diff --git a/.claude/skills/hugagent-backend-dev/references/api-envelope.md b/.claude/skills/hugagent-backend-dev/references/api-envelope.md
index 841012ce..a0d9525c 100644
--- a/.claude/skills/hugagent-backend-dev/references/api-envelope.md
+++ b/.claude/skills/hugagent-backend-dev/references/api-envelope.md
@@ -78,4 +78,8 @@ return error_response(code=20001, message="Bad request", status_code=400)
1. **路由中只用 `success_response` / `created_response` / `paginated_response`**
2. **错误通过抛 `AppException` 子类,由全局 handler 转 `error_response`**
3. **trace_id 默认自动生成,无需手动传**
-4. **SSE 端点(流式聊天)不使用信封,直接发 SSE 事件**
+4. **SSE 端点不使用信封**:用 `sse_response(generator, extra_headers=...)` 返回(自带 `Cache-Control: no-cache`、
+ `X-Accel-Buffering: no` 等 `SSE_HEADERS`);聊天回合的 SSE 输出统一走 `api/transports/run_sse.py`,不要自写一套
+5. **License 402** 只来自 `edition_ee/licensing/features.py` 的 `FeatureNotLicensed`(40201)/ `SeatLimitExceeded`(40202),
+ 路由与服务层不要手搓 402
+6. 时间字段序列化用 `.isoformat()`(模型存的是带时区的 UTC),不要输出无时区字符串
diff --git a/.claude/skills/hugagent-backend-dev/references/architecture.md b/.claude/skills/hugagent-backend-dev/references/architecture.md
index 5fda6ac0..306f4283 100644
--- a/.claude/skills/hugagent-backend-dev/references/architecture.md
+++ b/.claude/skills/hugagent-backend-dev/references/architecture.md
@@ -1,129 +1,164 @@
# 后端架构参考
-## 请求流转
+## 1. 顶层布局与依赖方向
```
-Browser → Nginx (:3000 /api/ proxy) → FastAPI (api/app.py)
- → api/middleware/ (CORS → Logging → Error Handler → license_gate)
- → api/routes/v1/*.py (路由层;CE 注册表 api/routes/v1/__init__.py,EE 注册表 edition_ee/routes/registry.py)
- → core/services/*.py (服务层 — 业务逻辑)
- → core/db/repository/ (仓库层 — 数据访问,按领域分文件)
- → core/db/models/ (ORM 包 → PostgreSQL/SQLite)
+src/backend/
+├── api/ HTTP 协议层:app.py(组装)、deps.py、middleware/、routes/v1/(CE_ROUTERS)、transports/run_sse.py、startup/
+├── core/ 内核:领域逻辑与可复用运行时(不导入 features / integrations / personal_agent / orchestration)
+├── orchestration/ 模式编排:plans/、loops/、automations/、workers/(周期任务与恢复)
+├── features/ 可安装的产品功能模块(各自 README + extension.py + models.py + api/)
+├── integrations/ 外部系统集成:channels(IM 机器人框架)、dingtalk、lark、email、yida、api
+├── personal_agent/ Personal Agent 模块(同样经 extension.py 接入)
+├── edition_ee/ 商业版专属:路由注册表、License、Team/RBAC、EE 表、KB provider、EE 服务(CE 树物理删除)
+├── extensions/ 随包资源:skills/、agents/、plugins/(default + marketplace)、mcp_servers/;根路径唯一来源 paths.py
+├── prompts/ 提示词装配运行时 + prompt_text/ 文件兜底
+├── hugagent_cli/ 命令行客户端
+├── alembic/ 迁移(EE 主链;CE 用独立 ce_0001 基线)
+└── tests/ pytest(按领域子目录:api/ chat/ core/ features// infra/ llm/ orchestration/ …)
```
-## 分层职责
+```
+api ──► orchestration ──► core
+ │ │ ▲
+ └──► features / integrations / personal_agent ──┘ (通过 core.harness.registry 注册钩子接入)
+edition_ee ──► core / features (CE 派生树中不存在)
+```
+
+- **core 永远不导入功能模块的实现**。功能模块在 `extension.setup()` 里调用 `core.harness.registry.register_*` 把能力挂进来;
+ 关闭时什么也不注册,core 的行为等同于未安装。
+- **不保留转发垫片**:模块搬家时直接改所有调用方的 import,不留旧路径 re-export(ADR 20261021)。
+- 单个模块 ≤ 500 行(存量超限文件已登记为例外,不要新增)。
+
+## 2. core/ 子包职责
+
+| 子包 | 职责 |
+|---|---|
+| `agent_runtime/` | 一次普通智能体回合:`runner.astream_chat_workflow` 选主智能体 / 直连路径;`prepare_*`(建 agent、组装输入)、`stream_*`(模型与工具事件 → 既有事件协议)、`*_state`(单轮有类型状态)、`integrations/`(记忆、压缩、本体证据/修复、演化) |
+| `runs/` | 持久化执行所有权:租约 / 心跳、取消、事件日志与续播(`events/`)、看门狗、工具副作用账本(`tool_effects/`)、恢复 |
+| `chat/` | 会话与消息读写、模式(modes)、分叉、引用、追问、压缩;`runtime/`(聊天 worker:消费回合事件、在 runs 租约下持久化输出、排队 / 插话 / 暂停 / 恢复);`operations/`、`projection/` |
+| `llm/` | AgentScope 适配与智能体装配:`factory/`(build / assembly / tools / prompts)、`runtime/`(流式循环、钩子适配、工具执行、人机交互)、`citations/`(锚点发号)、`mcp/`、`subagents/`、`tools/`、`tool_permissions/`、提示词版本池 |
+| `models/` | 模型供应商与选择:chat_models、providers、failover、reasoning effort、embedding / reranking、vision、用量 |
+| `capabilities/` | 能力生命周期:`catalog/`、`skills/`、`plugins/`(含插件资源)、`mcp/`、`agents/`、manifest 协议 |
+| `harness/` | 宿主接缝:`registry/`(功能模块注册钩子的唯一入口)、执行清单、观测事件、日志写入、用量 |
+| `auth/` | `backend.py`(`get_current_user` / `UserContext`)、`bearer.py`(mock/remote Bearer 异步校验 + 共享缓存)、session、本地账号、API Key、能力位、权限接口 |
+| `db/` | `engine.py`(Base / SessionLocal / `get_db` / 线程预算)、`threads.py`(`run_db` / `run_in_session` / `off_loop`)、`models/`(核心 ORM 包)、`repository/`、`utc_datetime.py`、各类升级 / 修复脚本 |
+| `config/` | `settings.py`(`settings` 冻结 dataclass:deploy / auth / sso / db / llm / memory / storage / kb / redis / server / sandbox / edition / branding …)、`catalog.json`、`mcp_config.py`、`display_names.py`、`runtime_env.py` |
+| `infra/` | `responses.py`、`exceptions.py`、`logging.py`、`time.py`(`utc_now`)、`background.py`(`spawn`)、redis、metrics、限流 / 熔断、leader 选举、加密、脱敏 |
+| `files/` | 产物(artifacts)、内容解析(content)、空间文件(spaces)、存储协议(storage:local / s3 / oss) |
+| `projects/` | 项目、项目环境、指令、本地工作区、个人项目作用域 |
+| `memory/` | 分层记忆(L1 画像 / L2 向量 / L3 图谱)、抽取流水线、outbox、脱敏、TTL |
+| `ontology/` | 本体治理:schema、校验、运行时闸门、输出评审、修订 |
+| `evolution/` | 协同进化共享契约与证据(控制面在 EE) |
+| `jobs/` | 后台作业运行时(作业 SDK、监督、通知、文件) |
+| `sandbox/` | 沙箱协议 / 工厂 / providers(script_runner、opensandbox、cube[EE])、runner 进程服务、进程 / 会话 / 工作区 / 权限 / OS 隔离 |
+
+## 3. 功能模块(features / integrations / personal_agent)
+
+`features/installed.py::INSTALLED_FEATURES` 是全量安装清单(CE 由 `ce/overlay/src/backend/features/installed.py` 覆盖)。
+
+| 模块 | 内容 |
+|---|---|
+| `features/automation` | 定时任务:调度、执行、投递目标、通知、远程副作用 |
+| `features/knowledge` | 自建知识库:解析、分块、向量、资产索引、Wiki;外部 provider 接缝 |
+| `features/marketplace` | 技能 / 智能体 / MCP 市场:目录、安装、发布、版本、导出 |
+| `features/workbench` | 可扩展工作台:应用目录、外部应用 / 嵌入 / 外部认证、插件 UI、资源实例、偏好、诊断、迁移 |
+| `features/hosted_apps` | 托管业务应用(SQL / MCP):部署、数据、发布、资源 |
+| `features/sites` | 站点托管与发布(静态服务、访问密码、打包、本机发布) |
+| `features/desktop` | 桌面双端:能力桥、网关、本机能力、登录桥接、可观测性 |
+| `features/evaluation` | 评测沙箱(严格 OpenSandbox) |
+| `features/distillation` · `security_console` · `datasources` · `sandbox_ops` | EE:技能 / 人格蒸馏、安全台、数据源、沙箱运维 |
+| `integrations/channels` + `dingtalk` / `lark` / `email` / `yida` | 入站渠道机器人框架与各平台适配 |
+| `personal_agent` | Personal Agent:会话、渠道、调度、投递、设备连接、工作区人格 |
+
+模块约定:
```
-┌─────────────────────────────────────────┐
-│ Routes (api/routes/v1/) │ ← HTTP 入口、参数校验、响应包装
-│ - 依赖注入: auth, db │
-│ - Pydantic 请求模型 │
-│ - _to_dict() 转换函数 │
-│ - 调用 Service │
-├─────────────────────────────────────────┤
-│ Services (core/services/) │ ← 业务逻辑、权限校验、审计
-│ - 构造函数接收 db: Session │
-│ - 内部创建 Repository │
-│ - 幂等操作 (ensure_xxx) │
-│ - 抛 AppException │
-├─────────────────────────────────────────┤
-│ Repository (core/db/repository/) │ ← 数据访问抽象(包,10 个领域文件)
-│ - CRUD 操作 │
-│ - 分页查询 │
-│ - 软删除过滤 │
-│ - 不含业务逻辑 │
-├─────────────────────────────────────────┤
-│ Models (core/db/models/) │ ← ORM 定义(包,17 个领域文件)
-│ - SQLAlchemy declarative │
-│ - 索引、约束、关系 │
-│ - EE 专属表登记 edition_ee/db/edition_tables.py │
-│ - Alembic 迁移 │
-└─────────────────────────────────────────┘
+features//
+├── README.md 负责什么、公开入口、依赖方向、版本差异
+├── extension.py NAME + setup() -> bool;可选 register_tools / register_gateway_contributions /
+│ register_catalog_options / register_retrieval_context
+├── models.py ORM(按安装清单加载,与运行开关无关)
+├── edition.py (可选)CE/EE 差异唯一入口,CE overlay 只覆盖它
+├── service.py / repository.py / <领域>.py
+└── api/ 路由文件(多数进 CE_ROUTERS / EE_ROUTERS,可关闭的由 setup() 内 register_router 挂载)
```
-## 聊天流式请求流转
+## 4. 启动与路由装配(api/app.py)
+
+1. `orchestration.configuration.register_configuration()`、`features.register_run_capabilities()`、
+ `register_model_credentials()`、`integrations.register_credentials()`
+2. 挂 health、`api/routes` 文件下载路由
+3. `iter_edition_routers(CE_ROUTERS)` 全部 include;`iter_edition_routers(EE_ROUTERS)` include 并附加
+ `edition_router_dependencies(policy_key)`(License 能力位,未授权 402)
+4. `register_product_tools()` / `register_catalog_options()` / `register_package_sources()` / `register_resource_owners()`
+5. `load_extensions(INSTALLED_FEATURES)`:先 `load_models`,再逐个 `.extension.setup()`;然后挂
+ `core.harness.registry.routers()` 里登记的模块路由
+6. `sites_serve_router`(静态站点兜底)→ 登录路由 → mock SSO(按登录模式)→ 本地模式前端静态托管(最后)
+
+注册表规则:
+
+- `CE_ROUTERS`(`api/routes/v1/__init__.py`):二元组 `(module, attr)`;短名解析为 `api.routes.v1.`,
+ 含点号的写绝对路径(`"features.sites.api.sites"`)。**顺序即注册顺序**,同前缀族的先后有语义(例:`plugin_ui` 必须在
+ `plugins` 之前,否则 `/v1/plugins/{slug}` 会吞掉 `/v1/plugins/ui-contributions`)
+- `EE_ROUTERS`(`edition_ee/routes/registry.py`):三元组 `(module, attr, policy_key)`;`policy_key` 取
+ `edition_ee/licensing/features.py::Feature` 的值(`content_admin` / `audit` / `multi_tenancy` / `system_config` /
+ `billing` / `model_gateway` / `evolution_control` …);`None` 仅用于 License 失效时也必须可达的入口(登录、换证)
+- `api.edition.EE_ROUTERS` 是懒加载接缝;CE overlay 把它换成空表
+- **不要**在 `app.py` 手工 `include_router()`
+
+## 5. 一次聊天回合的流转
```
-POST /v1/agents/responses
- → api/routes/v1/chats/__init__.py
- → orchestration/chat_run_executor.py # ChatRun + Redis Stream(后台 run,SSE 跟随,断线续播)
- → orchestration/workflow.py # 流式编排主入口
- → core/chat/modes.py # 解析对话模式 ChatModeSpec(standard/turbo/市场模式;装配契约)
- → orchestration/memory_integration.py # 检索分层记忆并注入(user-role 冻结块,600ms 预算)
- → core/llm/factory/build.py # 构建 AgentScope 2.0 ReActAgent(按 ChatModeSpec 收窄工具面)
- → core/llm/mcp/manager.py # MCP 客户端池(streamable-http → mcp 容器)
- → core/config/mcp_config.py # MCP server 定义(端口真源 mcp_servers/_ports.py)
- → core/llm/middlewares.py # AS2 中间件(动态模型、文件上下文、CitationAnchorMiddleware、OntologyGateMiddleware)
- → prompts/prompt_runtime.py # 装配系统提示词(DB 版本池优先,prompt_text/ 兜底)
- → orchestration/streaming.py # StreamingAgent:把 AS2 事件流映射为内部块事件
- → orchestration/citation_anchor.py # 引用锚点唯一发号(AnchorAllocator,e1/e2/…,经中间件回注工具结果)
- → core/memory/service.py # 流结束后后台保存记忆(L1/L2/L3)
- → orchestration/followups.py # 流结束后后台生成追问建议(写入消息 extra_data)
+POST /v1/agents/responses api/routes/v1/agent_responses.py(+ chats/ 子路由)
+ → 准入与回合登记 core/chat/runtime/admission.py · core/runs(租约、事件日志)
+ → 聊天 worker core/chat/runtime/worker.py(消费事件、按顺序持久化输出)
+ → core/agent_runtime/runner.astream_chat_workflow
+ → prepare_main / prepare_direct 建 agent、组装输入(模式 core/chat/modes.py、记忆 integrations/memory.py)
+ → core/llm/factory/build.py AgentScope agent:模型选择、工具集、MCP(core/llm/mcp)、技能、提示词
+ → stream_main / stream_direct 模型与工具事件 → 事件协议(core/llm/runtime/streaming.py、stream_events.py)
+ → core/llm/citations/ 引用锚点发号(e1/e2…)
+ → 结束后:本体评审、记忆 / 演化结算、追问(core/chat/runtime/followups.py)
+ → SSE 输出 api/transports/run_sse.py(跟随事件日志;续播 GET /v1/chats/stream/{run_id}/subscription)
```
-> `orchestration/strategy.py` 仍在但已退化为占位符(两个分支都返回 MainOnlyStrategy),不再是主链路环节。
-
-**平级编排入口**(不走普通 chat run 的独立驱动):
-
-| 入口 | 文件 | 说明 |
-|------|------|------|
-| 自主循环 | `orchestration/autonomous_loop.py` + `loop_planner.py` / `loop_evaluator.py` + `subagents/loop_reviewer.py` | driver 持有需求台账 feature_list.json,逐条注入 + 只读 reviewer 验收;路由 `v1/loops.py` |
-| 批量执行 | `orchestration/batch_orchestrator.py` | batch_confirm 确认后逐行执行 |
-| 计划模式 | `orchestration/subagents/plan_mode.py` | plan_generate / plan_execute run |
-| 渠道入站 | `core/channels/`(protocol/registry/manager/inbound/outbound) | IM 消息归一为 InboundMsg 后复用聊天编排,回复经 markdown.py 降级回发 |
-| 定时任务 | `orchestration/schedulers/`(automation / distillation_cron / evolution / memory_ttl) | croniter 调度 |
-
-SSE 事件(前端解析真源 `src/frontend/src/hooks/chatStream.ts`):
-
-- 生命周期:`run_started`(首帧,携带续播 run_id)、`meta`、`error`、`end` / `data: [DONE]`(终止)、心跳为 SSE 注释行
-- 正文/思考:`content`(delta 增量;别名 ai_message/text/delta)、`content_replace`、`thinking`、`compaction_notice`、`steer_applied`
-- 工具:`tool_call_start`、`tool_call_delta`(参数流式)、`tool_call`、`tool_result`、`tool_pending`、`model_progress`(活性信号)
-- 交互确认:`batch_confirm`、`file_confirm`、`design_pick`
-- 计划/子智能体:`plan_update`、`subagent_event`
-- 本体治理:`ontology_activation` / `ontology_gate` / `ontology_review` / `ontology_repair` / `ontology_revision` / `ontology_revision_thinking`
-
-断线续播:`GET /v1/chats/stream/{run_id}`。追问建议(follow_up_questions)流结束后后台生成,也可能以 `follow_up` 事件流内直送。
-
-## 模块索引
-
-| 模块 | 路径 | 职责 |
-|------|------|------|
-| App 入口 | `api/app.py` | FastAPI 实例、中间件;路由按注册表自动注册 |
-| CE 路由注册表 | `api/routes/v1/__init__.py` | **CE_ROUTERS 单一真源**(二元组);re-export EE_ROUTERS |
-| EE 路由注册表 | `edition_ee/routes/registry.py` | **EE_ROUTERS 单一真源**(三元组,带 license 能力位) |
-| 依赖注入 | `api/deps.py` | require_admin(ADMIN_TOKEN)、require_config(CONFIG_TOKEN)、require_admin_or_config、require_system_settings、require_super_admin |
-| 用户认证 | `core/auth/backend.py` | get_current_user / UserContext(桌面桥接 → session → API-Key → mock/remote) |
-| 健康检查 | `api/health.py` | /health, /ready, /live |
-| Schema | `api/schemas.py` | 请求/响应 Pydantic 模型 |
-| 路由 | `api/routes/v1/` | 66 个路由文件 |
-| 技能引擎 | `core/capabilities/skills/` | SKILL.md 解析、多源加载、{dir} 沙箱路径注入 |
-| 生成物 | `core/files/artifacts/` | 注册与下载(store.py,local/oss 双模) |
-| 认证 | `core/auth/` | backend.py, session.py, sso.py, permissions_iface.py(CE/EE 接缝) |
-| 渠道 | `core/channels/` | 入站机器人框架:钉钉/飞书/企微/微信 adapter;加渠道 = 写 adapter + registry.py 注册 |
-| 聊天 | `core/chat/` | context.py, tool_log.py |
-| 配置 | `core/config/` | settings.py + catalog 五件套(catalog.json/catalog.py/loader/resolver/migration)+ mcp_config.py |
-| 内容 | `core/files/content/` | 内容块、file_parser.py |
-| 数据库 | `core/db/` | engine.py, models/(17 领域文件), repository/(10 领域文件), model_extensions.py, edition_tables.py(垫片) |
-| 进化 | `core/evolution/` | GCE 共享契约(Episode/候选/结算);控制面在 edition_ee/evolution |
-| 基础设施 | `core/infra/` | exceptions, responses, logging, metrics, 限流, Redis, crypto |
-| 知识库 | `core/kb/` | 分块、向量化、混合检索 + wiki/(LLM-Wiki 管线)+ wiki_router.py + external_provider.py(外接接缝) |
-| License | `core/licensing/` | features.py(能力位+402)、manager.py(状态机) |
-| LLM | `core/llm/` | agent_factory(含 turbo 参数), chat_models, middlewares, mcp_manager/mcp_pool, offloader, subagent_tool, tools/ |
-| 记忆 | `core/memory/` | service.py(mem0)、pipeline.py、profile.py(L1)、sanitizer.py、audit.py |
-| 本体 | `core/ontology/` | Domain Pack 校验、运行时选取、工具闸(schemas/validator/revision/toolkit) |
-| 沙箱 | `core/sandbox/` | protocol.py + script_runner / opensandbox / cube 三实现 |
-| 服务 | `core/services/` | 70+ 业务服务(user/chat/chat_mode/channel/loop/kb/plugin/prompt_version/marketplace…) |
-| 存储 | `core/files/storage/` | protocol, local, s3, oss |
-| 编排 | `orchestration/` | workflow, chat_run_executor, streaming, citation_anchor, autonomous_loop, followups, batch_orchestrator, schedulers/, subagents/ |
-| 提示词 | `prompts/` | prompt_runtime + provider + prompt_text/{default,code_exec,distillation,plan_mode,subagents,turbo}/ |
-| MCP 服务器 | `mcp_servers/` | 12 个 server 目录、11 个在役(端口 9100–9114,真源 _ports.py;9105/9109-9111 为退役保留位) |
-| 技能资产 | `skill_bundles/` | default/(5 内置)+ marketplace/(40+ 可安装) |
-| EE 树 | `edition_ee/` | EE 专属路由/模型/KB provider(dify/fastgpt/weknora)/evolution 控制面;CE 派生树物理删除 |
-
-## 新路由注册步骤
-
-1. 创建 `api/routes/v1/my_feature.py`(EE 专属则放 `edition_ee/routes/`),文件内定义 `router = APIRouter(prefix="/v1/xxx", tags=["Xxx"])`
-2. 注册进对应注册表:
- - 通用功能 → `api/routes/v1/__init__.py` 的 `CE_ROUTERS` 加 `("my_feature", "router")`
- - 企业版功能 → `edition_ee/routes/registry.py` 的 `EE_ROUTERS` 加 `("edition_ee.routes.my_feature", "router", "")`(能力位 `None` 表示显式豁免 feature 守卫)
-3. **不要**在 `api/app.py` 手工 `include_router()` —— app 启动时经 `iter_edition_routers()` 按表自动注册,模块缺失(CE 派生树物理删除了 EE 文件)会被静默跳过
+平级编排入口(不走普通回合):
+
+| 入口 | 位置 |
+|---|---|
+| 计划模式 | `orchestration/plans/`(generate_run / execute_run / mode) |
+| 自主循环 | `orchestration/loops/`(driver / planner / reviewer / requirements / recovery) |
+| 定时任务派发 | `orchestration/automations/` + `features/automation` |
+| 周期任务 / 恢复 | `orchestration/workers/`(`_worker_base.py`、run_recovery、memory_ttl、evolution、蒸馏 cron) |
+| 渠道入站 | `integrations/channels/`(归一为入站消息后复用聊天回合) |
+
+## 6. 数据访问与事件循环
+
+- 同步 SQLAlchemy Session 的每次查询都是阻塞 I/O。
+- **只用 Session 不 await 的路由写 `def`**(FastAPI 线程池执行);`def` 路由里没有事件循环,不能 `create_task` / `get_running_loop`。
+- **要 await 网络的 async 代码不持有 Session**:每段 DB 工作写成同步函数,用 `await run_in_session(fn, ...)`(新开并关闭会话)
+ 或 `await run_db(fn, ...)`(已有会话)在线程里跑;需要 async 接口的纯 DB 函数用 `@off_loop` 包装。
+- 线程池与连接池按 `core.db.engine.thread_budget()` 同一预算配置(`apply_thread_budget()`)。
+- 跨请求存活的工作:落库交给 worker(`orchestration/workers/_worker_base.py`);事件循环上的临时任务用 `core.infra.background.spawn(coro, name=...)`。
+- 守卫测试:`tests/test_no_blocking_async_routes.py`、`tests/test_no_event_loop_in_sync_routes.py`、
+ `tests/infra/test_no_session_on_event_loop.py`(全仓零例外:任何 async 函数都不收 `db` / `Session` / `Depends(get_db)`、
+ 不调 `SessionLocal()`、不 `with … as db`;测试里覆盖 `get_db` 时同时用 `tests/db_support.py` 让 `run_in_session` 指向同一个库)。
+
+## 7. 模块索引(常用入口)
+
+| 主题 | 路径 |
+|---|---|
+| 应用组装 | `api/app.py`、`api/lifecycle.py`、`api/startup/` |
+| 路由注册表 | `api/routes/v1/__init__.py`(CE)、`edition_ee/routes/registry.py`(EE)、`core/harness/registry`(模块路由) |
+| 依赖注入 | `api/deps.py`(require_admin / require_config / require_admin_or_config / require_system_settings / require_ontology_governance / require_super_admin / console_actor) |
+| 认证 | `core/auth/backend.py`(get_current_user / UserContext)、`core/auth/bearer.py` |
+| DB | `core/db/engine.py`(get_db)、`core/db/threads.py`、`core/db/models/`、`core/db/repository/` |
+| 信封 / 异常 | `core/infra/responses.py`、`core/infra/exceptions.py`、`api/middleware/error_handler.py` |
+| 配置 | `core/config/settings.py`、`core/config/catalog.json` |
+| 智能体回合 | `core/agent_runtime/`、`core/llm/factory/`、`core/llm/runtime/` |
+| 运行与续播 | `core/runs/`、`core/chat/runtime/`、`api/transports/run_sse.py` |
+| MCP 服务 | `extensions/mcp_servers/`(`registry.py`、`_ports.py` 端口稳定契约、`_launcher.py`)、`core/config/mcp_config.py` |
+| 技能 / 插件 / 智能体资源 | `extensions/{skills,plugins,agents}/{default,marketplace}`、`core/capabilities/` |
+| 提示词 | `prompts/prompt_runtime.py`(DB 版本池优先,`prompt_text/` 兜底)、`core/llm/prompt_*.py` |
+| License | `edition_ee/licensing/`(features / deps / gate / manager)、`api/middleware/edition.py` |
+| CE 派生 | `ce/manifest.yaml`(排除)、`ce/overlay/`(覆盖接缝文件) |
diff --git a/.claude/skills/hugagent-backend-dev/references/error-codes.md b/.claude/skills/hugagent-backend-dev/references/error-codes.md
index 18149924..73d26851 100644
--- a/.claude/skills/hugagent-backend-dev/references/error-codes.md
+++ b/.claude/skills/hugagent-backend-dev/references/error-codes.md
@@ -35,6 +35,8 @@
|------|------|----------------|------|
| 40001 | 404 | `ResourceNotFoundError` | 资源未找到 |
| 40002 | 404 | `EndpointNotFoundError` | API 端点不存在 |
+| 40201 | 402 | `FeatureNotLicensed`(`edition_ee/licensing/features.py`) | License 未授权该能力(EE 路由能力位守卫) |
+| 40202 | 402 | `SeatLimitExceeded`(同上) | 席位不足 / License 无效,无法新增用户 |
| 41001 | 409 | `ResourceAlreadyExistsError` | 资源已存在 |
| 41002 | 409 | `ConcurrentModificationError` | 并发修改冲突 |
| 42001 | 429 | `RateLimitExceededError` | 速率限制 |
diff --git a/.claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh b/.claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh
index 313654df..ff2ca623 100755
--- a/.claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh
+++ b/.claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh
@@ -1,83 +1,108 @@
#!/usr/bin/env bash
#
-# 后端新功能脚手架生成器
+# 后端功能模块脚手架:生成 features// 完整骨架
#
# 用法:
-# bash .claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh
+# bash .claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh
#
# 示例:
# bash .claude/skills/hugagent-backend-dev/scripts/scaffold_feature.sh bookmark Bookmark bookmarks
#
-# 生成文件:
-# - src/backend/api/routes/v1/s.py (路由)
-# - src/backend/core/services/_service.py (服务)
-# - src/backend/tests/test_.py (测试)
+# 生成:
+# src/backend/features//__init__.py
+# src/backend/features//README.md 职责、公开入口、依赖方向
+# src/backend/features//extension.py NAME + setup()
+# src/backend/features//models.py ORM(UTCDateTime + utc_now + JSONType)
+# src/backend/features//repository.py
+# src/backend/features//service.py
+# src/backend/features//api/__init__.py
+# src/backend/features//api/s.py 路由(同步 def + run_in_session 示例)
+# src/backend/tests/features//__init__.py
+# src/backend/tests/features//test_.py
#
-# 注意: model 和 repository 需手动合并到对应领域文件中
-# - core/db/models/<领域>.py ← 添加 ORM 类(并在包 __init__.py re-export)
-# - core/db/repository/<领域>.py ← 添加 Repository 类
-# - api/routes/v1/__init__.py ← 注册进 CE_ROUTERS / EE_ROUTERS 注册表
-# - 运行: alembic revision --autogenerate -m "add ${table_name} table"
+# 只建新文件,已存在的文件跳过;不改任何注册表(手动步骤见脚本末尾输出)。
+# 不是所有新功能都该进 features/:先按 SKILL.md「放哪里」判断。
set -euo pipefail
-FEATURE="${1:?Usage: $0 }"
-FEATURE_CAP="${2:?Usage: $0 }"
-TABLE="${3:?Usage: $0 }"
+FEATURE="${1:?Usage: $0 }"
+FEATURE_CAP="${2:?Usage: $0 }"
+TABLE="${3:?Usage: $0 }"
+FEATURE_UPPER="$(echo "$FEATURE" | tr '[:lower:]' '[:upper:]')"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/../templates"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../../" && pwd)"
BACKEND="$PROJECT_ROOT/src/backend"
+PKG="$BACKEND/features/$FEATURE"
+TESTS="$BACKEND/tests/features/$FEATURE"
-echo "=== HugAgentOS Backend Scaffold ==="
-echo "Feature: $FEATURE / $FEATURE_CAP"
-echo "Table: $TABLE"
-echo ""
-
-# Route file
-ROUTE_FILE="$BACKEND/api/routes/v1/${FEATURE}s.py"
-if [ ! -f "$ROUTE_FILE" ]; then
+render() { # render
+ local src="$1" dest="$2"
+ if [ -f "$dest" ]; then
+ echo "⊘ Skipped ${dest#$PROJECT_ROOT/} (exists)"
+ return
+ fi
sed -e "s/\${feature}/$FEATURE/g" \
-e "s/\${Feature}/$FEATURE_CAP/g" \
- -e "s/\${FEATURE_NAME}/$FEATURE_CAP/g" \
- "$TEMPLATE_DIR/route.py" > "$ROUTE_FILE"
- echo "✓ Created $ROUTE_FILE"
-else
- echo "⊘ Skipped $ROUTE_FILE (exists)"
-fi
+ -e "s/\${FEATURE}/$FEATURE_UPPER/g" \
+ -e "s/\${table_name}/$TABLE/g" \
+ "$src" > "$dest"
+ echo "✓ Created ${dest#$PROJECT_ROOT/}"
+}
-# Service file
-SERVICE_FILE="$BACKEND/core/services/${FEATURE}_service.py"
-if [ ! -f "$SERVICE_FILE" ]; then
- sed -e "s/\${feature}/$FEATURE/g" \
- -e "s/\${Feature}/$FEATURE_CAP/g" \
- "$TEMPLATE_DIR/service.py" > "$SERVICE_FILE"
- echo "✓ Created $SERVICE_FILE"
-else
- echo "⊘ Skipped $SERVICE_FILE (exists)"
-fi
-
-# Test file
-TEST_FILE="$BACKEND/tests/test_${FEATURE}.py"
-if [ ! -f "$TEST_FILE" ]; then
- sed -e "s/\${feature}/$FEATURE/g" \
- -e "s/\${Feature}/$FEATURE_CAP/g" \
- "$TEMPLATE_DIR/test.py" > "$TEST_FILE"
- echo "✓ Created $TEST_FILE"
-else
- echo "⊘ Skipped $TEST_FILE (exists)"
-fi
+touch_file() { # touch_file
+ if [ -f "$1" ]; then
+ echo "⊘ Skipped ${1#$PROJECT_ROOT/} (exists)"
+ else
+ printf '%s\n' "$2" > "$1"
+ echo "✓ Created ${1#$PROJECT_ROOT/}"
+ fi
+}
+echo "=== HugAgentOS backend feature scaffold ==="
+echo "Feature: $FEATURE / $FEATURE_CAP Table: $TABLE"
echo ""
-echo "=== 手动步骤 ==="
-echo "1. 将 model 模板合并到 core/db/models/<领域>.py(替换 \${Feature}=$FEATURE_CAP, \${table_name}=$TABLE),并在包 __init__.py re-export"
-echo "2. 将 repository 模板合并到 core/db/repository/<领域>.py(替换 \${Feature}=$FEATURE_CAP)"
-echo "3. 在 api/routes/v1/__init__.py 的注册表中添加:"
-echo " CE_ROUTERS += ((\"${FEATURE}s\", \"router\"),) # 或 EE_ROUTERS(带 license 能力位)"
-echo "4. 创建迁移:"
-echo " alembic revision --autogenerate -m \"add $TABLE table\""
-echo "5. 应用迁移:"
-echo " alembic upgrade head"
-echo ""
-echo "Done!"
+
+mkdir -p "$PKG/api" "$TESTS"
+
+touch_file "$PKG/__init__.py" "\"\"\"${FEATURE_CAP} feature module.\"\"\""
+touch_file "$PKG/api/__init__.py" ""
+touch_file "$TESTS/__init__.py" ""
+touch_file "$PKG/README.md" "# ${FEATURE_CAP}
+
+负责:(一句话写清本模块拥有的业务能力与数据)。
+
+公开入口:service、repository、api/${FEATURE}s.py。调用方直接依赖实际模块,不保留旧路径转发。
+
+依赖方向:本模块依赖 core(db / auth / infra / harness registry);core 不导入本模块,只通过 extension.setup()
+注册的钩子接入。模型由安装清单加载,不依赖运行开关。
+
+版本差异:如 CE/EE 行为不同,统一放 edition.py,CE 只覆盖该入口。"
+
+render "$TEMPLATE_DIR/extension.py" "$PKG/extension.py"
+render "$TEMPLATE_DIR/model.py" "$PKG/models.py"
+render "$TEMPLATE_DIR/repository.py" "$PKG/repository.py"
+render "$TEMPLATE_DIR/service.py" "$PKG/service.py"
+render "$TEMPLATE_DIR/route.py" "$PKG/api/${FEATURE}s.py"
+render "$TEMPLATE_DIR/test.py" "$TESTS/test_${FEATURE}.py"
+
+cat < bool called once at startup; register hooks/routers/workers and return True,
+ or register nothing and return False when switched off (env flag etc.)
+ - Optional hooks the host looks up by name:
+ register_tools() agent tool contributions (product tool composition)
+ register_gateway_contributions() desktop gateway upload/receipt channels
+ register_catalog_options() capability catalog options
+ register_retrieval_context() retrieval context providers
+ - models.py is imported for every INSTALLED_FEATURES entry regardless of setup() — schema registration
+ must not depend on runtime switches
+
+Core never imports a feature's implementation: features call core.harness.registry.* to plug in.
+"""
+
+import os
+
+NAME = "${feature}"
+
+
+def enabled() -> bool:
+ return os.getenv("${FEATURE}_ENABLED", "true").strip().lower() in ("1", "true", "yes", "on")
+
+
+def setup() -> bool:
+ if not enabled():
+ return False
+ # Routes that must disappear when the module is off are mounted here instead of CE_ROUTERS.
+ # Always-on routes go into api/routes/v1/__init__.py::CE_ROUTERS (ordering is explicit there).
+ from core.harness.registry import register_router
+ from features.${feature}.api.${feature}s import router
+
+ register_router(router) # optional policy_key="content_admin" etc. applies the EE edition gate
+ return True
diff --git a/.claude/skills/hugagent-backend-dev/templates/model.py b/.claude/skills/hugagent-backend-dev/templates/model.py
index b591cc07..d3ad9d55 100644
--- a/.claude/skills/hugagent-backend-dev/templates/model.py
+++ b/.claude/skills/hugagent-backend-dev/templates/model.py
@@ -1,74 +1,58 @@
"""ORM model template.
Replace ${Feature}, ${feature}, ${table_name} with actual names.
-Copy relevant parts into core/db/models/<领域>.py (并在包 __init__.py re-export).
+
+Where it goes:
+ - Feature module → features/${feature}/models.py (loaded via INSTALLED_FEATURES by app and alembic)
+ - Core domain → core/db/models/.py and re-export in core/db/models/__init__.py
+ - EE-only table → edition_ee/db/models/… and add the table name to edition_ee/db/edition_tables.py::EE_ONLY_TABLES
+
+Conventions enforced by tests (tests/test_utc_conventions.py, tests/test_db_import_order.py):
+ - Timestamps: UTCDateTime(timezone=True) + default=utc_now (never datetime.utcnow / naive datetimes)
+ - JSON: JSONType (JSON on SQLite, JSONB on PostgreSQL) — models must work on both
+ - Soft delete via deleted_at; FKs declare ondelete
+ - Model modules import only core.db / core.infra leaves, never services or routes
"""
-from datetime import datetime
-from sqlalchemy import (
- Column, String, Integer, Boolean, Text, TIMESTAMP,
- ForeignKey, CheckConstraint, Index,
-)
-from sqlalchemy.dialects.postgresql import JSONB
-from sqlalchemy.orm import relationship
from core.db.engine import Base
+from core.db.models.column_types import JSONType
+from core.db.utc_datetime import UTCDateTime
+from core.infra.time import utc_now
+from sqlalchemy import Boolean, CheckConstraint, Column, ForeignKey, Index, Integer, String, Text
class ${Feature}(Base):
- """${Feature} table."""
+ """${Feature} — one sentence on what a row represents and who owns it."""
+
__tablename__ = "${table_name}"
- # --- Primary key ---
id = Column(String(64), primary_key=True)
-
- # --- Foreign key (to users_shadow) ---
user_id = Column(
String(64),
ForeignKey("users_shadow.user_id", ondelete="CASCADE"),
nullable=False,
)
+ # Optional link that must survive parent deletion → SET NULL
+ project_id = Column(
+ String(64),
+ ForeignKey("projects.project_id", ondelete="SET NULL"),
+ nullable=True,
+ )
- # --- Business fields ---
name = Column(String(200), nullable=False)
description = Column(Text)
- status = Column(String(20), nullable=False, default="active")
- count = Column(Integer, default=0)
- is_active = Column(Boolean, default=True)
-
- # --- Flexible JSONB ---
- extra_data = Column("metadata", JSONB, default={})
+ position = Column(Integer, nullable=False, default=0)
+ is_enabled = Column(Boolean, nullable=False, default=True)
- # --- Soft delete ---
- deleted_at = Column(TIMESTAMP(timezone=True))
-
- # --- Timestamps (required) ---
- created_at = Column(TIMESTAMP(timezone=True), default=datetime.utcnow)
- updated_at = Column(
- TIMESTAMP(timezone=True),
- default=datetime.utcnow,
- onupdate=datetime.utcnow,
- )
+ # Python attribute extra_data, DB column "metadata" (metadata is reserved on declarative models)
+ extra_data = Column("metadata", JSONType, default=dict)
- # --- Relationships ---
- user = relationship("UserShadow", back_populates="${feature}s")
+ created_at = Column(UTCDateTime(timezone=True), default=utc_now)
+ updated_at = Column(UTCDateTime(timezone=True), default=utc_now, onupdate=utc_now)
+ deleted_at = Column(UTCDateTime(timezone=True))
- # --- Indexes & Constraints ---
__table_args__ = (
- CheckConstraint("count >= 0", name="${table_name}_count_check"),
+ CheckConstraint("position >= 0", name="${table_name}_position_check"),
Index("idx_${table_name}_user_id", "user_id"),
Index("idx_${table_name}_updated_at", "updated_at"),
- Index(
- "idx_${table_name}_user_updated",
- "user_id", "updated_at",
- ),
- Index(
- "idx_${table_name}_deleted",
- "deleted_at",
- postgresql_where=Column("deleted_at").isnot(None),
- ),
- Index(
- "idx_${table_name}_metadata_gin",
- "metadata",
- postgresql_using="gin",
- ),
)
diff --git a/.claude/skills/hugagent-backend-dev/templates/repository.py b/.claude/skills/hugagent-backend-dev/templates/repository.py
index fdebaa87..fa006a57 100644
--- a/.claude/skills/hugagent-backend-dev/templates/repository.py
+++ b/.claude/skills/hugagent-backend-dev/templates/repository.py
@@ -1,83 +1,57 @@
-"""Repository template.
+"""Repository template — data access only, no business rules.
Replace ${Feature}, ${feature} with actual names.
-Add to core/db/repository/<领域>.py.
+Feature module: features/${feature}/repository.py. Core domain: core/db/repository/.py.
+
+Rules:
+ - Synchronous; takes a Session. Called from sync routes or inside core.db.threads.run_in_session
+ - Every read filters deleted_at IS NULL
+ - Time via core.infra.time.utc_now
"""
-from typing import Optional, List, Dict, Any, Tuple
-from datetime import datetime
-from sqlalchemy.orm import Session
+from typing import Any, Dict, List, Optional, Tuple
+
+from core.infra.time import utc_now
+from features.${feature}.models import ${Feature}
from sqlalchemy import desc
-from core.db.models import ${Feature}
+from sqlalchemy.orm import Session
class ${Feature}Repository:
- """Repository for ${feature} operations."""
-
def __init__(self, db: Session):
self.db = db
- # --- Read ---
+ def _live(self):
+ return self.db.query(${Feature}).filter(${Feature}.deleted_at.is_(None))
- def get_by_id(self, id: str) -> Optional[${Feature}]:
- """Get by primary key (respects soft delete)."""
- return self.db.query(${Feature}).filter(
- ${Feature}.id == id,
- ${Feature}.deleted_at.is_(None),
- ).first()
+ def get_by_id(self, item_id: str) -> Optional[${Feature}]:
+ return self._live().filter(${Feature}.id == item_id).first()
- def list_by_user(
- self,
- user_id: str,
- page: int = 1,
- page_size: int = 20,
- ) -> Tuple[List[${Feature}], int]:
- """Paginated list for user."""
- query = self.db.query(${Feature}).filter(
- ${Feature}.user_id == user_id,
- ${Feature}.deleted_at.is_(None),
- )
+ def list_by_user(self, user_id: str, page: int = 1, page_size: int = 20) -> Tuple[List[${Feature}], int]:
+ query = self._live().filter(${Feature}.user_id == user_id)
total = query.count()
items = (
- query
- .order_by(desc(${Feature}.updated_at))
+ query.order_by(desc(${Feature}.updated_at))
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return items, total
- # --- Create ---
-
def create(self, data: Dict[str, Any]) -> ${Feature}:
- """Create and commit."""
item = ${Feature}(**data)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item
- # --- Update ---
-
- def update(self, id: str, data: Dict[str, Any]) -> Optional[${Feature}]:
- """Update fields and commit."""
- item = self.get_by_id(id)
- if not item:
- return None
+ def update(self, item: ${Feature}, data: Dict[str, Any]) -> ${Feature}:
for key, value in data.items():
setattr(item, key, value)
- item.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(item)
return item
- # --- Delete (soft) ---
-
- def soft_delete(self, id: str) -> bool:
- """Mark as deleted."""
- item = self.get_by_id(id)
- if not item:
- return False
- item.deleted_at = datetime.utcnow()
+ def soft_delete(self, item: ${Feature}) -> None:
+ item.deleted_at = utc_now()
self.db.commit()
- return True
diff --git a/.claude/skills/hugagent-backend-dev/templates/route.py b/.claude/skills/hugagent-backend-dev/templates/route.py
index 279a688d..d584e541 100644
--- a/.claude/skills/hugagent-backend-dev/templates/route.py
+++ b/.claude/skills/hugagent-backend-dev/templates/route.py
@@ -1,136 +1,126 @@
-"""${FEATURE_NAME} API routes (v1).
-
-Replace ${FEATURE_NAME}, ${feature}, ${Feature} with actual names.
+"""API route template.
+
+Replace ${Feature}, ${feature} with actual names.
+Feature module: features/${feature}/api/${feature}s.py, registered in api/routes/v1/__init__.py::CE_ROUTERS as
+("features.${feature}.api.${feature}s", "router") — or in edition_ee/routes/registry.py::EE_ROUTERS with a policy key,
+or via register_router() from features/${feature}/extension.py when the module can be switched off.
+
+Event-loop rules (enforced by tests/test_no_blocking_async_routes.py, tests/test_no_event_loop_in_sync_routes.py,
+tests/infra/test_no_session_on_event_loop.py):
+ 1. Endpoint uses a Session and awaits nothing → declare it `def` (FastAPI runs it in the thread pool).
+ 2. Endpoint must await network I/O → `async def` WITHOUT `db: Session = Depends(get_db)`; put each DB step in a
+ sync helper and call it with `await run_in_session(helper, ...)`. Never hold a Session across an await.
+ 3. A `def` endpoint has no event loop: no asyncio.create_task / get_running_loop. Durable work → persist and let a
+ worker pick it up (orchestration/workers/_worker_base.py); loop-bound fire-and-forget → core.infra.background.spawn.
+
+Module must import on its own without composing the app (tests/test_route_import_order.py).
"""
-from typing import Any, Dict, List, Optional
+from typing import Optional
+from core.auth.backend import UserContext, get_current_user
+from core.db.engine import get_db
+from core.db.threads import run_in_session
+from core.infra.responses import created_response, paginated_response, success_response
from fastapi import APIRouter, Depends, Query, status
-from pydantic import BaseModel, Field
+from features.${feature}.service import ${Feature}Service
+from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
-from api.deps import require_admin
-from core.auth.backend import get_current_user, UserContext
-from core.db.engine import get_db
-from core.infra.exceptions import ResourceNotFoundError, BadRequestError
-from core.infra.logging import get_logger
-from core.infra.responses import success_response, created_response, paginated_response
-from core.services.${feature}_service import ${Feature}Service
-
-logger = get_logger(__name__)
+router = APIRouter(prefix="/v1/${feature}s", tags=["${Feature}"])
-router = APIRouter(prefix="/v1/${feature}s", tags=["${Feature}s"])
-
-
-# ---------------------------------------------------------------------------
-# Request / Response Models
-# ---------------------------------------------------------------------------
class Create${Feature}Request(BaseModel):
- """创建 ${Feature} 请求体."""
- name: str = Field(..., description="名称", min_length=1, max_length=200)
- description: Optional[str] = Field(None, description="描述")
- metadata: Optional[dict] = Field(default_factory=dict, description="扩展信息")
+ name: str = Field(..., min_length=1, max_length=200, description="名称")
+ description: Optional[str] = Field(None, max_length=2000, description="说明")
+ @field_validator("name")
+ @classmethod
+ def _strip(cls, v: str) -> str:
+ return v.strip()
-class Update${Feature}Request(BaseModel):
- """更新 ${Feature} 请求体."""
- name: Optional[str] = Field(None, description="名称", max_length=200)
- description: Optional[str] = Field(None, description="描述")
- metadata: Optional[dict] = Field(None, description="扩展信息")
+class Update${Feature}Request(BaseModel):
+ name: Optional[str] = Field(None, min_length=1, max_length=200)
+ description: Optional[str] = Field(None, max_length=2000)
+ is_enabled: Optional[bool] = None
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
def _to_dict(item) -> dict:
- """ORM → API response dict."""
return {
"id": item.id,
"name": item.name,
"description": item.description,
- "metadata": item.extra_data or {},
+ "is_enabled": item.is_enabled,
"created_at": item.created_at.isoformat() if item.created_at else None,
"updated_at": item.updated_at.isoformat() if item.updated_at else None,
}
-# ---------------------------------------------------------------------------
-# Endpoints
-# ---------------------------------------------------------------------------
+# --- Pure DB endpoints: plain `def` -----------------------------------------------------------
+
-@router.get("", summary="获取${Feature}列表")
-async def list_items(
+@router.get("", summary="列出我的${Feature}")
+def list_items(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
- service = ${Feature}Service(db)
- items, total, total_pages = service.list_items(user.user_id, page, page_size)
+ items, total = ${Feature}Service(db).list_items(str(user.user_id), page, page_size)
return paginated_response(
- items=[_to_dict(i) for i in items],
- page=page,
- page_size=page_size,
- total_items=total,
+ items=[_to_dict(i) for i in items], page=page, page_size=page_size, total_items=total
)
@router.get("/{item_id}", summary="获取${Feature}详情")
-async def get_item(
- item_id: str,
- user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
-):
- service = ${Feature}Service(db)
- item = service.get_item(item_id, user.user_id)
- if not item:
- raise ResourceNotFoundError("${feature}", item_id)
- return success_response(data=_to_dict(item))
+def get_item(item_id: str, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)):
+ return success_response(data=_to_dict(${Feature}Service(db).get_owned(item_id, str(user.user_id))))
-@router.post("", status_code=status.HTTP_201_CREATED, summary="创建${Feature}")
-async def create_item(
+@router.post("", status_code=status.HTTP_201_CREATED, summary="新建${Feature}")
+def create_item(
body: Create${Feature}Request,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
- service = ${Feature}Service(db)
- item = service.create(
- user_id=user.user_id,
- name=body.name,
- description=body.description,
- metadata=body.metadata,
- )
+ item = ${Feature}Service(db).create(str(user.user_id), body.name, body.description)
return created_response(data=_to_dict(item))
-@router.put("/{item_id}", summary="更新${Feature}")
-async def update_item(
+@router.patch("/{item_id}", summary="修改${Feature}")
+def update_item(
item_id: str,
body: Update${Feature}Request,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
- service = ${Feature}Service(db)
- update_data = body.model_dump(exclude_none=True)
- if not update_data:
- raise BadRequestError("No fields to update")
- item = service.update(item_id, user.user_id, update_data)
- if not item:
- raise ResourceNotFoundError("${feature}", item_id)
+ item = ${Feature}Service(db).update(item_id, str(user.user_id), body.model_dump(exclude_unset=True))
return success_response(data=_to_dict(item))
@router.delete("/{item_id}", summary="删除${Feature}")
-async def delete_item(
- item_id: str,
- user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
-):
- service = ${Feature}Service(db)
- ok = service.delete(item_id, user.user_id)
- if not ok:
- raise ResourceNotFoundError("${feature}", item_id)
- return success_response(message="Deleted")
+def delete_item(item_id: str, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)):
+ ${Feature}Service(db).delete(item_id, str(user.user_id))
+ return success_response(data={"id": item_id})
+
+
+# --- Endpoint that awaits network I/O: async, DB steps off the loop ---------------------------
+
+
+def _load_for_sync(db: Session, item_id: str, user_id: str) -> dict:
+ return _to_dict(${Feature}Service(db).get_owned(item_id, user_id))
+
+
+def _record_sync_result(db: Session, item_id: str, user_id: str, remote: dict) -> dict:
+ item = ${Feature}Service(db).update(item_id, user_id, {"description": remote.get("summary")})
+ return _to_dict(item)
+
+
+@router.post("/{item_id}/sync", summary="与外部系统同步")
+async def sync_item(item_id: str, user: UserContext = Depends(get_current_user)):
+ user_id = str(user.user_id)
+ current = await run_in_session(_load_for_sync, item_id, user_id) # DB step 1, session closed after
+ remote = {"summary": current["name"]} # await some_client.fetch(...) here
+ data = await run_in_session(_record_sync_result, item_id, user_id, remote) # DB step 2
+ return success_response(data=data)
diff --git a/.claude/skills/hugagent-backend-dev/templates/service.py b/.claude/skills/hugagent-backend-dev/templates/service.py
index a2e22666..5d02f65f 100644
--- a/.claude/skills/hugagent-backend-dev/templates/service.py
+++ b/.claude/skills/hugagent-backend-dev/templates/service.py
@@ -1,108 +1,60 @@
-"""Service layer template.
+"""Service template — business rules and permission checks.
Replace ${Feature}, ${feature} with actual names.
-Create as core/services/${feature}_service.py.
+Feature module: features/${feature}/service.py. Core domain: core//.py
+(there is no core/services/ package any more — services live with the domain that owns them).
+
+Rules:
+ - Synchronous; constructor takes a Session and builds its repositories
+ - Raises core.infra.exceptions.* (never HTTPException, never returns error dicts)
+ - Ownership / permission checks live here, not in the route
+ - No network I/O inside a method that holds the Session: do DB work, return, await network
+ in the async caller, then open a new DB step (core.db.threads)
+ - Module ≤ 500 lines; split by responsibility before it grows past that
"""
import uuid
-from typing import Optional, List, Dict, Any, Tuple
-from sqlalchemy.orm import Session
+from typing import List, Optional, Tuple
-from core.db.repository import ${Feature}Repository, AuditLogRepository
-from core.db.models import ${Feature}
-from core.infra.exceptions import ResourceOwnershipError
+from core.infra.exceptions import BadRequestError, ResourceNotFoundError, ResourceOwnershipError
+from features.${feature}.models import ${Feature}
+from features.${feature}.repository import ${Feature}Repository
+from sqlalchemy.orm import Session
class ${Feature}Service:
- """Business logic for ${feature}s."""
-
def __init__(self, db: Session):
self.db = db
self.repo = ${Feature}Repository(db)
- self.audit_repo = AuditLogRepository(db)
-
- # --- Create ---
-
- def create(
- self,
- user_id: str,
- name: str,
- description: str = None,
- metadata: dict = None,
- ) -> ${Feature}:
- """Create a new ${feature}."""
- item = self.repo.create({
- "id": f"${feature}_{uuid.uuid4().hex[:16]}",
- "user_id": user_id,
- "name": name,
- "description": description,
- "extra_data": metadata or {},
- })
-
- self.audit_repo.create({
- "user_id": user_id,
- "action": "${feature}.created",
- "resource_type": "${feature}",
- "resource_id": item.id,
- "status": "success",
- })
-
- return item
-
- # --- Read ---
- def get_item(self, id: str, user_id: str) -> Optional[${Feature}]:
- """Get item with ownership check."""
- item = self.repo.get_by_id(id)
- if item and item.user_id != user_id:
- raise ResourceOwnershipError("${feature}", id)
- return item
-
- def list_items(
- self,
- user_id: str,
- page: int = 1,
- page_size: int = 20,
- ) -> Tuple[List[${Feature}], int, int]:
- """List with pagination. Returns (items, total, total_pages)."""
- items, total = self.repo.list_by_user(user_id, page, page_size)
- total_pages = (total + page_size - 1) // page_size
- return items, total, total_pages
-
- # --- Update ---
-
- def update(
- self,
- id: str,
- user_id: str,
- update_data: Dict[str, Any],
- ) -> Optional[${Feature}]:
- """Update with ownership check."""
- item = self.repo.get_by_id(id)
- if not item:
- return None
+ def get_owned(self, item_id: str, user_id: str) -> ${Feature}:
+ item = self.repo.get_by_id(item_id)
+ if item is None:
+ raise ResourceNotFoundError("${feature}", item_id)
if item.user_id != user_id:
- raise ResourceOwnershipError("${feature}", id)
- return self.repo.update(id, update_data)
-
- # --- Delete ---
-
- def delete(self, id: str, user_id: str) -> bool:
- """Soft delete with ownership check."""
- item = self.repo.get_by_id(id)
- if not item:
- return False
- if item.user_id != user_id:
- raise ResourceOwnershipError("${feature}", id)
- return self.repo.soft_delete(id)
-
- # --- Idempotent ensure ---
+ raise ResourceOwnershipError("${feature}", item_id)
+ return item
- def ensure(self, id: str, user_id: str, **defaults) -> Optional[${Feature}]:
- """Get existing or create (idempotent)."""
- existing = self.repo.get_by_id(id)
- if existing:
- if existing.user_id != user_id:
- return None
- return existing
- return self.create(user_id=user_id, **defaults)
+ def list_items(self, user_id: str, page: int, page_size: int) -> Tuple[List[${Feature}], int]:
+ return self.repo.list_by_user(user_id, page, page_size)
+
+ def create(self, user_id: str, name: str, description: Optional[str] = None) -> ${Feature}:
+ name = name.strip()
+ if not name:
+ raise BadRequestError("名称不能为空")
+ return self.repo.create(
+ {
+ "id": f"${feature}_{uuid.uuid4().hex[:16]}",
+ "user_id": user_id,
+ "name": name,
+ "description": description,
+ }
+ )
+
+ def update(self, item_id: str, user_id: str, changes: dict) -> ${Feature}:
+ item = self.get_owned(item_id, user_id)
+ allowed = {k: v for k, v in changes.items() if k in {"name", "description", "is_enabled"} and v is not None}
+ return self.repo.update(item, allowed)
+
+ def delete(self, item_id: str, user_id: str) -> None:
+ self.repo.soft_delete(self.get_owned(item_id, user_id))
diff --git a/.claude/skills/hugagent-backend-dev/templates/test.py b/.claude/skills/hugagent-backend-dev/templates/test.py
index aa456b7b..6f3b1a33 100644
--- a/.claude/skills/hugagent-backend-dev/templates/test.py
+++ b/.claude/skills/hugagent-backend-dev/templates/test.py
@@ -1,115 +1,86 @@
"""Test template.
Replace ${Feature}, ${feature} with actual names.
-Create as tests/test_${feature}.py.
-Run: PYTHONPATH=src/backend pytest src/backend/tests/test_${feature}.py -v
+Create as src/backend/tests/features/${feature}/test_${feature}.py (core domains: tests//test_*.py).
+Run: PYTHONPATH=src/backend pytest src/backend/tests/features/${feature} -q
+
+Rules:
+ - Test behaviour at a real boundary (service against a real DB session, or HTTP through the router);
+ do not assert internal call order
+ - The shared `db_session` fixture (tests/conftest.py) gives a fresh database per test (SQLite unless
+ TEST_DATABASE_URL points at PostgreSQL); schema changes should pass on both
+ - Write the failing test first; never weaken an assertion to make it pass
"""
import pytest
-from unittest.mock import MagicMock, patch
-from sqlalchemy import create_engine
-from sqlalchemy.orm import sessionmaker
+from core.infra.exceptions import BadRequestError, ResourceNotFoundError, ResourceOwnershipError
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from features.${feature}.service import ${Feature}Service
-from core.db.engine import Base
-from core.db.models import ${Feature}
-from core.db.repository import ${Feature}Repository
-from core.services.${feature}_service import ${Feature}Service
+@pytest.fixture
+def service(db_session):
+ return ${Feature}Service(db_session)
-# ---------------------------------------------------------------------------
-# Fixtures
-# ---------------------------------------------------------------------------
-@pytest.fixture
-def db_session():
- """In-memory SQLite session for testing."""
- engine = create_engine("sqlite:///:memory:")
- Base.metadata.create_all(engine)
- Session = sessionmaker(bind=engine)
- session = Session()
- yield session
- session.close()
- Base.metadata.drop_all(engine)
+def test_create_and_list(service):
+ created = service.create("user_a", " 周报 ")
+ assert created.name == "周报"
+ items, total = service.list_items("user_a", 1, 20)
+ assert total == 1 and items[0].id == created.id
-@pytest.fixture
-def service(db_session):
- """${Feature}Service with test DB session."""
- return ${Feature}Service(db_session)
+def test_rejects_blank_name(service):
+ with pytest.raises(BadRequestError):
+ service.create("user_a", " ")
+
+
+def test_other_users_cannot_read_or_delete(service):
+ item = service.create("user_a", "私有")
+ with pytest.raises(ResourceOwnershipError):
+ service.get_owned(item.id, "user_b")
+ with pytest.raises(ResourceOwnershipError):
+ service.delete(item.id, "user_b")
+
+
+def test_soft_delete_hides_item(service):
+ item = service.create("user_a", "待删")
+ service.delete(item.id, "user_a")
+ with pytest.raises(ResourceNotFoundError):
+ service.get_owned(item.id, "user_a")
+ assert service.list_items("user_a", 1, 20)[1] == 0
+
+
+# --- HTTP boundary: mount only this router, override auth and DB --------------------------------
@pytest.fixture
-def sample_data():
- """Sample creation data."""
- return {
- "user_id": "test_user_001",
- "name": "Test ${Feature}",
- "description": "Test description",
- }
-
-
-# ---------------------------------------------------------------------------
-# Repository Tests
-# ---------------------------------------------------------------------------
-
-class TestRepository:
- def test_create(self, db_session, sample_data):
- repo = ${Feature}Repository(db_session)
- item = repo.create({
- "id": "${feature}_test001",
- **sample_data,
- })
- assert item.id == "${feature}_test001"
- assert item.name == sample_data["name"]
- assert item.deleted_at is None
-
- def test_get_by_id(self, db_session):
- repo = ${Feature}Repository(db_session)
- repo.create({"id": "${feature}_001", "user_id": "u1", "name": "A"})
- found = repo.get_by_id("${feature}_001")
- assert found is not None
- assert found.name == "A"
-
- def test_get_by_id_respects_soft_delete(self, db_session):
- repo = ${Feature}Repository(db_session)
- repo.create({"id": "${feature}_del", "user_id": "u1", "name": "D"})
- repo.soft_delete("${feature}_del")
- assert repo.get_by_id("${feature}_del") is None
-
- def test_list_by_user_pagination(self, db_session):
- repo = ${Feature}Repository(db_session)
- for i in range(5):
- repo.create({"id": f"${feature}_{i}", "user_id": "u1", "name": f"Item {i}"})
- items, total = repo.list_by_user("u1", page=1, page_size=3)
- assert total == 5
- assert len(items) == 3
-
-
-# ---------------------------------------------------------------------------
-# Service Tests
-# ---------------------------------------------------------------------------
-
-class TestService:
- def test_create(self, service, sample_data):
- item = service.create(**sample_data)
- assert item.name == sample_data["name"]
- assert item.user_id == sample_data["user_id"]
- assert item.id.startswith("${feature}_")
-
- def test_get_item_ownership(self, service):
- item = service.create(user_id="u1", name="Mine")
- # Owner can access
- assert service.get_item(item.id, "u1") is not None
- # Non-owner raises
- with pytest.raises(Exception):
- service.get_item(item.id, "u2")
-
- def test_delete(self, service):
- item = service.create(user_id="u1", name="ToDelete")
- assert service.delete(item.id, "u1") is True
- assert service.get_item(item.id, "u1") is None
-
- def test_ensure_idempotent(self, service):
- item1 = service.create(user_id="u1", name="First")
- item2 = service.ensure(item1.id, "u1")
- assert item1.id == item2.id
+def client(db_session, monkeypatch):
+ from core.auth.backend import UserContext, get_current_user
+ from core.db.engine import get_db
+ from core.infra.exceptions import AppException
+ from features.${feature}.api.${feature}s import router
+ from fastapi.responses import JSONResponse
+ from tests.db_support import share_session
+
+ app = FastAPI()
+ app.include_router(router)
+ app.dependency_overrides[get_db] = lambda: db_session
+ # async routes reach the database through run_in_session, not get_db
+ share_session(monkeypatch, db_session)
+ app.dependency_overrides[get_current_user] = lambda: UserContext(user_id="user_a", user_center_id="uc_a", username="a")
+
+ @app.exception_handler(AppException)
+ async def _envelope(_, exc: AppException):
+ return JSONResponse(status_code=exc.status_code, content={"code": exc.code, "message": exc.message})
+
+ return TestClient(app)
+
+
+def test_http_create_returns_envelope(client):
+ resp = client.post("/v1/${feature}s", json={"name": "周报"})
+ assert resp.status_code == 201
+ body = resp.json()
+ assert body["code"] == 10001
+ assert body["data"]["name"] == "周报"
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/attachment.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/attachment.svg
new file mode 100644
index 00000000..029d1c30
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/attachment.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/automation.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/automation.svg
new file mode 100644
index 00000000..0663b72e
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/automation.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/check.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/check.svg
new file mode 100644
index 00000000..1bfd4b84
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/chevron-right.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/chevron-right.svg
new file mode 100644
index 00000000..068cb596
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/chevron-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/close.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/close.svg
new file mode 100644
index 00000000..b5d6b13d
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/close.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/knowledge.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/knowledge.svg
new file mode 100644
index 00000000..1221ffaa
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/knowledge.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/my-space.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/my-space.svg
new file mode 100644
index 00000000..d7b07e6a
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/my-space.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/new-chat.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/new-chat.svg
new file mode 100644
index 00000000..98a36618
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/new-chat.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/search.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/search.svg
new file mode 100644
index 00000000..263a0e98
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/search.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/send.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/send.svg
new file mode 100644
index 00000000..1eee11cf
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/send.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/settings.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/settings.svg
new file mode 100644
index 00000000..6d957f24
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/settings.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/skill.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/skill.svg
new file mode 100644
index 00000000..48244686
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/icons-ui/skill.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/favicon.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/favicon.svg
new file mode 100644
index 00000000..77e0a10a
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/favicon.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-glyph-black.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-glyph-black.svg
new file mode 100644
index 00000000..4ac67167
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-glyph-black.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-glyph-blue.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-glyph-blue.svg
new file mode 100644
index 00000000..f1b49091
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-glyph-blue.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-glyph-white.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-glyph-white.svg
new file mode 100644
index 00000000..5c8116ff
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-glyph-white.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-mono-black.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-mono-black.svg
new file mode 100644
index 00000000..2fa9be89
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-mono-black.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-mono-white.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-mono-white.svg
new file mode 100644
index 00000000..198f5f3e
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-mono-white.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-reverse.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-reverse.svg
new file mode 100644
index 00000000..902c6bf0
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-reverse.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-small.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-small.svg
new file mode 100644
index 00000000..77e0a10a
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark-small.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark.svg b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark.svg
new file mode 100644
index 00000000..67a8da85
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/logo/hugagentos-mark.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/appTheme.v2.ts b/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/appTheme.v2.ts
new file mode 100644
index 00000000..599b20a0
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/appTheme.v2.ts
@@ -0,0 +1,39 @@
+// HugAgentOS Ant Design tokens v2 — reference for src/frontend/src/appTheme.ts.
+// Differences from the current file: hover/active darken instead of lighten; dark primary stays #126DFF;
+// semantic text colours and placeholder meet 4.5:1.
+import type { ThemeConfig } from 'antd';
+import { theme as antdTheme } from 'antd';
+
+const shared = {
+ borderRadius: 10, borderRadiusLG: 16, borderRadiusSM: 6,
+ fontFamily: 'var(--font-family)', fontSize: 14, fontSizeSM: 12, fontSizeLG: 16,
+ colorWarning: '#F8AB42',
+};
+
+export const LIGHT_V2: ThemeConfig = {
+ algorithm: antdTheme.defaultAlgorithm,
+ token: {
+ ...shared,
+ colorPrimary: '#126DFF', colorPrimaryHover: '#0F62E6', colorPrimaryActive: '#0A56CC',
+ colorLink: '#0F62E6', colorInfo: '#126DFF',
+ colorSuccess: '#02B589', colorError: '#D92D20',
+ colorSuccessText: '#067A5E', colorWarningText: '#B45309', colorErrorText: '#D92D20',
+ colorText: '#101828', colorTextSecondary: '#475467', colorTextTertiary: '#667085', colorTextPlaceholder: '#667085',
+ colorBorder: '#C5CCD8', colorBorderSecondary: '#E3E8EF',
+ colorBgContainer: '#FFFFFF', colorBgLayout: '#F5F7FB', colorBgElevated: '#FFFFFF',
+ },
+};
+
+export const DARK_V2: ThemeConfig = {
+ algorithm: antdTheme.darkAlgorithm,
+ token: {
+ ...shared,
+ colorPrimary: '#126DFF', colorPrimaryHover: '#0F62E6', colorPrimaryActive: '#0A56CC',
+ colorLink: '#5C9BFF', colorInfo: '#126DFF',
+ colorSuccess: '#22C79D', colorError: '#FF6B6B',
+ colorSuccessText: '#3DDBB0', colorWarningText: '#FFC26B', colorErrorText: '#FF8A80',
+ colorText: '#E8ECF4', colorTextSecondary: '#B3BDCD', colorTextTertiary: '#8792A4', colorTextPlaceholder: '#8792A4',
+ colorBorder: '#3A4556', colorBorderSecondary: '#2B3442',
+ colorBgContainer: '#161C25', colorBgLayout: '#12171F', colorBgElevated: '#1C2330', colorBgSpotlight: '#1C2330',
+ },
+};
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/hugagentos-tokens.v2.css b/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/hugagentos-tokens.v2.css
new file mode 100644
index 00000000..a74c2600
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/hugagentos-tokens.v2.css
@@ -0,0 +1,123 @@
+/* HugAgentOS design tokens v2 — generated with the design guidelines (2026-10).
+ * Additive to src/frontend/src/styles/variables.css: new names only, existing names keep their values
+ * except where the guidelines' migration list says otherwise. */
+:root{
+ --color-primary:#126DFF;
+ --color-on-primary:#FFFFFF;
+ --color-primary-text:#0F62E6;
+ --color-primary-bg:#DBE9FF;
+ --color-primary-light:#EBF2FF;
+ --state-hover:rgba(16,24,40,.04);
+ --state-press:rgba(16,24,40,.08);
+ --state-on-fill-hover:rgba(0,0,0,.08);
+ --state-on-fill-press:rgba(0,0,0,.16);
+ --color-success:#02B589;
+ --color-success-text:#067A5E;
+ --color-success-bg:#E6FFF9;
+ --color-warning:#F8AB42;
+ --color-warning-text:#B45309;
+ --color-warning-bg:#FFF5E8;
+ --color-error:#FC5D5D;
+ --color-error-text:#D92D20;
+ --color-error-bg:#FFF0F0;
+ --color-danger-fill:#D92D20;
+ --color-text:#101828;
+ --color-text-secondary:#475467;
+ --color-text-tertiary:#667085;
+ --color-text-placeholder:#667085;
+ --color-separator:#E3E8EF;
+ --color-control-border:#C5CCD8;
+ --color-fill-quiet:#F2F4F7;
+ --color-bg-base:#FFFFFF;
+ --color-bg-container:#FFFFFF;
+ --color-bg-elevated:#FFFFFF;
+ --color-bg-layout:#F5F7FB;
+ --color-bg-chat:#FDFDFC;
+ --color-brand-spark:#19D3A7;
+ --material:rgba(255,255,255,.82);
+ --scrim:rgba(16,24,40,.32);
+ --color-tint-blue-bg:#DBEAFE;
+ --color-tint-blue:#1E40AF;
+ --color-tint-purple-bg:#EDE9FE;
+ --color-tint-purple:#6D28D9;
+ --color-tint-teal-bg:#CCFBF1;
+ --color-tint-teal:#0F766E;
+ --color-tint-amber-bg:#FEF3C7;
+ --color-tint-amber:#B45309;
+ --shadow-raised:0 1px 2px rgba(16,24,40,.05), 0 8px 24px rgba(16,24,40,.06);
+ --shadow-floating:0 2px 8px rgba(16,24,40,.06), 0 20px 48px rgba(16,24,40,.14);
+ --space-1:4px;
+ --space-2:8px;
+ --space-3:12px;
+ --space-4:16px;
+ --space-6:24px;
+ --space-8:32px;
+ --space-12:48px;
+ --space-16:64px;
+ --radius-xs:6px;
+ --radius-sm:10px;
+ --radius-md:16px;
+ --radius-lg:24px;
+ --radius-full:999px;
+ --font-size-display:2.5rem;--line-height-display:3rem;--font-weight-display:600;--letter-spacing-display:-0.02em;
+ --font-size-title-1:1.75rem;--line-height-title-1:2.25rem;--font-weight-title-1:600;--letter-spacing-title-1:-0.01em;
+ --font-size-title-2:1.375rem;--line-height-title-2:1.875rem;--font-weight-title-2:600;--letter-spacing-title-2:0;
+ --font-size-title-3:1.125rem;--line-height-title-3:1.625rem;--font-weight-title-3:600;--letter-spacing-title-3:0;
+ --font-size-headline:0.9375rem;--line-height-headline:1.375rem;--font-weight-headline:600;--letter-spacing-headline:0;
+ --font-size-body-lg:1rem;--line-height-body-lg:1.75rem;--font-weight-body-lg:400;--letter-spacing-body-lg:0;
+ --font-size-body:0.875rem;--line-height-body:1.375rem;--font-weight-body:400;--letter-spacing-body:0;
+ --font-size-footnote:0.8125rem;--line-height-footnote:1.25rem;--font-weight-footnote:400;--letter-spacing-footnote:0;
+ --font-size-caption:0.75rem;--line-height-caption:1.125rem;--font-weight-caption:400;--letter-spacing-caption:0;
+ --motion-duration-instant:100ms;
+ --motion-duration-fast:160ms;
+ --motion-duration-normal:240ms;
+ --motion-duration-slow:320ms;
+ --motion-ease-brand-out:cubic-bezier(0.16,1,0.3,1);
+ --motion-ease-standard:cubic-bezier(0.4,0,0.2,1);
+ --motion-ease-exit:cubic-bezier(0.4,0,1,1);
+ --motion-stagger-step:60ms;
+}
+
+:root[data-theme="dark"]{
+ --color-primary:#126DFF;
+ --color-primary-text:#5C9BFF;
+ --color-primary-bg:rgba(62,139,255,.20);
+ --color-primary-light:rgba(62,139,255,.12);
+ --state-hover:rgba(255,255,255,.06);
+ --state-press:rgba(255,255,255,.10);
+ --state-on-fill-hover:rgba(0,0,0,.12);
+ --state-on-fill-press:rgba(0,0,0,.22);
+ --color-success:#22C79D;
+ --color-success-text:#3DDBB0;
+ --color-success-bg:rgba(34,199,157,.12);
+ --color-warning-text:#FFC26B;
+ --color-warning-bg:rgba(248,171,66,.12);
+ --color-error:#FF6B6B;
+ --color-error-text:#FF8A80;
+ --color-error-bg:rgba(255,107,107,.12);
+ --color-text:#E8ECF4;
+ --color-text-secondary:#B3BDCD;
+ --color-text-tertiary:#8792A4;
+ --color-text-placeholder:#8792A4;
+ --color-separator:#2B3442;
+ --color-control-border:#3A4556;
+ --color-fill-quiet:#1F2631;
+ --color-bg-base:#0F141B;
+ --color-bg-container:#161C25;
+ --color-bg-elevated:#1C2330;
+ --color-bg-layout:#12171F;
+ --color-bg-chat:#111720;
+ --material:rgba(28,35,48,.80);
+ --scrim:rgba(0,0,0,.56);
+ --color-tint-blue-bg:rgba(62,139,255,.20);
+ --color-tint-blue:#8FB9FF;
+ --color-tint-purple-bg:rgba(149,120,255,.18);
+ --color-tint-purple:#B9A5FF;
+ --color-tint-teal-bg:rgba(45,212,191,.18);
+ --color-tint-teal:#5EE7D5;
+ --color-tint-amber-bg:rgba(248,171,66,.18);
+ --color-tint-amber:#FFC97A;
+ --shadow-raised:0 1px 2px rgba(0,0,0,.40), 0 8px 24px rgba(0,0,0,.32);
+ --shadow-floating:0 2px 8px rgba(0,0,0,.45), 0 20px 48px rgba(0,0,0,.55);
+ color-scheme:dark;
+}
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/hugagentos-tokens.v2.json b/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/hugagentos-tokens.v2.json
new file mode 100644
index 00000000..d925c2e3
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/hugagentos-tokens.v2.json
@@ -0,0 +1,223 @@
+{
+ "name": "HugAgentOS",
+ "version": "2.0.0",
+ "color": {
+ "light": {
+ "color-primary": "#126DFF",
+ "color-on-primary": "#FFFFFF",
+ "color-primary-text": "#0F62E6",
+ "color-primary-bg": "#DBE9FF",
+ "color-primary-light": "#EBF2FF",
+ "state-hover": "rgba(16,24,40,.04)",
+ "state-press": "rgba(16,24,40,.08)",
+ "state-on-fill-hover": "rgba(0,0,0,.08)",
+ "state-on-fill-press": "rgba(0,0,0,.16)",
+ "color-success": "#02B589",
+ "color-success-text": "#067A5E",
+ "color-success-bg": "#E6FFF9",
+ "color-warning": "#F8AB42",
+ "color-warning-text": "#B45309",
+ "color-warning-bg": "#FFF5E8",
+ "color-error": "#FC5D5D",
+ "color-error-text": "#D92D20",
+ "color-error-bg": "#FFF0F0",
+ "color-danger-fill": "#D92D20",
+ "color-text": "#101828",
+ "color-text-secondary": "#475467",
+ "color-text-tertiary": "#667085",
+ "color-text-placeholder": "#667085",
+ "color-separator": "#E3E8EF",
+ "color-control-border": "#C5CCD8",
+ "color-fill-quiet": "#F2F4F7",
+ "color-bg-base": "#FFFFFF",
+ "color-bg-container": "#FFFFFF",
+ "color-bg-elevated": "#FFFFFF",
+ "color-bg-layout": "#F5F7FB",
+ "color-bg-chat": "#FDFDFC",
+ "color-brand-spark": "#19D3A7",
+ "material": "rgba(255,255,255,.82)",
+ "scrim": "rgba(16,24,40,.32)",
+ "color-tint-blue-bg": "#DBEAFE",
+ "color-tint-blue": "#1E40AF",
+ "color-tint-purple-bg": "#EDE9FE",
+ "color-tint-purple": "#6D28D9",
+ "color-tint-teal-bg": "#CCFBF1",
+ "color-tint-teal": "#0F766E",
+ "color-tint-amber-bg": "#FEF3C7",
+ "color-tint-amber": "#B45309",
+ "shadow-raised": "0 1px 2px rgba(16,24,40,.05), 0 8px 24px rgba(16,24,40,.06)",
+ "shadow-floating": "0 2px 8px rgba(16,24,40,.06), 0 20px 48px rgba(16,24,40,.14)"
+ },
+ "dark": {
+ "color-primary": "#126DFF",
+ "color-on-primary": "#FFFFFF",
+ "color-primary-text": "#5C9BFF",
+ "color-primary-bg": "rgba(62,139,255,.20)",
+ "color-primary-light": "rgba(62,139,255,.12)",
+ "state-hover": "rgba(255,255,255,.06)",
+ "state-press": "rgba(255,255,255,.10)",
+ "state-on-fill-hover": "rgba(0,0,0,.12)",
+ "state-on-fill-press": "rgba(0,0,0,.22)",
+ "color-success": "#22C79D",
+ "color-success-text": "#3DDBB0",
+ "color-success-bg": "rgba(34,199,157,.12)",
+ "color-warning": "#F8AB42",
+ "color-warning-text": "#FFC26B",
+ "color-warning-bg": "rgba(248,171,66,.12)",
+ "color-error": "#FF6B6B",
+ "color-error-text": "#FF8A80",
+ "color-error-bg": "rgba(255,107,107,.12)",
+ "color-danger-fill": "#D92D20",
+ "color-text": "#E8ECF4",
+ "color-text-secondary": "#B3BDCD",
+ "color-text-tertiary": "#8792A4",
+ "color-text-placeholder": "#8792A4",
+ "color-separator": "#2B3442",
+ "color-control-border": "#3A4556",
+ "color-fill-quiet": "#1F2631",
+ "color-bg-base": "#0F141B",
+ "color-bg-container": "#161C25",
+ "color-bg-elevated": "#1C2330",
+ "color-bg-layout": "#12171F",
+ "color-bg-chat": "#111720",
+ "color-brand-spark": "#19D3A7",
+ "material": "rgba(28,35,48,.80)",
+ "scrim": "rgba(0,0,0,.56)",
+ "color-tint-blue-bg": "rgba(62,139,255,.20)",
+ "color-tint-blue": "#8FB9FF",
+ "color-tint-purple-bg": "rgba(149,120,255,.18)",
+ "color-tint-purple": "#B9A5FF",
+ "color-tint-teal-bg": "rgba(45,212,191,.18)",
+ "color-tint-teal": "#5EE7D5",
+ "color-tint-amber-bg": "rgba(248,171,66,.18)",
+ "color-tint-amber": "#FFC97A",
+ "shadow-raised": "0 1px 2px rgba(0,0,0,.40), 0 8px 24px rgba(0,0,0,.32)",
+ "shadow-floating": "0 2px 8px rgba(0,0,0,.45), 0 20px 48px rgba(0,0,0,.55)"
+ }
+ },
+ "space": {
+ "space-1": 4,
+ "space-2": 8,
+ "space-3": 12,
+ "space-4": 16,
+ "space-6": 24,
+ "space-8": 32,
+ "space-12": 48,
+ "space-16": 64
+ },
+ "radius": {
+ "radius-xs": 6,
+ "radius-sm": 10,
+ "radius-md": 16,
+ "radius-lg": 24,
+ "radius-full": 999
+ },
+ "type": {
+ "display": {
+ "fontSize": 40,
+ "lineHeight": 48,
+ "fontWeight": 600,
+ "letterSpacing": "-0.02em"
+ },
+ "title-1": {
+ "fontSize": 28,
+ "lineHeight": 36,
+ "fontWeight": 600,
+ "letterSpacing": "-0.01em"
+ },
+ "title-2": {
+ "fontSize": 22,
+ "lineHeight": 30,
+ "fontWeight": 600,
+ "letterSpacing": "0"
+ },
+ "title-3": {
+ "fontSize": 18,
+ "lineHeight": 26,
+ "fontWeight": 600,
+ "letterSpacing": "0"
+ },
+ "headline": {
+ "fontSize": 15,
+ "lineHeight": 22,
+ "fontWeight": 600,
+ "letterSpacing": "0"
+ },
+ "body-lg": {
+ "fontSize": 16,
+ "lineHeight": 28,
+ "fontWeight": 400,
+ "letterSpacing": "0"
+ },
+ "body": {
+ "fontSize": 14,
+ "lineHeight": 22,
+ "fontWeight": 400,
+ "letterSpacing": "0"
+ },
+ "footnote": {
+ "fontSize": 13,
+ "lineHeight": 20,
+ "fontWeight": 400,
+ "letterSpacing": "0"
+ },
+ "caption": {
+ "fontSize": 12,
+ "lineHeight": 18,
+ "fontWeight": 400,
+ "letterSpacing": "0"
+ }
+ },
+ "font": {
+ "ui": "PingFang SC / Microsoft YaHei UI / Noto Sans CJK SC (system)",
+ "number": "DingTalk Sans",
+ "mono": "ui-monospace, SF Mono, Menlo, Consolas",
+ "marketing": "Source Han Sans SC / Noto Sans SC"
+ },
+ "motion": {
+ "duration": {
+ "instant": 100,
+ "fast": 160,
+ "normal": 240,
+ "slow": 320
+ },
+ "easing": {
+ "brand-out": [
+ 0.16,
+ 1,
+ 0.3,
+ 1
+ ],
+ "standard": [
+ 0.4,
+ 0,
+ 0.2,
+ 1
+ ],
+ "exit": [
+ 0.4,
+ 0,
+ 1,
+ 1
+ ]
+ },
+ "spring": {
+ "smooth": {
+ "visualDuration": 0.35,
+ "bounce": 0,
+ "stiffness": 322,
+ "damping": 36
+ },
+ "snappy": {
+ "visualDuration": 0.25,
+ "bounce": 0.15,
+ "stiffness": 632,
+ "damping": 43
+ }
+ },
+ "staggerStep": 60,
+ "staggerCap": 8,
+ "layoutAnimMaxItems": 20,
+ "freshEnterWindowMs": 2000
+ }
+}
diff --git a/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/motionTokens.v2.ts b/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/motionTokens.v2.ts
new file mode 100644
index 00000000..da835955
--- /dev/null
+++ b/.claude/skills/hugagent-frontend-dev/assets/design-v2/tokens/motionTokens.v2.ts
@@ -0,0 +1,32 @@
+// HugAgentOS motion tokens v2 — drop-in superset of src/frontend/src/utils/motionTokens.ts.
+// New: SPRING.smooth / SPRING.snappy described as visualDuration + bounce (Motion >= 11 accepts both forms).
+// Durations / easings are unchanged; keep them in sync with styles/variables.css (--motion-*).
+
+export const DUR = { instant: 0.1, fast: 0.16, normal: 0.24, slow: 0.32 } as const;
+
+export const EASE = {
+ /** Enter / expand / rise */
+ brandOut: [0.16, 1, 0.3, 1] as const,
+ /** Two-way state changes (colour, opacity) */
+ standard: [0.4, 0, 0.2, 1] as const,
+ /** Exit: accelerate away, ~0.7x the enter duration */
+ exit: [0.4, 0, 1, 1] as const,
+};
+
+export const SPRING = {
+ /** Panels, drawers, collapse/expand, layout changes. No overshoot. (≈ stiffness 322, damping 36) */
+ smooth: { type: 'spring', visualDuration: 0.35, bounce: 0 },
+ /** Tab ink, toggles, badge confirmation. Slight overshoot. (≈ stiffness 632, damping 43) */
+ snappy: { type: 'spring', visualDuration: 0.25, bounce: 0.15 },
+ /** @deprecated use SPRING.snappy — 33% overshoot */
+ pop: { type: 'spring', stiffness: 500, damping: 30 },
+ /** @deprecated use SPRING.snappy */
+ ink: { type: 'spring', stiffness: 480, damping: 38 },
+ /** @deprecated use SPRING.smooth — 33% overshoot makes panels wobble */
+ panel: { type: 'spring', stiffness: 320, damping: 24 },
+} as const;
+
+export const STAGGER_STEP = 0.06;
+export const STAGGER_CAP = 8;
+export const LAYOUT_ANIM_MAX_ITEMS = 20;
+export const FRESH_ENTER_WINDOW_MS = 2000;
diff --git a/.claude/skills/hugagent-frontend-dev/references/css-variables.md b/.claude/skills/hugagent-frontend-dev/references/css-variables.md
index afe227db..4fa848a1 100644
--- a/.claude/skills/hugagent-frontend-dev/references/css-variables.md
+++ b/.claude/skills/hugagent-frontend-dev/references/css-variables.md
@@ -1,153 +1,174 @@
-# CSS 变量与样式规范参考
+# CSS 变量、命名与样式模式
-> **重要**: 颜色、圆角、间距等视觉规范以 `references/ui-design-spec.md` 为权威来源。本文件侧重 CSS 实践模式。
+真源:`src/frontend/src/styles/variables.css`(`:root` 浅色 + 文件末尾 `:root[data-theme="dark"]` 深色覆盖 +
+`:root[data-font-platform=…]` 字体优先级)。TS 镜像:`utils/motionTokens.ts`(动效)、`appTheme.ts`(antd token)。
+三处同步改。设计规则见 `ui-design-spec.md`(v2)。
-## 全局变量 (variables.css)
+---
-以下变量需与 UI 设计规范对齐(参见 `ui-design-spec.md` 第 12 节的完整映射)。
+## 1. v1 → v2 令牌对照(写新代码先查这张表)
-```css
-:root {
- /* 主色 */
- --color-primary: #126DFF;
- --color-primary-hover: #3C87FF;
- --color-primary-active: #0862F3;
- --color-primary-disabled: #A0C5FF;
- --color-primary-bg: #DBE9FF;
- --color-primary-light: #EBF2FF;
-
- /* 边框与填充 */
- --color-fill-heavy: #8C94A2;
- --color-fill-deep: #B3BAC8;
- --color-fill: #D8DBE2;
- --color-border: #E3E6EA;
- --color-fill-hover: #EBEDEE;
- --color-bg-gray: #F5F6F7;
-
- /* 文字 */
- --color-text: #101828;
- --color-text-secondary: #475467;
- --color-text-tertiary: #667085;
- --color-text-placeholder: #98A2B3;
- --color-text-white: #FFFFFF;
-
- /* 语义色 */
- --color-success: #02B589;
- --color-warning: #F8AB42;
- --color-error: #FC5D5D;
-
- /* 圆角 */
- --radius-xs: 6px; /* 标签等小面积 */
- --radius-sm: 10px; /* 按钮、输入框、下拉框 */
- --radius-md: 16px; /* 卡片视图、背景卡片 */
- --radius-lg: 24px; /* 弹窗、大输入框 */
-
- /* 间距 (4px 基准) */
- --space-xs: 4px;
- --space-sm: 8px;
- --space-md: 16px;
- --space-lg: 20px;
- --space-xl: 40px;
-
- /* 字体(后一条定义生效:系统栈优先,中文回退 PingFang/雅黑) */
- --font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue",
- "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
- --font-family-number: "DingTalk Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
-
- /* Motion 令牌(TS 侧镜像在 utils/motionTokens.ts) */
- --motion-duration-instant: 100ms;
- --motion-duration-fast: 160ms;
- --motion-duration-normal: 240ms;
- --motion-duration-slow: 320ms;
- /* easing: --motion-ease-brand-out cubic-bezier(.16,1,.3,1) / --motion-ease-standard cubic-bezier(.4,0,.2,1) */
-}
-```
+v2 令牌**尚未全部合入** `variables.css`。写代码前先 `grep -n -- '--令牌名' src/frontend/src/styles/variables.css` 确认:
-> 阴影令牌:`--shadow-card` / `--shadow-card-hover` / `--shadow-dropdown`(双层 ambient + key-light)。
-> 底部还有 legacy 别名(`--primary` / `--bg` / `--text` / `--muted` / `--border` 等),新代码不要使用。
+- **已存在** → 直接用;
+- **不存在、而你的改动需要它** → 按迁移清单第 1 项把它加进 `variables.css`(浅色 `:root` 和深色覆盖块**两处都加**,
+ 值取自 `assets/design-v2/tokens/hugagentos-tokens.v2.css`),同步 `appTheme.ts`(如涉及 antd),然后再用。
+ **不要**写死色值、**不要**引用未定义的变量名、**不要**写 `var(--x, #fff)` 兜底(深色门禁会拦)。
-## CSS 类名前缀
+| 用途 | v1(现行代码) | v2(目标) | 说明 |
+|---|---|---|---|
+| 强调填充 | `--color-primary` #126DFF(深色 #3E8BFF) | `--color-primary`(深色也 #126DFF) | 深色值待迁移 #6 |
+| 强调文字 / 链接 | `--color-primary` | `--color-primary-text` #0F62E6 / 深 #5C9BFF | 新增 |
+| 主色上的文字 | `--color-text-white` | `--color-on-primary` | 新增 |
+| 悬停 / 按下 | `--color-*-hover` / `--color-*-active` 色阶 | `--state-hover` / `--state-press` / `--state-on-fill-hover` / `--state-on-fill-press` 叠加层 | **新代码禁用色阶** |
+| 禁用 | `--color-*-disabled` | `opacity: .4` | |
+| 成功/警告/错误 文字 | `--color-success` 等直接当文字 | `--color-success-text` / `--color-warning-text` / `--color-error-text` | P0 对比度 |
+| 成功/警告/错误 浅底 | `--color-*-light`(v1 的 `*-bg` 是更深一档) | `--color-success-bg` #E6FFF9 等 | **注意:v2 的 `*-bg` 取 v1 `*-light` 的值** |
+| 破坏性实心按钮 | — | `--color-danger-fill` #D92D20 | 新增 |
+| 占位符 | `--color-text-placeholder` #98A2B3 | 同名,值改为 #667085(= 三级文字) | 迁移 #7 |
+| 分隔线 | `--color-border` #E3E8EF | `--color-separator` | 改名,值相同 |
+| 输入框边框 | `--color-border` / `--color-fill` | `--color-control-border` #C5CCD8 | 新增 |
+| 次要按钮 / 胶囊 / 用户气泡 | `--color-bg-gray` / `--color-fill-hover` | `--color-fill-quiet` #F2F4F7 | 新增 |
+| 浮层材质 | 各处自写半透明 | `--material` + `backdrop-filter: blur(20px)` | 新增 |
+| 模态遮罩 | antd 默认 | `--scrim` | 新增 |
+| 静止卡片投影 | `--shadow-card` | **无投影** | 迁移 #9 |
+| 悬停卡片 / 对话输入区 | `--shadow-card-hover` / `--shadow-card` | `--shadow-raised` | 调整 |
+| 菜单 / 弹窗 | `--shadow-dropdown` | `--shadow-floating` | 调整 |
+| 分类色 | tint × 7(purple/cyan/teal/amber/green/red/blue) | tint × 4(blue/purple/teal/amber) | cyan/green/red 不新增引用 |
+| 胶囊圆角 | 写 `999px` | `--radius-full` | 新增 |
+| 间距 | `--space-xs/sm/md/lg/xl` = 4/8/16/**20**/**40** | `--space-1/2/3/4/6/8/12/16` = 4/8/12/16/24/32/48/64 | 20/40 不在栅格上 |
+| 字号 | `--font-size-xs/sm/md/lg/xl/xxl` = 12/14/16/18/22/44(px) | 九级角色:display / title-1/2/3 / headline / body-lg / body / footnote / caption(rem) | 见 ui-design-spec §2 |
+| 品牌火花绿 | — | `--color-brand-spark` | 仅标志,界面禁用 |
+| 弹簧 | `SPRING.pop / ink / panel` | `SPRING.smooth / snappy` | 迁移 #2 |
-所有自定义类使用 `.jx-` 前缀,避免与 Ant Design 冲突。
+在 v2 间距令牌合入前:4/8/16 用 `--space-xs/sm/md`,12/24/32/48/64 可直接写 px(门禁只管颜色),
+**不要**在新代码里用 `--space-lg`(20) / `--space-xl`(40)。
-## 命名规则 (BEM-like)
+---
-| 类型 | 格式 | 示例 |
-|------|------|------|
-| Block | `.jx-blockName` | `.jx-chatArea` |
-| Element | `.jx-blockName-element` | `.jx-chatArea-header` |
-| Modifier | `.jx-blockName--modifier` | `.jx-chatArea--empty` |
-| Sub-element | `.jx-blockName-element-sub` | `.jx-fileCard-icon-bg` |
+## 2. 现行令牌速查(variables.css 当前值)
-## 常用尺寸
+### 背景四级 + 对话底
-| 用途 | 值 |
-|------|-----|
-| 侧边栏宽度 | 260px (展开) / 0 (收起) |
-| 输入框最大宽度 | 840px |
-| 消息最大宽度 | 840px |
-| 面板内边距 | 20px (桌面) / 16px (移动) |
-| 卡片间距 | 8px - 16px |
-| 卡片圆角 | 16px (`--radius-md`) |
-| 输入框圆角 | 10px (`--radius-sm`) |
-| 按钮圆角 | 10px (`--radius-sm`) |
-| 弹窗圆角 | 24px (`--radius-lg`) |
-| 按钮最小高度 | 36px |
+| 令牌 | 浅色 | 深色 |
+|---|---|---|
+| `--color-bg-base` | #FFFFFF | #0F141B |
+| `--color-bg-container` | #FFFFFF | #161C25 |
+| `--color-bg-elevated` | #FFFFFF | #1C2330 |
+| `--color-bg-layout` | #F5F7FB | #12171F |
+| `--color-bg-chat` | #FDFDFC | #111720 |
-## 常用样式模式
+### 文字
-### 卡片
+`--color-text` #101828 · `--color-text-secondary` #475467 · `--color-text-tertiary` #667085 ·
+`--color-text-placeholder` #98A2B3(v2 改 #667085)· `--color-text-white` #FFFFFF
-```css
-.jx-card {
- padding: var(--space-md) var(--space-lg); /* 16px 20px */
- border-radius: var(--radius-md); /* 16px */
- border: 1px solid var(--color-border);
- background: #fff;
- transition: box-shadow 0.2s, border-color 0.2s;
-}
-.jx-card:hover {
- border-color: var(--color-primary-hover);
- box-shadow: 0 4px 12px rgba(18, 109, 255, 0.08);
-}
-```
+### 主色与语义
+
+- 主色:`--color-primary` #126DFF、`-hover` #3C87FF、`-active` #0862F3、`-disabled` #A0C5FF、`-bg` #DBE9FF、`-light` #EBF2FF
+- 语义:`--color-{success|warning|error}` + `-hover/-active/-disabled/-bg/-light`(v1 六档,新代码按上表换 v2)
+- 线与填充:`--color-border` #E3E8EF、`--color-fill` #DDE3EC、`--color-fill-hover` #EEF1F5、`--color-bg-gray` #F4F6F9、
+ `--color-fill-deep` #98A2B3、`--color-fill-heavy` #8C94A2
+- 分类 tint:`--color-tint-{purple,cyan,teal,amber,green,red,blue}-bg` + `--color-tint-{…}`(深浅两档已定义)
+
+### 圆角 / 间距 / 字号 / 字体
+
+- `--radius-xs/sm/md/lg` = 6 / 10 / 16 / 24
+- `--space-xs/sm/md/lg/xl` = 4 / 8 / 16 / 20 / 40
+- `--font-size-xs/sm/md/lg/xl/xxl` = 12 / 14 / 16 / 18 / 22 / 44 px
+- `--font-family`(`--font-family-cjk` 按 `data-font-platform` 切换 PingFang SC / Microsoft YaHei UI / Noto Sans CJK SC)、
+ `--font-family-mono`、`--font-family-number`(DingTalk Sans)
+
+### 投影
+
+`--shadow-card` / `--shadow-card-hover` / `--shadow-dropdown`(深浅两档)
+
+### 动效
+
+`--motion-duration-instant/fast/normal/slow` = 100 / 160 / 240 / 320ms ·
+`--motion-ease-brand-out` / `--motion-ease-standard` / `--motion-ease-exit` · `--motion-stagger-step` 60ms
-### 按钮
+### 局部域令牌(只在对应模块内用)
+
+- `--delivery-*`:交付文件卡片(文件类型图标色等)
+- `--color-personal-*` / `--shadow-personal-composer`:Personal Agent 对话皮肤
+- `--module-*`:模块导航轨与二级边栏
+- `--scrollbar-*`:滚动条
+- `--bg / --text / --muted / --border / --primary / --primary2 / --accent / --shadow1`:**旧别名,新代码禁用**
+
+---
+
+## 3. 命名规则
```css
-.jx-btn {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 8px 18px;
- border-radius: var(--radius-sm); /* 10px */
- font-size: 14px;
- cursor: pointer;
- transition: all 0.2s;
- min-height: 36px;
-}
+.jx-chatArea { } /* Block:.jx- 前缀 + camelCase */
+.jx-chatArea-header { } /* Element:连字符 */
+.jx-chatArea--empty { } /* Modifier:双连字符 */
```
-### 输入区
+- 所有自定义类 `.jx-` 前缀;关键帧 `jx-kf-名称`,动效工具类 `jx-anim-名称`
+- 组件私有的自定义属性用 `--jx--`,**值也必须引令牌**(`css-var-literal-color` 规则)
+- 样式按功能放 `styles/` 对应文件或子目录(`styles/chat/`、`styles/catalog/`、`styles/myspace/`、`styles/automation/`、
+ `styles/mobile/`);`styles/index.ts` 统一 import,组件专属样式可在组件内直接 import
+
+---
+
+## 4. 常用样式模式(v2 写法)
```css
-.jx-inputArea {
- width: 100%;
- max-width: 840px;
- margin: 0 auto;
- border-radius: var(--radius-lg); /* 24px */
- border: 1.5px solid var(--color-border);
- background: #fff;
+/* L1 卡片:静止无投影,悬停浮起 */
+.jx-xxx-card {
+ background: var(--color-bg-container);
+ border-radius: var(--radius-md);
+ padding: var(--space-md);
+ transition: transform var(--motion-duration-fast) var(--motion-ease-standard),
+ box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
+}
+.jx-xxx-card:hover { transform: translateY(-1px); box-shadow: var(--shadow-raised); }
+
+/* 中性控件悬停:叠加层而非换色 */
+.jx-xxx-item:hover { background: var(--state-hover); }
+.jx-xxx-item:active { background: var(--state-press); }
+
+/* L2 浮层:材质 + 模糊,从触发点展开 */
+.jx-xxx-menu {
+ background: var(--material);
+ backdrop-filter: blur(20px);
+ border-radius: var(--radius-md); /* 外 16 */
+ padding: 6px; /* 内边距 6 */
+ box-shadow: var(--shadow-floating);
+ transform-origin: var(--jx-menu-origin, top left);
}
+.jx-xxx-menu-item { border-radius: var(--radius-sm); } /* 内 10 = 16 − 6,同心 */
+
+/* 语义提示条:浅底 + 前景文字成对 */
+.jx-xxx-hint--warning { background: var(--color-warning-bg); color: var(--color-warning-text); }
+
+/* 分类标签:tint 成对 */
+.jx-xxx-tag--skill { background: var(--color-tint-purple-bg); color: var(--color-tint-purple); border-radius: var(--radius-full); }
+
+/* 半透明面 / 墨色浮层(深色下自动正确) */
+.jx-xxx-glass { background: color-mix(in srgb, var(--color-bg-container) 60%, transparent); }
+.jx-xxx-ink { background: color-mix(in srgb, var(--color-text) 5%, transparent); }
+
+/* 数字列 */
+.jx-xxx-num { font-family: var(--font-family-number); font-variant-numeric: tabular-nums; }
+
+/* 小图标按钮:视觉 28,点击区外扩到 32 */
+.jx-xxx-iconBtn { position: relative; width: 28px; height: 28px; border-radius: var(--radius-full); }
+.jx-xxx-iconBtn::after { content: ''; position: absolute; inset: -2px; }
```
-## 文件索引
+---
+
+## 5. 布局约定(模块页面与容器响应式)
-| 文件 | 内容 |
-|------|------|
-| `variables.css` | CSS 变量 + body 背景 + AntD 覆写 |
-| `chat.css` | 聊天区域、输入框、消息气泡、空状态 |
-| `sidebar.css` | 侧边栏、导航、历史列表 |
-| `catalog.css` | Catalog 面板、搜索、列表项 |
-| `tool.css` | 工具输出面板、展开/折叠 |
-| `common.css` | 全局工具类、弹窗覆写 |
+- **模块主页面**(项目、我的空间、站点、应用中心、能力中心、定时任务、设置、更新记录、实验室):公共内容区是全宽滚动视口,
+ 几何集中在 `styles/workspace-layout.css`。模块面板用正常文档流,**不要**再给面板自己设 `height:100%` + `overflow:auto`
+ 造第二个滚动容器,也不要用 `overflow-x:hidden`(会隐式把 `overflow-y` 变成 auto)。限宽只给模块内容。
+ 回归:`npm run test:module-pages`。
+- **容器响应式**:聊天工具栏、项目 / 能力目录 / 知识库卡片按所在面板的可用宽度排版——容器提供宽度,后代用容器查询响应;
+ 不要让元素查询自身,不要用 JS 重复维护 CSS 断点。入口 `components/chat/ComposerToolbar.tsx`、
+ `styles/chat/toolbar-layout.css`、`styles/projects.css`、`styles/catalog/`。
+ 回归:`npm run test:container-layout-browser`、`npm run test:chat-width-browser`。
+- 聊天、Canvas、插件工作区和真正的编辑器 / 限高预览保留专用布局。
diff --git a/.claude/skills/hugagent-frontend-dev/references/icon-assets.md b/.claude/skills/hugagent-frontend-dev/references/icon-assets.md
index c781434b..f0ae7e10 100644
--- a/.claude/skills/hugagent-frontend-dev/references/icon-assets.md
+++ b/.claude/skills/hugagent-frontend-dev/references/icon-assets.md
@@ -1,7 +1,38 @@
# 图标资产清单
-本技能内置了项目 UI 设计稿中的全部 SVG 图标,位于 `assets/` 目录。
-当需要在前端使用图标时,优先从此清单中选取,保持视觉一致性。
+本技能带两批图标:
+
+- `assets/design-v2/`:**v2 设计规范**的界面图标风格样例、标志与网页图标(新代码以此为风格基准)
+- `assets/icons/`、`assets/mcp-icons/`:v1 设计稿导出的图标(存量界面在用,部分带硬编码色值,复用前先改成 `currentColor`)
+
+## v2 资产(`assets/design-v2/`)
+
+| 目录 | 内容 | 规则 |
+|---|---|---|
+| `icons-ui/` | 12 个界面图标:new-chat / search / knowledge / skill / automation / my-space / attachment / settings / send / chevron-right / check / close | 24px 画板、留白 2px、线宽 1.5、圆头圆角、`stroke="currentColor"` |
+| `logo/hugagentos-mark*.svg` | 方形标:标准 / small(≤24px 用)/ reverse(深色底)/ mono-black / mono-white | 只用于方形容器;禁止与标准字并排 |
+| `logo/hugagentos-glyph-*.svg` | 环线(无底板):蓝 / 白 / 黑 | 头像、App 图标内自行合成 |
+| `logo/favicon.svg` | 网页图标(小尺寸版) | 16/32px 使用 |
+| `tokens/` | v2 令牌参考实现(CSS / JSON / motionTokens / appTheme) | 见 `css-variables.md` |
+
+图标规则(v2,详见 `ui-design-spec.md` §6):图标是文字的一部分——尺寸跟随字号、线宽跟随字重(13px→1.5,15/20px→2)、
+颜色继承文字色;默认线性,选中态换面性。**不要给图标单独上品牌色,火花绿 `#19D3A7` 不进界面。**
+
+```tsx
+// v2 写法:currentColor + 跟随字号
+export function SearchIcon({ size = '1em' }: { size?: string | number }) {
+ return (
+
+
+
+ );
+}
+```
+
+---
+
+## v1 图标
## 使用方式
@@ -89,10 +120,9 @@ import sendIcon from '../assets/icons/send.svg';
---
-## 设计约束
+## 设计约束(v2 替代 v1 的约定)
-- **主色**: 图标主色为 `#126DFF`(蓝色),与设计规范一致
-- **灰色**: 非激活态图标用 `#808080`
-- **尺寸**: 常用 16px / 28px / 32px / 34px / 38px(均为 4 的倍数或接近)
-- **风格**: 线性为主,部分填充,圆角端点
-- 详见 `ui-design-spec.md` 第 3 节「图标」规范
+- v1 约定"图标主色 `#126DFF`、非激活 `#808080`"**已废止**:v2 图标继承文字色,激活态用面性而不是换色
+- v1 SVG 里的硬编码 `fill` / `stroke` 色值在深色模式下不会反相;复用前改成 `currentColor`,否则过不了视觉走查
+- 尺寸跟随字号(13 / 15 / 20px 常用),点击区按桌面 ≥32、触屏 ≥44 外扩
+- 详见 `ui-design-spec.md` §6「图标」
diff --git a/.claude/skills/hugagent-frontend-dev/references/sse-events.md b/.claude/skills/hugagent-frontend-dev/references/sse-events.md
index 8764623b..4b31de68 100644
--- a/.claude/skills/hugagent-frontend-dev/references/sse-events.md
+++ b/.claude/skills/hugagent-frontend-dev/references/sse-events.md
@@ -1,246 +1,126 @@
# SSE 事件类型参考
-统一回复端点 `POST /v1/agents/responses` 在请求体 `stream: true` 时返回 Server-Sent Events(默认 false 返回完整 JSON)。后端把 run 跑在后台
-(ChatRun + Redis Stream),SSE 只是"跟随"——断线后可用 `GET /v1/chats/stream/{run_id}`
-重新接上同一条流续播。
+统一回复端点 `POST /v1/agents/responses`(`stream: true` 返回 SSE,默认返回完整 JSON)。后端把每一轮跑成后台 run
+(`core/runs` 持有租约、事件日志与续播;`core/chat/runtime/worker.py` 消费事件并持久化输出),SSE 只是"跟随"。
-**前端解析单一真源是 `hooks/chatStream.ts`(`processChatStream()`)**;`hooks/useStreaming.ts`
-只做发送/中止/续播/排队编排;通用分帧器在 `utils/sse.ts`(`parseSSE`)。后端事件产出主体在
-`orchestration/streaming.py`(StreamingAgent)与 `orchestration/chat_run_executor.py`。
+| 端 | 位置 |
+|---|---|
+| 前端解析单一真源 | `hooks/chatStream.ts` → `hooks/chatStream/`(`consume.ts` 入口;`payload.ts` 分帧与分派;`lifecycleAndTools.ts` / `toolResultsAndMeta.ts` / `controls.ts` 分领域 reducer;`snapshot*.ts` 快照恢复) |
+| 前端发送 / 续播编排 | `hooks/useStreaming.ts` + `hooks/streamSend.ts` / `streamFollowing.ts` / `streamRecovery.ts` / `streamQueue.ts` |
+| 通用分帧器 | `utils/sse.ts`(`parseSSE`) |
+| 订阅客户端 | `runSubscriptionClient.ts`(`GET /v1/chats/stream/{run_id}/subscription`) |
+| 后端事件产出 | `core/agent_runtime/stream_main.py` / `stream_direct.py`、`core/llm/runtime/stream_events.py`;SSE 输出 `api/transports/run_sse.py` |
-## 流格式
-
-```
-data: {"type": "", ...payload} # JSON 事件
-data: [DONE] # 流终止标记
-: heartbeat # SSE 注释行心跳(直接忽略)
-```
-
-> 没有独立的 `done` 事件——结束是 `end` 事件或 `[DONE]`。正文事件规范名是 `content`,
-> 但 `ai_message` / `text` / `delta` 都会被当作正文别名接受。
-
-## 事件类型
-
-### `run_started` — 首帧
-
-```json
-{"type": "run_started", "run_id": "run_xxx", "message_id": "msg_xxx"}
-```
-
-- 必须记录到 `useChatStore.setActiveRun(chatId, { runId, messageId })`
-- `run_id` 是断线续播的凭据
-
-### `content` — 文本增量
-
-```json
-{"type": "content", "delta": "你好"}
-```
-
-- 取 `delta || content || text` 追加到当前消息内容
-- 兼容别名:`ai_message` / `text` / `delta`
-- 内嵌 `... ` 标签的模型由 parseBuffer 解析为 thinking(structuredReasoning=false 时)
-
-### `thinking` — 思考过程
-
-```json
-{"type": "thinking", "delta": "让我分析一下..."}
-```
-
-- 取 `content || text || delta`;有 `delta` 字段时按增量追加到最近的 thinking segment
-- 收到带 `delta` 的 thinking 即标记 structuredReasoning(思考走独立通道,如 reasoning_content),关闭 `` 内嵌解析
-- 兼容别名:`thought`
-
-### `content_replace` — 整段替换正文
-
-```json
-{"type": "content_replace", "content": "重写后的完整正文"}
-```
-
-- 本体治理评审后重写答案时使用:**整体替换**当前消息正文,而不是追加
-
-### `tool_call_start` / `tool_call_delta` — 工具参数流式展示
-
-```json
-{"type": "tool_call_start", "id": "call_abc123", "name": "internet_search"}
-{"type": "tool_call_delta", "id": "call_abc123", "delta": "{\"query\": \"北京"}
-```
-
-- 参数尚未生成完时先出卡片(status='running'),delta 增量拼接参数 JSON 实时展示
-- 随后的 `tool_call` 带完整参数,按 id 更新同一张卡片
-
-### `tool_call` — 工具调用(完整参数)
-
-```json
-{"type": "tool_call", "id": "call_abc123", "name": "internet_search", "input": {"query": "北京 天气"}}
-```
-
-- 参数字段取 `input ?? args ?? tool_args ?? arguments`
-- 按工具 id 去重:已存在则更新(status='running'),否则 push 到 `toolCalls[]` 并追加 `{ type: 'tool', toolIndex }` segment
-- 兼容别名:`tool_use` / `tool_start`
-
-### `tool_result` — 工具执行结果
-
-```json
-{
- "type": "tool_result",
- "id": "call_abc123",
- "output": "搜索结果内容...",
- "citations": [{"title": "...", "url": "...", "source": "internet_search"}]
-}
-```
+**新增事件处理只写在 `hooks/chatStream/` 对应的 reducer 里**,不要在别处再解析 SSE。
-- 结果字段取 `output ?? result`;`error` 字段存在 → status='error',否则 'success'
-- `citations` 追加到 `allCitations[]`
-- `subagent_name` 存在时改写 displayName 为「调用子智能体:xxx」
-- 兼容别名:`tool_end`
-
-### `tool_pending` — 工具等待批复
+## 流格式
-```json
-{"type": "tool_pending"}
```
-
-- UI 进入 pending 态;收到下一个非 heartbeat 事件自动解除
-
-### `batch_confirm` — 批量计划确认
-
-```json
-{"type": "batch_confirm", "plan_id": "...", "total": 20, "source_type": "xlsx",
- "preview": [...], "default_template": "...", "placeholder_keys": [...], "chat_id": "...", "warnings": [...]}
+data: {"type": "", "server_ts": 1730000000000, "event_offset": 42, ...payload}
+data: [DONE] # 流终止
+: heartbeat # 注释行心跳,忽略
```
-- batch_runner MCP 返回了执行计划,后端已暂停 agent
-- 调 `useBatchStore.setPendingConfirm(...)` 打开确认弹窗,用户审阅/编辑模板后才执行
+- `server_ts` 更新最后活动时间(停滞检测);`event_offset` 记录续播偏移。
+- 无 `type` 的裸文本帧当作正文追加。
+- 正文规范名 `content`,`ai_message` / `text` / `delta` 均作别名接受。
-### `file_confirm` — 我的空间写确认
+## 订阅帧(续播 / 刷新后)
-```json
-{"type": "file_confirm", "confirm_id": "...", ...}
-{"type": "file_confirm", "confirm_id": "...", "expired": true}
-```
+| 事件 | 说明 |
+|---|---|
+| `run_snapshot` | 订阅首帧,携带 `state`:对齐的原生快照(已产生的正文/思考/工具段),`installRunSnapshot()` 直接装入 |
+| `run_terminal` | run 已终态,结束跟随 |
-- 某工具协程已挂起等用户确认 /myspace 写。**本 SSE 流不结束**——用户点允许/拒绝走带外
- `POST /file-confirm`,挂起的工具原地续跑,后续 tool_result/meta 仍从同一条流来
-- 入队 `useUIStore.enqueuePendingConfirm(chatId, info)`;`expired: true` 表示该项超时回收,
- 调 `resolvePendingConfirm` 只摘掉这一个 confirm_id
+## 生命周期
-### `design_pick` — 建站方案三选一
+| 事件 | 载荷要点 | 处理 |
+|---|---|---|
+| `run_started` | `run_id`, `message_id` | 首帧,存 activeRun(续播凭据) |
+| `queued_run_started` | 排队消息交接信息 | 排队中的下一条消息开始执行(`parseQueuedRunHandoff`) |
+| `steer_applied` | 插话边界 | 运行中插话被采纳,切分正文边界 |
+| `meta` | `message_id`, `citations`, `workspace_files`, … | 设置 messageId;`citations` 非空时整体替换 |
+| `follow_up` | `follow_up_questions[]` | 追问建议;也可能流结束后后台生成写入消息 `extra_data` |
+| `error` | `error` | 终止本次流处理 |
+| `end` / `[DONE]` | — | 收尾所有 running 工具,标记流结束 |
-- 与 `file_confirm` 同机制:流不结束,用户选择走带外接口,工具协程原地续跑
+## 正文与思考
-### `plan_update` — 计划清单更新
+| 事件 | 处理 |
+|---|---|
+| `content`(别名 `ai_message` / `text` / `delta`) | 取 `delta || content || text` 追加;内嵌 `` 的模型由 parseBuffer 拆出思考 |
+| `thinking`(别名 `thought`) | 带 `delta` 时按增量追加到最近的 thinking 段,并标记 structuredReasoning |
+| `content_replace` | **整体替换**当前正文(本体治理重写答案) |
-```json
-{"type": "plan_update", "plan": [{"content": "...", "status": "completed"}, ...]}
-```
+## 工具
-- `update_plan` 工具产生的轻量计划清单;run 正常结束时后端自动补发全部 completed 的收尾帧
+| 事件 | 载荷要点 | 处理 |
+|---|---|---|
+| `tool_call_start`(兼容 `tool_use` / `tool_start`) | `id`, `name` | 先出运行中的工具行 |
+| `tool_call_delta` | `id`, `arguments_delta` | 参数 JSON 增量拼接实时展示 |
+| `tool_call` | `id`, `name`, `input`(兼容 `args` / `tool_args` / `arguments`) | 按 id 更新同一项 |
+| `tool_progress` | `id`, `progress`, `total`, `message` | 运行中工具的进度注记(`progressNote`,如"3/10") |
+| `tool_result`(兼容 `tool_end`) | `id`, `output`/`result`, `error`, `citations` | 置 success / error;`subagent_name` 存在时改写展示名 |
+| `tool_pending` | — | 工具等待批复,下一个非心跳事件自动解除 |
+| `subagent_event` | `subType`: tool_call / tool_call_delta / tool_result / thinking / content / error | 渲染为父工具的 `subSteps` |
+| `vision_progress` | `status`, `count` | 视觉桥在模型开口前读图,写 `chatStore.setVisionReading` |
-### `subagent_event` — 子智能体嵌套事件
+> **UI 规范(v2)**:工具调用默认是**一行安静的文字**(做了什么 · 结果多少 · 用了多久),点击展开;运行中用文字微光,
+> 完成用 `statusIn` 落定图标。只有文件、表格、网页等产物做成卡片。见 `ui-design-spec.md` §7.3 / §8.6。
-```json
-{"type": "subagent_event", "subType": "tool_call", ...}
-```
+## 需要用户介入(流不结束,带外批复后原地续跑)
-- `subType` 取值:`tool_call` / `tool_call_delta` / `tool_result` / `thinking` / `content` / `error`
-- 渲染为父工具卡片的 `subSteps` 子步骤
+| 事件 | 处理 |
+|---|---|
+| `file_confirm`(`confirm_id`;`expired: true` 表示超时回收) | 入队 `useUIStore.enqueuePendingConfirm`;用户允许/拒绝走带外接口 |
+| `design_pick` / `choose_design` 工具 | 建站三选一卡片,同 file_confirm 机制;超时由助手自行选择 |
+| `user_question`(`request_id`, `questions[]`) | `ask_user_question` 挂起 run,常驻输入区被问答面板替换;`enqueuePendingUserQuestion`,按 `request_id` 去重 |
+| `user_question_resolved`(`request_id`, `outcome`) | **后端拥有终态**:在这里摘除;`outcome === 'timeout'` 时提示采用默认方案 |
-### `steer_applied` — 运行中插话生效
+> v2:这些"等你确认"项是**唯一允许扩散涟漪**的状态。
-- 用户在 run 进行中追加的指令(steering)被后端采纳的边界标记
+## 上下文与压缩
-### `compaction_notice` — 上下文压缩提示
+| 事件 | 处理 |
+|---|---|
+| `context_usage` | 上下文用量快照 → `chatStore.setContextUsage`(ContextGauge) |
+| `compaction_progress` | 压缩进度(`phase` 如 `post_turn` = 本轮结束后执行的压缩)→ upsert `compaction` 段 |
+| `compaction_notice` | 上一轮后台压缩完成的提示 → `chatStore.setContextCompaction` |
-- 长会话触发上下文压缩时的提示帧
+## 计划 / 循环
-### `model_progress` — 模型活性信号
+- `plan_update`:`update_plan` 工具产生的轻量计划清单;run 正常结束时后端补发全部 completed 的收尾帧。
+- Plan 模式(`plan_step_*` / `plan_complete` 等)与自主循环(`loop_*`)事件经 `ChatStreamOptions.onEvent` 钩子交给
+ `usePlanMode` / `useLoopMode` 先处理,钩子返回 true 则不再进通用 reducer。
-- 表示"模型还在飞行中",用于喂 run 卡死看门狗/停滞检测,UI 不直接渲染
+## 本体治理
-### `ontology_*` — 本体治理事件族
+`ontology_activation` / `ontology_gate` / `ontology_review` / `ontology_repair` / `ontology_revision` /
+`ontology_revision_thinking`;`scope === 'ontology_revision'` 的工具事件单独分流(重写结果经 `content_replace` 落地)。
-`ontology_activation` / `ontology_gate` / `ontology_review` / `ontology_repair` /
-`ontology_revision` / `ontology_revision_thinking`——本体激活、工具闸、输出评审、修复与重写过程
-(重写结果经 `content_replace` 落地)。另有 `eventObj.scope === 'ontology_revision'` 的分流。
+## 已退役(不要再处理或产出)
-### `follow_up` — 追问建议
-
-```json
-{"type": "follow_up", "follow_up_questions": ["追问1", "追问2"]}
-```
-
-- 追问也可能不走 SSE:流结束后由后台任务生成,写进消息 `extra_data.follow_up_questions`,
- 前端轮询/刷新历史时取到
-
-### `meta` — 元信息
-
-```json
-{"type": "meta", "message_id": "msg_xxx", "citations": [...], "workspace_files": [...], "artifacts": [...]}
-```
-
-- 设置 `messageId`;`citations` 非空时**整体替换** allCitations
-- `artifacts` 追加为下载类 toolCall 展示
-
-### `error` — 错误
-
-```json
-{"type": "error", "error": "错误描述"}
-```
-
-- 直接 throw,终止本次流处理
-
-### `end` / `[DONE]` — 流结束
-
-- `{"type": "end"}` 或 `data: [DONE]` 均表示结束
-- finalize 所有 running 状态的 toolCalls,触发后续操作(摘要、分类等由后端负责)
+- `batch_confirm`(批量执行已退役)
+- `model_progress`(活性信号改由 `server_ts` / 心跳承担)
## Segment 渲染顺序
-消息通过 `segments[]` 按顺序渲染:
+`segments[]` 按到达顺序渲染:`thinking` / `tool` / `text` / `plan` / `compaction`。
-```
-[thinking] → [tool] → [thinking] → [tool] → [text]
-```
-
-每个 segment 类型对应不同的 UI 组件:
-- `thinking` → ThinkingBlock (可折叠)
-- `tool` → ToolCall (可展开,显示名称/参数/结果;子智能体带 subSteps)
-- `text` → Markdown 渲染 + 引用锚点胶囊(`[锚文本](cite:eN)`)
+- `thinking` → 一行"已思考 N 秒",可折叠
+- `tool` → 一行过程记录,可展开(子智能体带 subSteps)
+- `text` → Markdown + 引用锚点胶囊(`[锚文本](cite:eN)`)
- `plan` → 计划清单条
-
-## 中断流
-
-```typescript
-// AbortController 中断
-const abortController = new AbortController();
-
-// 发送请求时
-const r = await authFetch(url, {
- method: 'POST',
- signal: abortController.signal,
- body: ...,
-});
-
-// 用户点击停止
-abortController.abort();
-```
+- `compaction` → 压缩过程行
## 断线续播
-```typescript
-// run_started 时已存下 activeRun
-const { runId } = useChatStore.getState().activeRuns[chatId];
+`hooks/streamRecovery.ts`:
-// 重连:从后台 run 的 Redis Stream 重放 + 继续跟随
-const r = await authFetch(`${apiUrl}/v1/chats/stream/${runId}`);
-// 事件格式与 POST /v1/agents/responses 完全一致
-```
-
-实际编排在 `useStreaming.ts::resumeRunIfAny`:
-
-- `getActiveChatRun` 探测活跃 run;已终态则清僵尸状态(`cleanupZombieRunState`)
-- 普通对话从 `last_event_offset` **断点续传**;`plan_generate` / `plan_execute` / `autonomous_loop`
- 从 offset 0 **全量重放**(loop 会先截掉尾部 assistant 占位再重建)
-- 跨标签页互斥:Web Locks `hugagent_run_follow_${run_id}`(`ifAvailable: true`),
- 只允许一个标签页跟随同一 run
+- 探测活跃 run(`last_event_offset`);已终态则清理僵尸状态
+- 普通对话:`openRunSubscription(runId)` → `run_snapshot` 首帧恢复已产生内容,再继续跟随
+- `plan_generate` / `plan_execute` / `autonomous_loop`:从 offset 0 全量重放
+- 跨标签页互斥:Web Locks `hugagent_run_follow_${run_id}`,只允许一个标签页跟随同一 run
- 运行中插话:`steerChatRun` / `withdrawChatRunSteer`,失败时降级为下一轮普通消息
+
+中断:`AbortController.abort()` 只断开跟随;取消 run 走显式取消接口(`markRunCancelledByUser`)。
diff --git a/.claude/skills/hugagent-frontend-dev/references/ui-design-spec.md b/.claude/skills/hugagent-frontend-dev/references/ui-design-spec.md
index 8acca712..081f9362 100644
--- a/.claude/skills/hugagent-frontend-dev/references/ui-design-spec.md
+++ b/.claude/skills/hugagent-frontend-dev/references/ui-design-spec.md
@@ -1,448 +1,423 @@
-# UI 设计规范参考
+# HugAgentOS UI 设计规范 v2(开发落地版)
-本文档基于 UI 团队出品的设计规范(`img/设计规范/`),是前端开发的视觉对齐依据。
+> 来源:《HugAgentOS 设计规范 v2.0 · 2026.10》(`HugAgentOS-UI设计规范/HugAgentOS设计规范-v2.html` + `assets/`)。
+> v2 相对 v1 经过一次对抗性审查,修了 25 项问题(6 项 P0 可访问性/语义错误、14 项 P1 层级一致性、5 项 P2 品牌表达)。
+> 本文只保留写代码要用的规则与数值;品牌物料、宣传版式见原规范。
+>
+> **代码是唯一事实来源。** 改令牌时 `styles/variables.css`、`utils/motionTokens.ts`、`appTheme.ts` 三处同步改。
+> v2 令牌参考实现在本 skill 的 `assets/design-v2/tokens/`,**尚未全部合入仓库**——合入状态和落地方式见
+> `css-variables.md` 的「v1 → v2 对照」与本文末尾「迁移清单」。
---
-## 1. 颜色系统
-
-### 1.1 主色 (Primary Blue)
-
-| 状态 | 色值 | 用途 |
-|------|------|------|
-| 常规 | `#126DFF` | 主按钮、链接、选中态 |
-| 悬浮 | `#3C87FF` | hover 状态 |
-| 按下 | `#0862F3` | active/pressed 状态 |
-| 禁用、描边 | `#A0C5FF` | disabled 按钮描边 |
-| 背景 | `#DBE9FF` | 选中项背景、badge 背景 |
-| 浅色/白色悬浮 | `#EBF2FF` | 列表 hover、浅色背景 |
-
-### 1.2 边框与填充 (Neutral Gray)
-
-| 状态 | 色值 | 用途 |
-|------|------|------|
-| 重(填充或 icon) | `#8C94A2` | 实心 icon 填充 |
-| 深/悬浮 | `#B3BAC8` | 深边框、placeholder |
-| 一般 | `#D8DBE2` | 常规分割线、边框 |
-| 描边 | `#E3E6EA` | 轻量描边 |
-| 灰色背景上的 hover | `#EBEDEE` | 灰背景区域的 hover |
-| 背景灰 | `#F5F6F7` | 次级背景、侧边栏底色 |
-
-### 1.3 文字色
-
-| 色值 | 用途 |
-|------|------|
-| `#101828` | 强调文字、正文标题 |
-| `#475467` | 次强调、大段正文内容 |
-| `#667085` | 次要信息 |
-| `#98A2B3` | 置灰/说明文字 |
-| `#FFFFFF` | 纯白文字(深色背景上) |
-
-### 1.4 语义色 — 成功 (Success Green)
-
-| 状态 | 色值 |
-|------|------|
-| 常规 | `#02B589` |
-| 悬浮 | `#11D4A4` |
-| 按下 | `#04A87F` |
-| 禁用、描边 | `#80DAC4` |
-| 背景 | `#CFF8EE` |
-| 浅色/白色悬浮 | `#E6FFF9` |
-
-### 1.5 语义色 — 警告 (Warning Orange)
-
-| 状态 | 色值 |
-|------|------|
-| 常规 | `#F8AB42` |
-| 悬浮 | `#FFC16C` |
-| 按下 | `#F69D24` |
-| 禁用、描边 | `#FBD5A0` |
-| 背景 | `#FFECD1` |
-| 浅色/白色悬浮 | `#FFF5E8` |
-
-### 1.6 语义色 — 失败/危险 (Error Red)
-
-| 状态 | 色值 |
-|------|------|
-| 常规 | `#FC5D5D` |
-| 悬浮 | `#FF7676` |
-| 按下 | `#F95555` |
-| 禁用、描边 | `#FCAAAA` |
-| 背景 | `#FFDFDF` |
-| 浅色/白色悬浮 | `#FFF0F0` |
-
-### 1.7 Icon 专用色
-
-按需取色,保证页面**蓝色系占比最多**。
-
-| 颜色 | 色值 |
-|------|------|
-| 蓝色 | `#126DFF` |
-| 红色 | `#F25149` |
-| 紫色 | `#7655FA` |
-| 绿色 | `#02B589` |
-| 橙色 | `#F8AB42` |
+## 0. 设计原则(冲突时按顺序取舍)
+
+1. **内容为先**:界面是容器,报告、表格、答案才是主角。界面元素用中性色,颜色只表达含义和**唯一**的主要操作。能用留白分组就不加边框和底色。
+2. **清晰的层级**:每个视图只有一个主要操作。层级靠字号、字重、间距建立,不靠盒子套盒子。
+3. **一致而自然**:同样的东西永远长一样、动一样。动效有来处、有去处、可打断。照顾深浅外观、字号放大、减弱动效。
+4. **过程安静可见**:智能体在思考/检索/调用工具时,用**一行安静的文字**说清在做什么、用了多久;可展开看细节,默认不打扰。不要多处同时闪动的指示器。
---
-## 2. 文字排版
+## 1. 色彩
+
+### 1.1 品牌色
+
+| 名称 | 色值 | 用途 |
+|---|---|---|
+| 环抱蓝 Hug Blue | `#126DFF`(白字 4.51:1) | **唯一强调色**:主要操作、链接、选中、焦点 |
+| 火花绿 Spark | `#19D3A7` | **只属于标志和品牌动画,界面中禁用**(与成功色亮度对比仅 1.37:1,分不清) |
+| 墨 | `#0B0D12` | 文字、深色底(宣传) |
+| 纸 | `#FFFFFF` | 浅色底 |
+
+- 深色模式主色**填充仍用 `#126DFF`**(v1 的 `#3E8BFF` 白字只有 3.31:1,P0);深色下的链接/强调文字改用 `--color-primary-text`(`#5C9BFF`)。
+
+### 1.2 语义色:每种只有三个令牌
+
+| 语义 | 填充(状态点、实心按钮) | 前景(文字/图标,≥4.5:1) | 浅底(提示条、标签) |
+|---|---|---|---|
+| 强调 | `--color-primary` #126DFF | `--color-primary-text` #0F62E6 | `--color-primary-light` #EBF2FF |
+| 成功 | `--color-success` #02B589 | `--color-success-text` #067A5E | `--color-success-bg` #E6FFF9 |
+| 警告 | `--color-warning` #F8AB42 | `--color-warning-text` #B45309 | `--color-warning-bg` #FFF5E8 |
+| 错误 | `--color-error` #FC5D5D | `--color-error-text` #D92D20 | `--color-error-bg` #FFF0F0 |
+| 破坏性实心按钮 | `--color-danger-fill` #D92D20(白字 4.83:1) | — | — |
+
+规则:
+- **语义色不能直接当文字色用**(v1 的 success/warning/error 当文字只有 2.63/1.93/3.06:1,P0)。文字、图标一律用 `*-text`。
+- **取消 hover/active/disabled 色阶**(v1 的 `--color-*-hover/-active/-disabled` 不再用于新代码)。状态改为统一叠加层,见 1.3。
+- 浅底上的文字用对应的 `*-text`,不要用填充色压浅底(v1 危险按钮红字压浅红底 2.76:1,P0)。
-### 2.1 字体栈
+### 1.3 状态叠加层(hover / press / disabled)
-| 平台 | 字体 | 字重 |
-|------|------|------|
-| macOS | PingFang SC | 正常 (400) / 加粗 (600) |
-| Windows | 微软雅黑 | 正常 (400) / 加粗 (700) |
-| 特殊数字/汉字 | 钉钉进步体 / DingTalk Sans | 正常 (400) |
+颜色只会**变深**,白字对比度不会下降:
+
+| 状态 | 中性控件(透明/浅底) | 彩色填充(主按钮等) |
+|---|---|---|
+| 悬停 | 叠 `--state-hover`(浅 `rgba(16,24,40,.04)` / 深 `rgba(255,255,255,.06)`) | 叠 `--state-on-fill-hover`(黑 8% / 深色黑 12%) |
+| 按下 | 叠 `--state-press`(.08 / .10),并 `scale(.97)` | 叠 `--state-on-fill-press`(黑 16% / 深色黑 22%),并 `scale(.97)` |
+| 禁用 | 整体 `opacity: .4` | 整体 `opacity: .4` |
+
+写法(叠加而不是换色):
-**CSS font-family 推荐写法(与 variables.css 实现一致,系统栈优先):**
```css
-font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue",
- "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
+.jx-xxx-btn { background: var(--color-primary); color: var(--color-on-primary); }
+.jx-xxx-btn:hover { background-image: linear-gradient(var(--state-on-fill-hover), var(--state-on-fill-hover)); }
+.jx-xxx-btn:active { background-image: linear-gradient(var(--state-on-fill-press), var(--state-on-fill-press)); transform: scale(.97); }
+.jx-xxx-btn:disabled { opacity: .4; }
```
-### 2.2 字号层级
+antd 侧:`colorPrimaryHover` / `colorPrimaryActive` 设为**更深**的 `#0F62E6` / `#0A56CC`(见 `assets/design-v2/tokens/appTheme.v2.ts`)。
-| 字号 | 字重 | 用途 |
-|------|------|------|
-| 12px | 常规 | 辅助文案、标签 |
-| 14px | 常规 | 正文 - 常规(默认正文) |
-| 14px | 加粗 | 小标题 |
-| 16px | 常规 | 正文 - 大字号 |
-| 16px | 加粗 | 常规标题 |
-| 18px | 加粗 | 特殊标题和强调 |
-| 22px | 加粗 | 页面一级标题 |
-| 44px | 加粗 | 页面 banner、运营等标题 |
+### 1.4 文字与背景
-### 2.3 日期格式
+文字三级(**占位符 = 三级文字色**,不再用 2.58:1 的 `#98A2B3`):
-- 用 `/` 分隔:`2025/08/18 14:08`
-- 到最末右对齐
+| 令牌 | 浅色 | 深色 | 用途 |
+|---|---|---|---|
+| `--color-text` | #101828 | #E8ECF4 | 正文、标题 |
+| `--color-text-secondary` | #475467 | #B3BDCD | 说明 |
+| `--color-text-tertiary` | #667085 | #8792A4 | 辅助、时间戳、**占位符** |
----
+背景与线(深色模式层级越高表面越亮,不靠加重投影):
+
+| 令牌 | 浅色 | 深色 | 用途 |
+|---|---|---|---|
+| `--color-bg-base` | #FFFFFF | #0F141B | 页面底色 |
+| `--color-bg-layout` | #F5F7FB | #12171F | 侧栏、分区底 |
+| `--color-bg-container` | #FFFFFF | #161C25 | 卡片、面板 |
+| `--color-bg-elevated` | #FFFFFF | #1C2330 | 浮层、弹窗 |
+| `--color-bg-chat` | #FDFDFC | #111720 | 对话区底 |
+| `--color-separator` | #E3E8EF | #2B3442 | 分隔线(装饰性;即 v1 的 `--color-border`) |
+| `--color-control-border` | #C5CCD8 | #3A4556 | 输入框边框(配合可见标签) |
+| `--color-fill-quiet` | #F2F4F7 | #1F2631 | 次要按钮、胶囊、用户气泡 |
+| `--material` | rgba(255,255,255,.82) | rgba(28,35,48,.80) | 浮层材质,配合 `backdrop-filter: blur(20px)` |
+| `--scrim` | rgba(16,24,40,.32) | rgba(0,0,0,.56) | 模态遮罩 |
-## 3. 图标
+### 1.5 分类色:从 7 组收敛到 4 组
-- **推荐图标库**: IconPark(字节跳动)
-- **最小切图尺寸**: 16px,其他尺寸为 4 的倍数
+只用于区分类别,**类别优先用图标和文字区分,颜色只做辅助**:
-### 3.1 线性图标
+| 组 | 示例 | 令牌(浅底 + 前景成对使用) |
+|---|---|---|
+| 蓝 | MCP / 连接器 | `--color-tint-blue-bg` + `--color-tint-blue` |
+| 紫 | 技能 | `--color-tint-purple-bg` + `--color-tint-purple` |
+| 青 | 自动化 | `--color-tint-teal-bg` + `--color-tint-teal` |
+| 琥珀 | 计划模式 | `--color-tint-amber-bg` + `--color-tint-amber` |
+
+新代码不要再用 `cyan / green / red` 三组 tint(存量保留,不新增引用)。
+
+---
-| 尺寸 | 描边宽度 | 圆角 | 端点 |
-|------|---------|------|------|
-| 32px | 2.4px 居中 | 2px | 圆形 |
-| 16px | 1.2px 居中 | 1px | 圆形 |
+## 2. 文字层级
-> 使用时只根据 32px / 16px 两种基准尺寸,通过缩放功能来变化大小。
+按**角色**命名(参考 Apple 文字样式),九级覆盖所有场景。**字重只用 400 / 500 / 600**(不再出现 700/900)。中文不加负字距;西文 28px 以上收紧。**字号一律用 rem**,跟随浏览器缩放。
-### 3.2 面性图标 — 单色
+| 角色 | 令牌前缀 | 字号 / 行高 / 字重 / 字距 | 用途 | 与 v1 |
+|---|---|---|---|---|
+| 展示 display | `--font-size-display` | 2.5rem(40) / 48 / 600 / -0.02em | 欢迎语、宣传 | 调整(原 xxl 44) |
+| 标题一 title-1 | `--font-size-title-1` | 1.75rem(28) / 36 / 600 / -0.01em | 页面标题 | 新增 |
+| 标题二 title-2 | `--font-size-title-2` | 1.375rem(22) / 30 / 600 | 分区标题 | 沿用 xl |
+| 标题三 title-3 | `--font-size-title-3` | 1.125rem(18) / 26 / 600 | 弹窗、卡片标题 | 沿用 lg |
+| 重点 headline | `--font-size-headline` | 0.9375rem(15) / 22 / 600 | 列表项标题 | 新增 |
+| 阅读 body-lg | `--font-size-body-lg` | 1rem(16) / 28 / 400 | 对话、文档正文 | 沿用 md |
+| 正文 body | `--font-size-body` | 0.875rem(14) / 22 / 400 | **界面默认**:按钮、输入、列表、表格 | 沿用 sm |
+| 注释 footnote | `--font-size-footnote` | 0.8125rem(13) / 20 / 400 | 过程行、说明 | 新增 |
+| 说明 caption | `--font-size-caption` | 0.75rem(12) / 18 / 400 | 时间戳、标签 | 沿用 xs |
-- 尺寸 32px,背景圆角 8px
+每级同时有 `--line-height-*` / `--font-weight-*` / `--letter-spacing-*` 四件套(见 v2 token CSS)。
-### 3.3 面性图标 — 双色
+字体(已在代码中落地,`platformTypography.ts` 在挂载前给 `` 打 `data-font-platform`):
-- 同色系,主颜色 100%,浅色 50%
+| 场景 | 字体 |
+|---|---|
+| 界面中文 | Apple: PingFang SC · Windows: Microsoft YaHei UI · 其他: Noto Sans CJK SC(`--font-family`) |
+| 数字 | DingTalk Sans → 系统字体(`--font-family-number`);**表格、计时、金额一律 `font-variant-numeric: tabular-nums`** |
+| 代码 | ui-monospace, SF Mono, Menlo, Consolas(`--font-family-mono`) |
+| 宣传 | 思源黑体(不进产品界面) |
---
-## 4. 间距
+## 3. 间距与尺寸
-以 **4px 为基本单位**,使用 4 的倍数作为间距。
+**8 栅格**:`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64`。4 只用于图标和文字之间的半步。v1 的 20 与 40 不在栅格上,**新代码不再使用**。
-| 值 | 常见用途 |
-|-----|---------|
-| 4px | 紧凑间距(标签内边距、小图标间距) |
-| 8px | 元素间常规间距 |
-| 16px | 卡片内间距、段落间距 |
-| 20px | 模块间距、表单行距 |
-| 40px | 大区域分隔 |
+| v2 令牌 | 值 | 对应 v1 |
+|---|---|---|
+| `--space-1` | 4 | `--space-xs` |
+| `--space-2` | 8 | `--space-sm` |
+| `--space-3` | 12 | (v1 缺失) |
+| `--space-4` | 16 | `--space-md` |
+| `--space-6` | 24 | 替代 `--space-lg` 20 |
+| `--space-8` | 32 | (v1 缺失) |
+| `--space-12` | 48 | 替代 `--space-xl` 40 |
+| `--space-16` | 64 | — |
+
+尺寸规则:
+- **点击区**:桌面 ≥ 32×32,触屏 ≥ 44×44(真实尺寸)。紧凑控件最小 28,用伪元素/padding 外扩点击区。
+- **控件高度**:28(紧凑,仅桌面表格和工具栏)· 32(默认)· 40(主要操作、移动端)。不再出现 24px 高的按钮。
+- **阅读宽度**:对话与文档正文最大 **720px**(每行约 40 个汉字)。
+- **页面边距**:桌面 24 / 32,移动端 16 + 安全区(`env(safe-area-inset-*)`)。
---
-## 5. 圆角
+## 4. 圆角
+
+值不变,规则变严:**圆角跟随尺寸,且同心——内层圆角 = 外层圆角 − 内边距**。
-系统使用**大圆角**表达 AI、时尚、亲近的品牌感。
+| 令牌 | 值 | 用于 |
+|---|---|---|
+| `--radius-xs` | 6 | 28px 控件、代码块 |
+| `--radius-sm` | 10 | 32–40px 控件、菜单项 |
+| `--radius-md` | 16 | 卡片、菜单、弹窗 |
+| `--radius-lg` | 24 | 对话输入区、面板 |
+| `--radius-full` | 999px | 标签、头像、开关、独立图标按钮(胶囊) |
-| 圆角值 | 变量 | 用途 | 示例 |
-|--------|------|------|------|
-| 6px | `--radius-xs` | 小面积元素 | 标签、小卡片 |
-| 10px | `--radius-sm` | 常用组件 | 按钮、输入框、下拉框、菜单 |
-| 16px | `--radius-md` | 分割模块 | 卡片视图、背景卡片 |
-| 24px | `--radius-lg` | 大容器 | 弹窗、大输入框 |
+例:菜单外 16、内边距 6 → 选项 10(v1 外 10 / 内边距 6 / 选项 6 不同心,P1)。
---
-## 6. 投影
+## 5. 层级与材质
-为整体性统一限制投影层级,分为两种状态:
+一共四层。**投影只给浮在内容之上的东西。**
-- **常态**: 轻微投影(几乎无感知)
-- **悬停/hover**: 加深投影(凸显层级)
+| 层 | 背景 | 内容 | 投影 |
+|---|---|---|---|
+| L0 底面 | `--color-bg-layout` / `--color-bg-base` | 页面、侧栏 | 无 |
+| L1 内容 | `--color-bg-container` | 卡片、面板 | **静止无投影**,悬停出现 `--shadow-raised` 并上浮 1px |
+| L2 浮层 | `--material` + `backdrop-filter: blur(20px)` | 菜单、弹出框、提示 | `--shadow-floating` |
+| L3 模态 | `--color-bg-elevated` + `--scrim` 遮罩 | 对话框、表单抽屉 | `--shadow-floating` |
-可根据具体场景微调投影颜色。
+- 静止卡片不加投影,用底色和留白分组(替代 v1 `--shadow-card`)。
+- **对话输入区只保留投影(`--shadow-raised`),不再同时加边框**。
+- 投影令牌:`--shadow-raised`(原 card)、`--shadow-floating`(原 dropdown),深浅两档已定义。
---
-## 7. 按钮
+## 6. 图标
-最小切图尺寸 **36px**,其他为 4 的倍数。
+参考 SF Symbols:**图标是文字的一部分**。
-### 7.1 按钮类型
+- 24px 画板,四周留 2px,**线宽 1.5px**,圆头圆角(`stroke-linecap/linejoin: round`),`stroke="currentColor"`。
+- 尺寸跟随字号、线宽跟随字重、颜色继承文字色:13px 常规 → 线宽 1.5;15px 中粗 → 2;20px 标题 → 2。
+- 默认线性,**选中态换面性**。
+- 风格样例:`assets/design-v2/icons-ui/`(12 个:new-chat / search / knowledge / skill / automation / my-space / attachment / settings / send / chevron-right / check / close)。
+- 既有 IconPark / `@ant-design/icons` 可继续用,但要满足上面的线宽与 currentColor 规则;不要给图标单独上品牌色。
-| 类型 | 说明 |
-|------|------|
-| 主要按钮 | 蓝色填充(`#126DFF`),白色文字 |
-| 次要按钮 | 白色填充、蓝色描边 |
-| 文字按钮 | 无边框、无背景,文字色即操作色 |
-| 图标按钮 | 仅图标,无文字 |
-| 成功按钮 | 绿色填充(`#02B589`) |
-| 危险按钮 | 红色填充(`#FC5D5D`) |
+---
-### 7.2 按钮尺寸
+## 7. 组件
-| 尺寸 | 高度(参考) |
-|------|------------|
-| 大 | 40px |
-| 中(默认) | 36px |
-| 小 | 28px |
+以 Ant Design 为底,用 v2 令牌覆盖(ConfigProvider token,不覆写组件内部 DOM 样式)。
-### 7.3 按钮状态
+### 7.1 按钮
-每种按钮均有 4 种状态:常规 → 悬浮(hover) → 按下(active) → 禁用(disabled)。
+- **一个视图只有一个主要按钮**(`type="primary"`)。
+- 次要操作用**浅灰填充**(`--color-fill-quiet`),**不再用描边按钮**。
+- 低频操作用文字按钮(`type="text"` / `type="link"`)。
+- 破坏性操作:**确认框里用实心红**(`--color-danger-fill` + 白字),**列表里用红色文字按钮**(`--color-error-text`)。
+- 尺寸 28 / 32 / 40;加载中按钮文案写进行时("生成中")。
+- 文案写动作:"生成报告",不写"立即开启智能分析之旅"。
----
+### 7.2 输入框与对话输入区
-## 8. 导航
+- 输入框**必须有可见标签**,不能只靠占位符说明用途。占位符写示例("例如:Q3 华东区销售分析")。
+- 出错时**写清原因和改法**:"请输入 00:00 到 23:59 之间的时间"。
+- 边框用 `--color-control-border`。
+- **对话输入区**是最重要的控件:`--radius-lg` 大圆角,**只有投影没有边框**;内容为空时发送按钮是中性色,**有内容后才变主色**(主色只在能操作时出现)。
-### 8.1 导航菜单
+### 7.3 对话与过程
-- 垂直列表,图标 + 文字
-- 选中项高亮
+- 用户消息:浅灰气泡(`--color-fill-quiet`)。助手回复**不加气泡**。
+- 思考、工具调用:**默认一行安静的文字**——做了什么、结果多少、用了多久,点击展开细节。
+ 例:"已思考 12 秒"、"已检索知识库「销售数据 2026」14 条 · 0.8s"、"正在生成 Excel 报表"。
+- **只有用户会打开的产物**(文件、表格、网页)才做成卡片。v1 把每次工具调用做成卡片(过程比答案显眼,P1),新代码不要这样。
+- 正文宽度 ≤ 720px,阅读字号 body-lg(16/28)。
-### 8.2 Tabs / 标签页
+### 7.4 状态
-- **一级**: 页面或模块的一级导航,有选中样式、默认样式、hover 样式
-- **二级**: 页面或模块的二级导航
-- **三级**: 紧凑标签切换
+状态**同时用颜色和形状**表达。**一屏只有一个持续动画。**
-### 8.3 面包屑 / 返回
+| 状态 | 表现 |
+|---|---|
+| 运行中(当前那一项) | 文字微光(shimmer),一屏一个 |
+| 运行中(其他项) | 静止蓝点 |
+| 已完成 | 成功色勾,`statusIn` 落定 |
+| 等你确认 | 警告色点 + **扩散涟漪**(唯一允许涟漪的状态,因为它在叫人) |
+| 失败 | 错误色 |
+| 排队中 / 已暂停 | 中性色静止 |
+| 计数徽标 | 超过 99 显示 `99+` |
-- **面包屑**: 显示页面在系统层级结构中的位置,并提供上返回,本系统中较少使用
-- **返回**: 用于二级页面,能向上返回
+### 7.5 卡片与浮层
+
+- 卡片静止无投影,悬停出现 `--shadow-raised` 并 `translateY(-1px)`。
+- 菜单用材质背景,**从触发点展开**(`transform-origin` 指向触发点),`--radius-md` + 选项 `--radius-sm`。
+- 提示条(toast):**反色胶囊**,出现在**底部中间**,3 秒后自动消失,新提示替换旧提示。
+- 快捷键提示(⌘N / ⌘K)用 caption 字号、三级文字色。
---
-## 9. 数据录入
+## 8. 动效
-### 9.1 输入框
+### 8.1 原则
-- 标签对齐方式在页面内统一(左对齐 / 右对齐 / 顶对齐)
-- 默认高度 36px
-- 前后间距 20px(页面范围统一)
-- 前后间距需要在整个页面范围统一
-- 可附带前缀 icon
+1. **有来处**:菜单从按钮展开,面板从边缘滑入,消息从输入框升起;返回沿原路离开。
+2. **可打断**:手势、拖拽、展开收起、布局变化都用弹簧;中途打断从当前位置和速度继续。动画期间界面始终可操作。
+3. **克制**:产品界面弹簧不回弹或只轻微回弹;一次性过渡 ≤ 320ms;除折叠展开外**只动 `opacity` 和 `transform`**,不动 filter(含 blur)和阴影。
+4. **进行中常在**:表示"正在运行"的动画在减弱动效时也继续;装饰性位移在减弱动效时换成淡入淡出。
-### 9.2 下拉选择
+### 8.2 弹簧(Motion 库,用时长 + 回弹描述)
-- 包含下拉选择 + 搜索
-- 基础样式有正文类型、hover 状态
-- 有单选和多选模式
-- 支持复选框列表
+| 预设 | 参数 | 用于 |
+|---|---|---|
+| `SPRING.smooth` | `visualDuration: .35, bounce: 0` | 面板、抽屉、折叠展开、布局变化(替代 v1 `panel`) |
+| `SPRING.snappy` | `visualDuration: .25, bounce: .15` | 标签页指示条、开关、徽标确认(替代 v1 `pop` / `ink`) |
-### 9.3 日期选择器
+v1 的 `pop` / `panel` 回弹 33%(面板会晃,P1),`ink` 一并标记弃用;**新代码只用 smooth / snappy**,`bouncy` 不进产品界面。
-- 标准日历面板
-- 支持日期范围选择
-- 支持快捷选项(本周/上周/本月等)
+### 8.3 时长与曲线(不适合弹簧的一次性过渡)
-### 9.4 多选 / 单选
+| 令牌 | 时长 | 用于 |
+|---|---|---|
+| `--motion-duration-instant` | 100ms | 悬停着色、按下反馈 |
+| `--motion-duration-fast` | 160ms | 状态切换、菜单弹出、图标落定 |
+| `--motion-duration-normal` | 240ms | 入场、出场、消息发送、列表编排 |
+| `--motion-duration-slow` | 320ms | 面板转场、折叠展开(能用弹簧优先弹簧) |
-- 单选: 圆形 radio,选中为蓝色填充
-- 多选: 方形 checkbox,选中为蓝色勾选
-- 有横排和竖排两种布局
+曲线三条:`--motion-ease-brand-out`(入场/展开/升起)、`--motion-ease-standard`(双向状态切换)、`--motion-ease-exit`(退场)。
+**退场时长 = 入场 × 0.7**(如菜单 160 → 112ms,提示条 240 → 168ms)。
+**禁止**在代码里写 `ease` / `ease-out` / 新的 cubic-bezier 值,TS 侧用 `utils/motionTokens.ts` 的 `DUR` / `EASE`。
-### 9.5 开关
+### 8.4 编排
-- 默认灰色背景
-- 开启状态为主色蓝(`#126DFF`)
-- 带有开/关文字标识
+- 静态列表入场逐项错开 60ms(`--motion-stagger-step` / `STAGGER_STEP`),**第 8 项起不再增加延迟**(`STAGGER_CAP`)。
+- 超过 20 项的动态列表不做布局动画(`LAYOUT_ANIM_MAX_ITEMS`)。
+- 翻看历史记录不播任何入场动画,只对 **2 秒内新产生**的内容播放(`FRESH_ENTER_WINDOW_MS`)。
----
+### 8.5 原语库(新增动画前先找同语义的原语)
-## 10. 数据展示
+命名:关键帧 `jx-kf-名称`,工具类 `jx-anim-名称`(定义在 `styles/motion.css`)。**入场类一律 `animation-fill-mode: backwards`。**
-### 10.1 标签 (Tag)
+| 原语 | 工具类 | 参数 | 用于 | 减弱动效时 |
+|---|---|---|---|---|
+| fadeInUp | `.jx-anim-fadeInUp` | 240ms · brand-out · 上移 8px | 卡片、确认条入场 | 淡入 |
+| fadeIn | `.jx-anim-fadeIn` | 240ms · standard | 大面积区域 | 淡入 |
+| scaleIn | `.jx-anim-scaleIn` | 160ms · brand-out · .97 → 1 | 菜单、弹出框(原点指向触发点) | 淡入 |
+| statusIn | `.jx-anim-statusIn` | 160ms · brand-out · .7 → 1 | 状态图标落定 | 淡入 |
+| flash | `.jx-anim-flash` | 1.2s · standard | 跳转定位后的一次性高亮 | 保持 |
+| shake | `.jx-anim-shake`(`jx-kf-shakeX`) | 300ms · ±3px | 校验失败 | 停止 |
+| shimmer 文字 | 关键帧 `jx-kf-shimmer` | 2s linear 循环 | 运行中的那一行(一屏一个) | 保持(`motion-keep`) |
+| ripple | `.jx-anim-ripple` | 1.6s 循环 | **只用于"等你确认"** | 停止 |
+| stagger | `.jx-anim-stagger` + `staggerStyle(i)` | 60ms 步进,封顶第 8 项 | 静态列表入场 | 淡入 |
-- 慎重使用标签,保证页面干净有序
-- 同一信息前台展现阶段对比最多使用 2 种颜色样式
-- 类型:基础标签、可关闭标签、表态标签
+> 代码里另有 `.jx-anim-pulse`(`motion-keep` 的通用"进行中"脉冲)与 `.jx-anim-flash-row`。v2 要求一屏只有一个持续动画:
+> 运行中的当前项优先用文字微光,其他运行项保持静止蓝点,不要为每一项都挂 pulse。
-### 10.2 卡片 (Card)
+### 8.6 智能体动效(一次任务的完整过程)
-- padding: 16–20px(可根据内容调整,特殊情况为 4 的倍数)
-- 类型一:图标 + 标题 + 描述 + 标签
-- 类型二:大面积内容卡片(标题 + 正文段落)
-- 圆角 12px
+同一时刻只有一个微光在动;文字按词块淡入,不做模糊,不逐字排队。
-### 10.3 气泡提示卡片
+| 环节 | v2 | v1(不要再这样写) |
+|---|---|---|
+| 发送 | 用户气泡 `translateY(8px) + scale(.98) → 1`,240ms brand-out,原点右下 | scale .92 · 300ms ease-out |
+| 思考 | 一行"正在思考 · 8 秒",文字微光 2s linear,计时每秒更新;骨架屏只在加载历史时出现,不与微光同屏 | 跳点 + 呼吸 + 骨架三处同时动 |
+| 调用工具 | 进行中:文字微光;完成:图标 `statusIn` 160ms,只留下一行可展开的记录 | 卡片 + 无限涟漪 |
+| 流式输出 | **按词块淡入**,opacity 180ms standard,落后模型 ≤ 300ms | 逐字 350ms + 65ms 错开 + blur 3px(每秒约 15 字,越积越多) |
-- 点击/鼠标移入元素时弹出气泡式卡片浮层
+### 8.7 交互控件
-### 10.4 Tips 提示
+| 控件 | 动效 |
+|---|---|
+| 开关 | 滑块 snappy 弹簧;底色 160ms standard |
+| 标签页 | 指示条位置与宽度 snappy 弹簧;文字颜色 160ms standard |
+| 面板 / 抽屉 | smooth 弹簧从边缘滑入;遮罩透明度跟随同一进度 |
+| 菜单 | 打开 160ms brand-out `scale(.97 → 1)`;关闭 112ms exit;原点在按钮处 |
+| 提示条 | 入场 240ms brand-out 上移 16px;退场 168ms exit;3 秒后消失 |
+| 折叠展开 | 高度与透明度 smooth 弹簧;箭头旋转 240ms brand-out |
-- 用于展示不完全的内容,鼠标移入时展示完整内容
-- 有浅色和深色两种样式
+### 8.8 减弱动效(`prefers-reduced-motion: reduce`)
-### 10.5 表格 (Table)
+v2 改为 Apple 做法:**位移和缩放换成淡入淡出,时长不变**;循环装饰停止;运行指示器继续。
-- 标准表格:表头 + 数据行 + 分页
-- 特殊表格:带颜色标记行
+| 类型 | 减弱动效时 | 写法 |
+|---|---|---|
+| 入场位移 / 缩放 | 淡入,时长不变 | 同文件 `@media (prefers-reduced-motion: reduce)` 内写**同名 `@keyframes` 覆盖版,只保留 opacity**,标注 `motion-fade` |
+| 弹簧(Motion 库) | 换成透明度过渡 | `useReducedMotion()` 为真时只动 opacity,时长取 `DUR.fast` |
+| 循环装饰(涟漪、抖动) | 停止 | 覆盖版定格,标注 `motion-stop` |
+| 工作指示器(微光、转圈、骨架) | 继续 | 使用它的规则上标注 `motion-keep: 理由` |
+| CSS transition | 归零 | 保持现状(改成正数会让 antd 浮层定位也产生过渡) |
----
+> 构建门禁 `scripts/check-loading-motion.mjs` 保留。**当前门禁仍按 v1 规则检查**:装饰性关键帧必须有同名
+> 覆盖版(v1 为"定格终态")或在使用处标注 `motion-keep:`。按 v2 写"仅 opacity"的同名覆盖版同样满足门禁;
+> `motion-fade` / `motion-stop` 标注随迁移清单第 3 项一起被门禁识别。
-## 11. 数据提示
+---
-### 11.1 Alert 警告提示
+## 9. 文案语气
-4 种语义,每种有可关闭和不可关闭两种形态:
+像一位靠谱的同事说话:直接、具体、不卖弄。
-| 类型 | 颜色 |
-|------|------|
-| 成功 | 绿色系 (`#02B589`) |
-| 注意 | 橙色系 (`#F8AB42`) |
-| 失败 | 红色系 (`#FC5D5D`) |
-| 提示信息 | 蓝色系 (`#126DFF`) |
+| 场景 | ✅ | ❌ |
+|---|---|---|
+| 按钮 | 写动作:生成报告 | 立即开启智能分析之旅 |
+| 进行中 | 写正在做什么:正在检索 3 个知识库 | AI 正在努力思考中… |
+| 出错 | 写原因和办法:文件超过 50MB,请压缩后重新上传 | 哎呀,出错了! |
+| 完成 | 写结果在哪:报告已保存到我的空间 | 操作成功 |
-### 11.2 全局提示 (Message)
+所有用户可见文案仍须包 `t()`(见 SKILL.md i18n 节)。
-- 顶部居中弹出,自动消失
-- 类型:成功 / 注意 / 失败
+---
-### 11.3 弹窗 (Modal)
+## 10. 品牌标志(界面内用到时)
-- 标题 + 内容区 + 底部操作栏(取消 + 确认/保存)
-- 确认按钮为主色蓝,危险操作为红色
-- 圆角 20px
+- **主标志是标准字**(A 里已含环线和火花点)。**禁止方形标与标准字并排**。产品顶栏这类横向空间用标准字。
+- 方形标只用于方形容器(应用图标、头像、网页图标)。**24px 及以下用小尺寸版** `hugagentos-mark-small.svg` / `favicon.svg`。
+- 彩色稿里火花点永远是火花绿;只有单色稿可以让火花点跟随主体变色。
+- 禁止:拉伸压扁、换品牌色、旋转倾斜、投影/描边/渐变、火花点改色或移位。
+- 对话中的助手头像:品牌蓝底 + 白色环线 + 火花绿点,环线占圆形头像宽度 60%。
+- 标志动画 ≤ 1 秒(描边 0–600ms standard → 填实 500–700ms → 火花点 650ms 起 snappy 弹出);启动页只播一次;减弱动效时整体 240ms 淡入。
+- 素材:`assets/design-v2/logo/`(mark / mark-small / reverse / mono / glyph / favicon)。
---
-## 12. CSS 变量映射(推荐)
+## 11. Ant Design 主题映射(v2 目标值)
-以下为设计规范到 CSS 变量的推荐映射,应用在 `variables.css` 中:
+真源 `src/frontend/src/appTheme.ts`,参考实现 `assets/design-v2/tokens/appTheme.v2.ts`:
-```css
-:root {
- /* 主色 */
- --color-primary: #126DFF;
- --color-primary-hover: #3C87FF;
- --color-primary-active: #0862F3;
- --color-primary-disabled: #A0C5FF;
- --color-primary-bg: #DBE9FF;
- --color-primary-light: #EBF2FF;
-
- /* 边框与填充 */
- --color-fill-heavy: #8C94A2;
- --color-fill-deep: #B3BAC8;
- --color-fill: #D8DBE2;
- --color-border: #E3E6EA;
- --color-fill-hover: #EBEDEE;
- --color-bg-gray: #F5F6F7;
-
- /* 文字 */
- --color-text: #101828;
- --color-text-secondary: #475467;
- --color-text-tertiary: #667085;
- --color-text-placeholder: #98A2B3;
- --color-text-white: #FFFFFF;
-
- /* 成功 */
- --color-success: #02B589;
- --color-success-hover: #11D4A4;
- --color-success-active: #04A87F;
- --color-success-disabled: #80DAC4;
- --color-success-bg: #CFF8EE;
- --color-success-light: #E6FFF9;
-
- /* 警告 */
- --color-warning: #F8AB42;
- --color-warning-hover: #FFC16C;
- --color-warning-active: #F69D24;
- --color-warning-disabled: #FBD5A0;
- --color-warning-bg: #FFECD1;
- --color-warning-light: #FFF5E8;
-
- /* 错误/危险 */
- --color-error: #FC5D5D;
- --color-error-hover: #FF7676;
- --color-error-active: #F95555;
- --color-error-disabled: #FCAAAA;
- --color-error-bg: #FFDFDF;
- --color-error-light: #FFF0F0;
-
- /* Icon 色 */
- --color-icon-blue: #126DFF;
- --color-icon-red: #F25149;
- --color-icon-purple: #7655FA;
- --color-icon-green: #02B589;
- --color-icon-orange: #F8AB42;
-
- /* 圆角 */
- --radius-xs: 6px;
- --radius-sm: 10px;
- --radius-md: 16px;
- --radius-lg: 24px;
-
- /* 间距 */
- --space-xs: 4px;
- --space-sm: 8px;
- --space-md: 16px;
- --space-lg: 20px;
- --space-xl: 40px;
-
- /* 字体(系统栈优先) */
- --font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue",
- "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
- --font-family-number: "DingTalk Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
-
- /* 字号 */
- --font-size-xs: 12px;
- --font-size-sm: 14px;
- --font-size-md: 16px;
- --font-size-lg: 18px;
- --font-size-xl: 22px;
- --font-size-xxl: 44px;
-}
-```
+| Token | 浅色 | 深色 |
+|---|---|---|
+| colorPrimary | #126DFF | **#126DFF**(v1 为 #3E8BFF) |
+| colorPrimaryHover / Active | #0F62E6 / #0A56CC(变深) | 同左 |
+| colorLink | #0F62E6 | #5C9BFF |
+| colorSuccess / colorError | #02B589 / #D92D20 | #22C79D / #FF6B6B |
+| colorSuccessText / WarningText / ErrorText | #067A5E / #B45309 / #D92D20 | #3DDBB0 / #FFC26B / #FF8A80 |
+| colorText / Secondary / Tertiary | #101828 / #475467 / #667085 | #E8ECF4 / #B3BDCD / #8792A4 |
+| colorTextPlaceholder | #667085 | #8792A4 |
+| colorBorder / BorderSecondary | #C5CCD8 / #E3E8EF | #3A4556 / #2B3442 |
+| colorBgContainer / Layout / Elevated | #FFFFFF / #F5F7FB / #FFFFFF | #161C25 / #12171F / #1C2330 |
+| borderRadius / LG / SM | 10 / 16 / 6 | 同左 |
+| fontSize / SM / LG | 14 / 12 / 16 | 同左 |
+
+`AppThemeProvider.tsx` 在系统开启减弱动效时额外给 antd 传 `motion: false`(已实现,保持)。
---
-## 13. Ant Design 主题映射
-
-使用 Ant Design ConfigProvider 时,按以下规范配置 token:
-
-```typescript
-const theme = {
- token: {
- colorPrimary: '#126DFF',
- colorSuccess: '#02B589',
- colorWarning: '#F8AB42',
- colorError: '#FC5D5D',
- colorInfo: '#126DFF',
- colorText: '#101828',
- colorTextSecondary: '#475467',
- colorTextTertiary: '#667085',
- colorTextQuaternary: '#98A2B3',
- colorBorder: '#E3E6EA',
- colorBorderSecondary: '#D8DBE2',
- colorBgContainer: '#FFFFFF',
- colorBgLayout: '#F5F6F7',
- borderRadius: 10,
- borderRadiusLG: 16,
- borderRadiusSM: 6,
- fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif',
- fontSize: 14,
- fontSizeSM: 12,
- fontSizeLG: 16,
- },
-};
-```
+## 12. 迁移清单(按风险从低到高)
+
+前四项**只加不删、无视觉变化**,可以先合入;后面几项会改变现有界面,需要逐页走查 / 产品评审。
+
+| # | 改动 | 文件 | 影响 |
+|---|---|---|---|
+| 1 | 新增 `*-text`、`danger-fill`、`state-*`、`fill-quiet`、`brand-spark`、`material`、`scrim`、`radius-full`、`space-1…16`、文字层级、`shadow-raised/floating` 令牌 | variables.css · appTheme.ts | 只加不删 |
+| 2 | SPRING 增加 smooth / snappy,旧 pop / ink / panel 标记 `@deprecated` | utils/motionTokens.ts | 新代码用新预设 |
+| 3 | 减弱动效:位移类覆盖版从"定格"改为"仅 opacity",门禁识别 `motion-fade` / `motion-stop` | styles/motion.css · scripts/check-loading-motion.mjs | 只影响开启减弱动效的用户 |
+| 4 | 新增小尺寸方形标,网页图标 16 / 32 换用 | public/ · index.html | 网页图标更清晰 |
+| 5 | 语义色当文字处换成 `*-text` | 全局搜 `color: var(--color-(success|warning|error))` | 提示文字变深 |
+| 6 | 主按钮悬停 / 按下改为叠加层;深色主色改回 #126DFF | appTheme.ts · variables.css | 悬停由变浅改为变深 |
+| 7 | 占位符改为三级文字色;输入框补齐可见标签 | variables.css · 各表单 | 占位符变深 |
+| 8 | `--space-lg` 20 → 24、`--space-xl` 40 → 48 | variables.css · 引用处 | 需逐页走查 |
+| 9 | 静止卡片去掉 `shadow-card`;对话输入区去掉边框 | 各卡片样式 · composer | 需走查 |
+| 10 | 用户气泡入场 .98 + 8px / 240ms;流式输出改为词块淡入,去掉 blur | chat/messages.css · chat/grapheme-reveal.css | 需体验评审 |
+| 11 | 工具调用默认折叠为一行;运行中改为文字微光,涟漪只留给待确认 | tool.css · 工具调用组件 | 需产品评审 |
+| 12 | 菜单圆角 16、选项 10(同心) | antd Dropdown 主题 · 自研菜单 | 视觉微调 |
+
+做第 1、2 项时直接以 `assets/design-v2/tokens/` 下的 CSS / TS 为准,深浅两档一起加;第 5 项起每一项都是**会改变现有界面**的改动,没有用户明确要求不要顺手做。
diff --git a/.claude/skills/hugagent-frontend-dev/scripts/scaffold_component.sh b/.claude/skills/hugagent-frontend-dev/scripts/scaffold_component.sh
index dfe21e1c..b357a282 100755
--- a/.claude/skills/hugagent-frontend-dev/scripts/scaffold_component.sh
+++ b/.claude/skills/hugagent-frontend-dev/scripts/scaffold_component.sh
@@ -75,7 +75,10 @@ fi
echo ""
echo "=== 手动步骤 ==="
-echo "1. 在 styles/index.ts 中 import './${COMPONENT_CAMEL}.css'"
+echo "1. 在 styles/index.ts 中 import './${COMPONENT_CAMEL}.css'(或在组件内直接 import)"
echo "2. 按需在父组件中引入 <$COMPONENT />"
+echo "3. 新文案的英文翻译加进 src/i18n/en/ 对应分片(EE 专属进 edition-ee/i18n/)"
+echo "4. 模板里标 (v2) 的令牌先 grep variables.css;缺失则按 references/css-variables.md 补齐深浅两档"
+echo "5. 运行门禁: cd src/frontend && npm run check:i18n && npm run check:dark && npm run check:motion"
echo ""
echo "Done!"
diff --git a/.claude/skills/hugagent-frontend-dev/templates/component.tsx b/.claude/skills/hugagent-frontend-dev/templates/component.tsx
index 153cc176..5c12f6cb 100644
--- a/.claude/skills/hugagent-frontend-dev/templates/component.tsx
+++ b/.claude/skills/hugagent-frontend-dev/templates/component.tsx
@@ -2,92 +2,73 @@
* Component template.
*
* Replace ${ComponentName}, ${componentName}, ${group} with actual names.
- * Create as components/${group}/${ComponentName}.tsx
+ * Create as components/${group}/${ComponentName}.tsx and export it from components/${group}/index.ts.
+ *
+ * Rules (see SKILL.md + references/ui-design-spec.md):
+ * - Every user-visible string goes through t() (npm run build fails otherwise)
+ * - Copy: buttons say the action, errors say cause + fix, success says where the result is
+ * - One primary button per view; secondary actions use the quiet fill, not outlined buttons
+ * - No colors in inline style — put them in the CSS file with tokens
*/
-import React, { useState, useMemo, useCallback, useRef } from 'react';
-import { Button, Space, message } from 'antd';
-// import { useChatStore } from '../../stores';
-// import type { SomeType } from '../../types';
+import { useCallback, useMemo, useState } from 'react';
+import { Button, message } from 'antd';
+import { t } from '../../i18n';
+// import { useSomeStore } from '../../stores';
+// import type { SomeItem } from '../../types';
-// ---------------------------------------------------------------------------
-// Props
-// ---------------------------------------------------------------------------
+interface ${ComponentName}Item {
+ id: string;
+ name: string;
+ active: boolean;
+}
interface ${ComponentName}Props {
- /** 必填属性示例 */
- itemId: string;
- /** 回调示例 */
- onClose: () => void;
- /** 可选属性示例 */
- items?: any[];
+ /** 所属对象 id */
+ ownerId: string;
+ items?: ${ComponentName}Item[];
+ onSaved?: () => void;
}
-// ---------------------------------------------------------------------------
-// Component
-// ---------------------------------------------------------------------------
-
-export function ${ComponentName}({ itemId, onClose, items = [] }: ${ComponentName}Props) {
- // --- Zustand stores (global state) ---
- // const { someState, someAction } = useSomeStore();
-
- // --- Local state ---
- const [loading, setLoading] = useState(false);
- const [editMode, setEditMode] = useState(false);
-
- // --- Refs ---
- const inputRef = useRef(null);
+export function ${ComponentName}({ ownerId, items = [], onSaved }: ${ComponentName}Props) {
+ const [saving, setSaving] = useState(false);
- // --- Derived state (useMemo) ---
- const filteredItems = useMemo(() => {
- return items.filter((item) => item.active);
- }, [items]);
+ const activeItems = useMemo(() => items.filter((item) => item.active), [items]);
- // --- Callbacks (useCallback) ---
- const handleSubmit = useCallback(async () => {
- setLoading(true);
+ const handleSave = useCallback(async () => {
+ setSaving(true);
try {
- // await someApiCall();
- message.success('操作成功');
- onClose();
+ // await saveSomething(ownerId);
+ message.success(t('已保存到我的空间'));
+ onSaved?.();
} catch (e) {
- message.error(`操作失败:${(e as Error).message}`);
+ // Error text: cause + what to do. Prefer the backend message when it is user-facing.
+ message.error(t('保存失败:{reason}', { reason: (e as Error).message }));
} finally {
- setLoading(false);
+ setSaving(false);
}
- }, [itemId, onClose]);
+ }, [ownerId, onSaved]);
- // --- Early return for empty state ---
- if (!filteredItems.length) {
- return (
-
- );
+ if (!activeItems.length) {
+ return {t('还没有内容')}
;
}
- // --- Render ---
return (
-
-
-
{filteredItems.length} 项
-
- setEditMode(!editMode)}>
- {editMode ? '取消' : '编辑'}
-
-
- 提交
-
-
-
+
+
+ {t('{n} 项', { n: activeItems.length })}
+
+ {saving ? t('保存中') : t('保存')}
+
+
-
- {filteredItems.map((item) => (
-
+
+ {activeItems.map((item) => (
+
{item.name}
-
+
))}
-
-
+
+
);
}
diff --git a/.claude/skills/hugagent-frontend-dev/templates/css-module.css b/.claude/skills/hugagent-frontend-dev/templates/css-module.css
index a1c2e9b8..6701c6c8 100644
--- a/.claude/skills/hugagent-frontend-dev/templates/css-module.css
+++ b/.claude/skills/hugagent-frontend-dev/templates/css-module.css
@@ -1,96 +1,126 @@
/**
- * CSS module template.
+ * CSS template (UI design spec v2).
*
- * Replace ${componentName} with actual name.
- * Add to styles/ or alongside the component.
- * Import in styles/index.ts or component file.
+ * Replace ${componentName} with the actual name. Put it in styles/ (or a styles/ / subfolder) and import it
+ * from styles/index.ts, or import it from the component file.
*
- * Rules:
- * - All classes prefixed with .jx-
- * - Colors use CSS variables (--color-*), see references/ui-design-spec.md
- * - BEM-like: .jx-name (block), .jx-name-child (element), .jx-name--mod (modifier)
- * - Spacing: 4px grid (--space-xs/sm/md/lg/xl)
- * - Border-radius: --radius-xs(4) / --radius-sm(8) / --radius-md(12) / --radius-lg(20)
- * - Font size: 12/14(default)/16/18/22/44px
+ * Rules — see references/ui-design-spec.md and references/css-variables.md:
+ * - Classes prefixed .jx- ; BEM-like: .jx-name / .jx-name-child / .jx-name--mod
+ * - Colors only through tokens (npm run check:dark fails on literals, rgba(), and var(--x, #fff) fallbacks)
+ * - 8-point grid: 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64. Do not use --space-lg (20) / --space-xl (40) in new code
+ * - Concentric radii: inner radius = outer radius − padding. --radius-xs 6 / sm 10 / md 16 / lg 24 / full 999px
+ * - Resting cards have no shadow; shadow only on hover (raised) and on floating layers
+ * - Hover/press = overlay that darkens, never a lighter color; disabled = opacity .4
+ * - Motion: only opacity/transform, tokens for duration/easing, never `ease` / `ease-out` / new cubic-bezier
+ * - Font weights 400 / 500 / 600 only; sizes in rem
+ *
+ * v2 tokens marked (v2) may not be in variables.css yet: grep first; if missing, add them to BOTH the light
+ * :root block and the dark block (values from assets/design-v2/tokens/hugagentos-tokens.v2.css) before use.
*/
-/* --- Container --- */
+/* --- Block --- */
.jx-${componentName} {
display: flex;
flex-direction: column;
- gap: var(--space-md); /* 16px */
- padding: var(--space-lg); /* 20px */
+ gap: var(--space-md); /* 16 */
+ padding: 24px; /* --space-6 (v2) */
+ color: var(--color-text);
}
-/* --- Header --- */
+/* --- Header: hierarchy from size/weight/spacing, not boxes --- */
.jx-${componentName}-header {
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 12px; /* --space-3 (v2) */
}
-.jx-${componentName}-header h3 {
+.jx-${componentName}-title {
margin: 0;
- font-size: 16px;
+ font-size: 1.125rem; /* title-3: --font-size-title-3 (v2) */
+ line-height: 1.625rem;
font-weight: 600;
- color: var(--color-text);
}
/* --- List --- */
.jx-${componentName}-list {
display: flex;
flex-direction: column;
- gap: var(--space-sm); /* 8px */
+ gap: var(--space-sm); /* 8 */
+ margin: 0;
+ padding: 0;
+ list-style: none;
}
-/* --- Item --- */
+/* --- L1 item: resting = no border, no shadow; grouped by background + spacing --- */
.jx-${componentName}-item {
display: flex;
align-items: center;
- gap: var(--space-md); /* 16px */
- padding: var(--space-md); /* 16px */
- border-radius: var(--radius-md); /* 12px - card level */
- border: 1px solid var(--color-border);
- background: #fff;
- transition: box-shadow 0.2s, border-color 0.2s;
+ gap: 12px;
+ min-height: 32px; /* default control height; touch targets ≥ 44 */
+ padding: 12px var(--space-md);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-container);
+ font-size: 0.875rem; /* body */
+ line-height: 1.375rem;
cursor: pointer;
+ transition:
+ transform var(--motion-duration-fast) var(--motion-ease-standard),
+ box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
}
.jx-${componentName}-item:hover {
- border-color: var(--color-primary-hover);
- box-shadow: 0 4px 12px rgba(18, 109, 255, 0.08);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-raised); /* (v2) — until merged: var(--shadow-card-hover) */
}
-/* --- Item active modifier --- */
+.jx-${componentName}-item:active {
+ transform: scale(.97);
+}
+
+/* Selected: primary light background + primary text; no extra border */
.jx-${componentName}-item--active {
- border-color: var(--color-primary);
background: var(--color-primary-light);
+ color: var(--color-primary-text); /* (v2) — until merged: var(--color-primary) */
}
-/* --- Item disabled modifier --- */
.jx-${componentName}-item--disabled {
- opacity: 0.5;
+ opacity: .4;
cursor: not-allowed;
}
-/* --- Empty state --- */
+/* Secondary metadata: footnote/caption + tertiary text; numbers tabular */
+.jx-${componentName}-meta {
+ font-size: 0.8125rem; /* footnote */
+ color: var(--color-text-tertiary);
+ font-variant-numeric: tabular-nums;
+}
+
+/* Semantic hint: light background + *-text foreground, never the fill color as text */
+.jx-${componentName}-hint--error {
+ padding: var(--space-sm) 12px;
+ border-radius: var(--radius-sm);
+ background: var(--color-error-bg); /* v2 value = v1 --color-error-light */
+ color: var(--color-error-text); /* (v2) */
+}
+
+/* --- Empty state: one plain sentence, no illustration box --- */
.jx-${componentName}-empty {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 60px 0;
+ padding: 48px 0; /* --space-12 (v2) */
+ text-align: center;
+ font-size: 0.875rem;
color: var(--color-text-tertiary);
- font-size: 14px;
}
-/* --- Responsive --- */
+/* --- Entrance: reuse motion primitives (.jx-anim-fadeInUp etc.) instead of new keyframes --- */
+
+/* --- Narrow container / mobile --- */
@media (max-width: 768px) {
.jx-${componentName} {
- padding: var(--space-md); /* 16px */
+ padding: var(--space-md); /* 16 + safe area where relevant */
}
.jx-${componentName}-item {
- padding: 10px var(--space-md);
+ min-height: 44px;
}
}
diff --git a/.claude/skills/hugagent-frontend-dev/templates/hook.ts b/.claude/skills/hugagent-frontend-dev/templates/hook.ts
index 9b09e939..284e8301 100644
--- a/.claude/skills/hugagent-frontend-dev/templates/hook.ts
+++ b/.claude/skills/hugagent-frontend-dev/templates/hook.ts
@@ -2,87 +2,35 @@
* Custom hook template.
*
* Replace ${Feature}, ${feature} with actual names.
- * Create as hooks/use${Feature}.ts
+ * Create as hooks/use${Feature}.ts. hooks/index.ts only re-exports the most common hooks;
+ * import others by deep path.
+ *
+ * Rules:
+ * - Network calls go through API functions (api.ts / src/api/.ts), which use apiRequest()
+ * - Every request started by the hook is aborted on unmount or when superseded
+ * - Effects are split by concern; no conditional hooks
*/
-import { useState, useEffect, useCallback, useRef } from 'react';
-// import { use${Feature}Store } from '../stores';
-// import { authFetch } from '../api';
-
-// ---------------------------------------------------------------------------
-// Hook
-// ---------------------------------------------------------------------------
-
-export function use${Feature}(apiUrl: string) {
- // --- Store state ---
- // const { items, setItems, loading, setLoading } = use${Feature}Store();
+import { useCallback, useEffect, useRef } from 'react';
+import { use${Feature}Store } from '../stores';
- // --- Local state ---
- const [initialized, setInitialized] = useState(false);
-
- // --- Refs ---
+export function use${Feature}() {
+ const fetchItems = use${Feature}Store((s) => s.fetchItems);
+ const loading = use${Feature}Store((s) => s.loading);
const abortRef = useRef(null);
- const timerRef = useRef | null>(null);
-
- // --- Initialization ---
- useEffect(() => {
- if (initialized) return;
- const init = async () => {
- try {
- // const r = await authFetch(`${apiUrl}/v1/${feature}s`);
- // const { data } = await r.json();
- // setItems(data.items);
- setInitialized(true);
- } catch (e) {
- console.error('Failed to init ${feature}:', e);
- }
- };
-
- init();
- }, [apiUrl, initialized]);
-
- // --- Cleanup ---
- useEffect(() => {
- return () => {
- abortRef.current?.abort();
- if (timerRef.current) clearTimeout(timerRef.current);
- };
- }, []);
-
- // --- Actions ---
const refresh = useCallback(async () => {
- // setLoading(true);
- try {
- // const r = await authFetch(`${apiUrl}/v1/${feature}s`);
- // const { data } = await r.json();
- // setItems(data.items);
- } catch (e) {
- console.error('Refresh failed:', e);
- } finally {
- // setLoading(false);
- }
- }, [apiUrl]);
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+ await fetchItems(controller.signal);
+ }, [fetchItems]);
- const doSomething = useCallback(async (id: string) => {
- abortRef.current = new AbortController();
- try {
- // await authFetch(`${apiUrl}/v1/${feature}s/${id}/action`, {
- // method: 'POST',
- // signal: abortRef.current.signal,
- // });
- } catch (e) {
- if ((e as Error).name !== 'AbortError') {
- console.error('Action failed:', e);
- }
- }
- }, [apiUrl]);
+ // Initial load + cleanup.
+ useEffect(() => {
+ void refresh();
+ return () => abortRef.current?.abort();
+ }, [refresh]);
- // --- Return ---
- return {
- initialized,
- refresh,
- doSomething,
- abortRef,
- };
+ return { loading, refresh };
}
diff --git a/.claude/skills/hugagent-frontend-dev/templates/store.ts b/.claude/skills/hugagent-frontend-dev/templates/store.ts
index e4fd65a6..6b6defa7 100644
--- a/.claude/skills/hugagent-frontend-dev/templates/store.ts
+++ b/.claude/skills/hugagent-frontend-dev/templates/store.ts
@@ -2,130 +2,101 @@
* Zustand store template.
*
* Replace ${Feature}, ${feature} with actual names.
- * Create as stores/${feature}Store.ts
+ * Create as stores/${feature}Store.ts and re-export it from stores/index.ts.
+ *
+ * Rules:
+ * - Server data comes from API functions (api.ts or src/api/.ts), never raw fetch here
+ * - Business data cached in localStorage MUST be keyed per account with userScopedKey(base, userId)
+ * (a null key means "not signed in yet": do not read or write)
+ * - Clear account-scoped state on logout / account switch (reset())
*/
import { create } from 'zustand';
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
+import { loadJsonPref, removeLocal, saveJsonPref, userScopedKey } from '../storage';
+// import { list${Feature}s, create${Feature}, delete${Feature} } from '../api/${feature}';
interface ${Feature}Item {
id: string;
name: string;
- // ... add fields
}
-// ---------------------------------------------------------------------------
-// State Interface (state + actions)
-// ---------------------------------------------------------------------------
-
interface ${Feature}State {
- // --- State ---
+ userId: string | null;
items: ${Feature}Item[];
loading: boolean;
selectedId: string | null;
error: string | null;
- // --- Actions ---
- setItems: (items: ${Feature}Item[]) => void;
- setLoading: (loading: boolean) => void;
+ bindUser: (userId: string | null) => void;
selectItem: (id: string | null) => void;
- fetchItems: () => Promise;
- createItem: (data: Partial<${Feature}Item>) => Promise;
+ fetchItems: (signal?: AbortSignal) => Promise;
+ createItem: (name: string) => Promise<${Feature}Item>;
deleteItem: (id: string) => Promise;
reset: () => void;
}
-// ---------------------------------------------------------------------------
-// localStorage persistence (optional)
-// ---------------------------------------------------------------------------
-
-const STORAGE_KEY = 'hugagent_ui_${feature}_v1';
+const STORAGE_BASE = 'hugagent_ui_${feature}_v1';
-function loadFromStorage(): ${Feature}Item[] {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- return raw ? JSON.parse(raw) : [];
- } catch {
- return [];
- }
+function persist(userId: string | null, items: ${Feature}Item[]) {
+ const key = userScopedKey(STORAGE_BASE, userId);
+ if (key) saveJsonPref(key, { items });
}
-function saveToStorage(items: ${Feature}Item[]) {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
-}
-
-// ---------------------------------------------------------------------------
-// Store
-// ---------------------------------------------------------------------------
-
export const use${Feature}Store = create<${Feature}State>((set, get) => ({
- // --- Initial state ---
- items: loadFromStorage(),
+ userId: null,
+ items: [],
loading: false,
selectedId: null,
error: null,
- // --- Simple setters ---
- setItems: (items) => {
- set({ items });
- saveToStorage(items);
+ bindUser: (userId) => {
+ const key = userScopedKey(STORAGE_BASE, userId);
+ const cached = key ? loadJsonPref<{ items: ${Feature}Item[] }>(key, { items: [] }) : { items: [] };
+ set({ userId, items: cached.items, selectedId: null, error: null });
},
- setLoading: (loading) => set({ loading }),
+
selectItem: (id) => set({ selectedId: id }),
- // --- Async: fetch ---
- fetchItems: async () => {
+ fetchItems: async (signal) => {
set({ loading: true, error: null });
try {
- // const r = await authFetch(`${apiUrl}/v1/${feature}s`);
- // const { data } = await r.json();
- // set({ items: data.items });
- // saveToStorage(data.items);
+ const items: ${Feature}Item[] = []; // = await list${Feature}s(signal);
+ void signal;
+ set({ items });
+ persist(get().userId, items);
} catch (e) {
+ if ((e as Error).name === 'AbortError') return;
set({ error: (e as Error).message });
- console.error('Failed to fetch ${feature}s:', e);
} finally {
set({ loading: false });
}
},
- // --- Async: create ---
- createItem: async (data) => {
- try {
- // const r = await authFetch(`${apiUrl}/v1/${feature}s`, {
- // method: 'POST',
- // headers: { 'Content-Type': 'application/json' },
- // body: JSON.stringify(data),
- // });
- // const { data: created } = await r.json();
- // const items = [...get().items, created];
- // set({ items });
- // saveToStorage(items);
- } catch (e) {
- console.error('Failed to create ${feature}:', e);
- throw e;
- }
+ createItem: async (name) => {
+ const created: ${Feature}Item = { id: '', name }; // = await create${Feature}(name);
+ const items = [created, ...get().items];
+ set({ items });
+ persist(get().userId, items);
+ return created;
},
- // --- Async: delete ---
+ // Optimistic delete with rollback: the list stays truthful if the request fails.
deleteItem: async (id) => {
+ const previous = get().items;
+ const items = previous.filter((item) => item.id !== id);
+ set({ items, selectedId: get().selectedId === id ? null : get().selectedId });
try {
- // await authFetch(`${apiUrl}/v1/${feature}s/${id}`, { method: 'DELETE' });
- const items = get().items.filter((i) => i.id !== id);
- set({ items, selectedId: get().selectedId === id ? null : get().selectedId });
- saveToStorage(items);
+ // await delete${Feature}(id);
+ persist(get().userId, items);
} catch (e) {
- console.error('Failed to delete ${feature}:', e);
+ set({ items: previous });
throw e;
}
},
- // --- Reset ---
reset: () => {
- set({ items: [], loading: false, selectedId: null, error: null });
- localStorage.removeItem(STORAGE_KEY);
+ const key = userScopedKey(STORAGE_BASE, get().userId);
+ if (key) removeLocal(key);
+ set({ userId: null, items: [], loading: false, selectedId: null, error: null });
},
}));
diff --git a/.env.example b/.env.example
index 68b1ad2c..054c0a41 100644
--- a/.env.example
+++ b/.env.example
@@ -41,7 +41,7 @@ VITE_DEFAULT_LANGUAGE=en
# ── 存储(社区版:本地文件) ─────────────────────────────────────────────────
STORAGE_TYPE=s3
-COMPOSE_FILE=docker-compose.yml:docker/compose.object-storage.yml
+COMPOSE_FILE=docker-compose.yml:docker/object-storage/compose.object-storage.yml
S3_ENDPOINT=http://object-storage:9000
S3_BUCKET=hugagent-files
S3_REGION=us-east-1
@@ -86,10 +86,10 @@ REDIS_HOST_PORT=6380
# Personal Agent (CE/EE): optional module, off unless enabled here.
# CE allows authenticated users; EE also applies account/role/team permission.
# No Personal Agent license feature is required. Start the dedicated
-# controller with docker/compose.personal-agent.yml and --profile personal-agent.
+# controller with docker/personal-agent/compose.personal-agent.yml and --profile personal-agent.
PERSONAL_AGENT_ENABLED=false
# Build a versioned graphical image before use.
-# docker build -f docker/Dockerfile.personal-agent -t hugagent-personal-agent:v5 .
+# docker build -f docker/personal-agent/Dockerfile.personal-agent -t hugagent-personal-agent:v5 .
# Do not overwrite a tag used by a running permanent computer.
PERSONAL_AGENT_IMAGE=hugagent-personal-agent:v5
# Backend API reachable from the permanent computer. No credential value here.
diff --git a/.gitignore b/.gitignore
index f99ef493..ac636bd6 100755
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,7 @@
# Runtime-generated catalog (synced from DB + filesystem at startup)
src/backend/configs/catalog.json
-# OpenSandbox config — generated from docker/opensandbox-config.toml.tpl by the
+# OpenSandbox config — generated from docker/opensandbox-server/opensandbox-config.toml.tpl by the
# opensandbox-config-init service at compose-up (sed 把 @@HOST_REPO_PATH@@
# 替换成各机 .env 里的实际仓库路径)。.tpl 入库,实文件不入库。
opensandbox-config.toml
@@ -60,6 +60,8 @@ spike/
.DS_Store
# Project specific
+# 根目录 examples/(示例工程)仅本地使用,不入库
+/examples/
output/
images/
logs/
@@ -164,10 +166,12 @@ deploy_kit/
node_modules/
# 建站模板必须完整入库(覆盖上面的 lib/ 与 AGENTS.md 规则),但依赖树除外
-!docker/site-template/**
-docker/site-template/react-vite/node_modules/
+!docker/sandbox/site-template/**
+docker/sandbox/site-template/react-vite/node_modules/
.jobtest/
# Local scratch scripts and verification artifacts
/.scratch/
+# macOS native release archives (desktop/scripts/workbench-macos-native.mjs)
+/release-archive/
.ageval
\ No newline at end of file
diff --git a/README.md b/README.md
index c359056f..636b9550 100644
--- a/README.md
+++ b/README.md
@@ -3,16 +3,12 @@
- HugAgentOS: The Enterprise AgentOS for Ontology-Grounded Trustworthy Reasoning
+ HugAgentOS: the open-source Agent Harness that runs 24/7
- The open-source, self-hosted foundation for enterprise AI agents
-
-
-
- Give models the context and tools to retrieve knowledge, work with files,
- run code, and carry real tasks through to completion.
+ Hand it your question, then close your laptop —
+ the agent keeps working in the cloud and tells you when it's done.
@@ -37,8 +33,11 @@
-
-
+
+
+
+
+
@@ -48,11 +47,18 @@
-HugAgentOS is an enterprise-grade AgentOS that treats domain ontology as a
-control plane for agent reasoning, decisions, and actions. Its open-source
-Community Edition combines agentic chat, private knowledge-base RAG,
-sub-agents, MCP tools, Agent Skills, sandboxed execution, long-term memory,
-automation, and a data canvas in one self-hosted workspace.
+Models keep getting stronger, but getting an agent to **actually finish the job**
+depends on the layer around the model: run lifecycle, context assembly, tools and
+sandboxes, human approvals, memory, and failure recovery. That layer is the
+**agent harness**.
+
+HugAgentOS is an open-source, self-hosted agent harness built around the
+**durable agent**: your request runs **in the cloud**, not in your browser or on your
+computer. Close the tab, shut the laptop or go offline, and the task keeps moving;
+you can also hand off tasks and get results in the IM app you use. On top of
+that, HugAgentOS provides an always-on **Personal Agent** and runs in two forms —
+**HugAgent Cloud** (on your server) and **HugAgent Local** (on your desktop) — which
+can also be combined.
> [!NOTE]
> This Community repository is generated from the upstream main repository for
@@ -60,6 +66,18 @@ automation, and a data canvas in one self-hosted workspace.
> Issue or Discussion. Pull requests for documentation and examples are
> welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
+## At a glance
+
+| Situation | A typical chat assistant | HugAgentOS |
+|---|---|---|
+| You close your laptop | The task stops with it | The task keeps running in the cloud; come back to the result |
+| The server restarts or fails | In-flight work is lost | Another instance takes over from the last saved progress |
+| A step fails and retries | It may send the email twice | Operations with an uncertain outcome are never redone automatically |
+| A very long task | Bound by timeouts and turn limits | No business time limit; it runs until the job is done |
+| Reaching you | Waits for you to open the page | IM notifications, schedules and heartbeat reminders |
+| Where it runs | Cloud or local, pick one | HugAgent Cloud, HugAgent Local, or a local + cloud hybrid |
+| Over time | Starts from zero every time | Memory, procedures and skills accumulate, each approved and reversible |
+
## Demo
A 60-second tour: the same task run twice, with audited self-evolution in between —
@@ -68,140 +86,190 @@ approve it.
https://github.com/user-attachments/assets/635c5b35-10c0-47b9-a6f1-c73df9510e90
-## Quick start
+## ⏱️ Durable agent: close the laptop, the work goes on
-Use the one-command install to try it out, or Docker Compose for a long-running
-service with isolation. Either way you need an OpenAI-compatible or local model.
-**The initial account and password are both `admin` and must be changed on first
-sign-in; the Community Edition has no self-registration.**
+Once you hand a task to HugAgentOS, it runs in cloud services and sandboxes — **your
+device is just a window onto it**. Leave whenever you like, or come back on another
+device; you always see the same progress and results.
-### Option 1: one-command install (Linux / macOS / WSL2)
-
-Requires Python 3.11+, Node.js 20+, Git and `curl`. No Docker, PostgreSQL or
-Redis needed.
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh | bash
+```text
+09:00 In Feishu, tell the agent: "Research pricing for competitors A, B and C, put it in a table and write a comparison report"
+09:01 The agent replies "Started"; the task runs in a cloud sandbox → you close the laptop and go to a meeting
+10:40 It needs to send an email: an approval reminder arrives in Feishu, and you approve it on the web from your phone
+11:30 The report arrives in the same Feishu chat
+11:35 Reply "Add D as well" → the same task picks it up, no restart
```
-The installer fetches the source into `~/.hugagent/source`, creates an isolated
-Python environment, builds the web application and walks you through first-run
-setup; it then opens [http://127.0.0.1:3001](http://127.0.0.1:3001). Start it
-again later with `~/.hugagent/venv/bin/hugagent`.
+**Why it keeps going**
+
+- **Runs in the cloud, not on your device**: background tasks, schedules and heartbeats
+ run on the server, and working files live in a dedicated persistent sandbox. Closing
+ the page doesn't stop anything, and any device can pick up the live progress.
+- **Survives server failures**: progress is saved to the database step by step and
+ executed by leased workers. If a process or machine dies, another instance takes over
+ from the last saved progress instead of silently dropping the task.
+- **Never repeats risky actions**: emails, writes to external systems and other
+ operations record their intent and outcome. On recovery, anything that may or may not
+ have happened is not redone automatically — you confirm "executed" or "not executed".
+- **Runs until it's done**: long tasks and the
+ [autonomous loop](./document/en/modules/autonomous-loop.md) have no total time limit or
+ business turn cap; stall detection, failure circuit breakers and a hard backstop prevent
+ runaways. Waiting for your approval or answer doesn't consume the execution budget.
+- **Change your mind mid-run**: add requirements at any time and the agent takes them at
+ its next step, with no cancel-and-restart.
+- **Finds you when it needs you**: actions that need approval are approved on the web, with
+ a reminder in IM; until you answer, the task waits instead of acting on its own.
+
+> [!IMPORTANT]
+> Continuous cloud operation relies on PostgreSQL, Redis and OpenSandbox — deploy
+> **HugAgent Cloud** with Docker Compose. The one-command install uses SQLite and
+> in-process state and is meant for trials and development.
+
+## 🤖 Personal Agent: an agent that is always on
+
+Personal Agent is built on the durable harness above: **one conversation, one
+persistent identity, one permanent cloud computer**. You just say what you need; it
+decides whether to answer right away or start a background task, and reports back
+when the work is done.
+
+| Capability | Description |
+|---|---|
+| 💬 **One entry, parallel background tasks** | Small talk and status questions are answered on the spot; research, reports, code and computer work become background tasks (3 in parallel by default, adjustable from 1 to 8). Follow-ups go in the same chat and it decides which task they belong to |
+| 🖥️ **Permanent sandbox and cloud computer** | Each user gets a dedicated OpenSandbox with no expiry, with separate persistent volumes for the workspace and browser profile; a lost container is rebuilt on the same volumes |
+| ⏰ **Schedules and heartbeats** | "Remind me to check email at 8 every morning" creates a schedule; heartbeats check `HEARTBEAT.md` at an interval and only speak up when something needs you |
+| 📱 **Reachable on your channels** | Bind Feishu, DingTalk, WeChat or WeCom bots; each contact or group gets its own session and results go back where the request came from |
+| ✅ **Execution you control** | Email, computer input and session writes need approval; every email is approved individually, other actions can be allowed for the rest of a task; a rejection makes it find another way |
+| 🧠 **Persona and memory as files** | `SOUL.md`, `USER.md`, `MEMORY.md` and daily logs live in the workspace and can be viewed and edited in My Space; Dreaming periodically consolidates logs into long-term memory |
+| 💻 **Connect your own computer** | In the desktop client's local + cloud mode, connect a Mac, Windows or Linux machine in one click and grant screen control or local session management; the device only polls outbound and opens no inbound ports |
+
+Personal Agent is off by default in the Community Edition. In a HugAgent Cloud
+deployment, set `PERSONAL_AGENT_ENABLED=true` and enable the `personal-agent` Compose
+profile. See the [Personal Agent guide](./document/en/modules/personal-agent.md).
+
+## ☁️ HugAgent Cloud and 💻 HugAgent Local
+
+The same harness, placed wherever your data and compute should live.
+
+| | **HugAgent Cloud** | **HugAgent Local** | **Local + cloud (hybrid)** |
+|---|---|---|---|
+| Runs on | Your server or private cloud | Your computer | Cloud as control plane, local machine as execution plane |
+| Access | Browser, from any device | Desktop client (Windows / macOS / Linux) | Desktop client |
+| Data and state | PostgreSQL, Redis, S3-compatible storage | Local SQLite, Milvus Lite, local files | Cloud projects stay in the cloud; local projects stay on the machine |
+| Execution | Isolated OpenSandbox sandboxes with snapshots | Your real project directories, gated by folder grants and permission profiles | Both |
+| Best for | 24/7 Personal Agent, long runs, IM channels | Offline work, sensitive data, local code and files | An always-on cloud agent that can also operate your machine and projects |
+| Install with | Docker Compose | Desktop offline installer, or the one-command script | Choose "Local + cloud" in the desktop client |
+
+- **HugAgent Cloud** is the complete durable harness, for work that must stay online,
+ be reachable from any device and connect to IM channels. Each Docker sandbox uses a
+ separate bridge with the trusted backend and controller; sandbox host ports are not
+ published and peer sandboxes cannot reach each other's execd.
+- **HugAgent Local** ships its own private Python runtime and services, so **no Python,
+ Docker or database install is needed**. It can run fully offline with local models;
+ once a local project is bound, Bash and file tools work directly in the project's real
+ directory. A failed upgrade rolls back to the previous version and data backup.
+- **Hybrid mode** uses one cloud account: cloud conversations are stored in the cloud,
+ while only local projects' conversations, files and execution stay on the machine.
+ This is also how Personal Agent "connects this computer" so an always-on cloud agent
+ can operate your desktop.
+
+See [Desktop local mode](./document/en/deployment/desktop-local-mode.md) and
+[Local project workspace](./document/en/deployment/local-project-workspace.md).
+
+## 🧬 Self-evolution: better with real work, audited at every step
+
+HugAgentOS does not make the agent start from zero every time — and does not let it
+quietly learn the wrong things.
+
+- **Learning procedures from trajectories**: the post-response memory pipeline keeps
+ outcome-verified trajectories such as "failure → user correction → success" as
+ reusable **procedural** memory instead of facts that go stale; stable procedures can
+ be compiled further into skills.
+- **Three memory layers**: L1 personal profile, L2 vector procedural memory (Milvus,
+ optional) and L3 relationship graph (Neo4j, optional). Writes go through a durable
+ outbox so crash replays never add duplicates, and a sanitizer filters credentials.
+- **Approved one by one, reversible**: evolution proposals for memory and skills appear
+ as cards and only take effect after you confirm them; you can switch it off at any time.
+- **Dreaming for Personal Agent**: a daily job consolidates logs into `MEMORY.md` /
+ `USER.md`, with `DREAMS.md` recording sources, deduplication and conflicts; only what
+ you explicitly asked it to remember is promoted automatically.
+- **Traceable ontology evolution**: approvals, rejections, evidence and outcomes are
+ recorded and organized into versioned ontology proposals that apply after human review.
+
+## 🧰 More harness capabilities
+
+| Capability | Description |
+|---|---|
+| 🔁 **Autonomous loop** | Goals survive across turns until done; maker ≠ checker — a worker does the work, a read-only reviewer verifies the real output, and command-checkable requirements are verified by the driver itself |
+| 🗺️ **Plan Mode** | Plan first, then execute step by step with per-step MCP, skill and sub-agent bindings; resumable like any run |
+| 🤝 **Sub-agents** | Built-in explorer, worker and reviewer, plus your own sub-agents, routed automatically or by `@` mention |
+| 🔧 **MCP, Skills and plugins** | Built-in web search, page fetching, knowledge retrieval, charts and automation tools; an MCP marketplace, private MCP servers, Agent Skills and plugins |
+| 🧪 **Sandbox and artifacts** | Run code in OpenSandbox or a local subprocess sandbox, producing charts, reports, Office files, web pages and data canvases |
+| 📚 **Private knowledge-base RAG** | Document chunking, hybrid vector and keyword retrieval, optional reranking |
+| 🛡️ **Optional ontology control plane** | With a Domain Pack, deterministic rule checks run before tool execution and high-risk answers are reviewed by an independent reviewer or committee; switched off, the original flow is unchanged |
+| 🔌 **Replaceable models** | Connect cloud or local OpenAI-compatible models through one model service; reviewer, planner, memory and other roles can each use their own model |
-> [!WARNING]
-> The installation listens on `127.0.0.1` only. If you genuinely need remote
-> access, use `hugagent serve --host 0.0.0.0 --port 3001 --no-browser`, and set a
-> strong password, a firewall and HTTPS first. Do not expose it on an untrusted
-> network.
+## Quick start
-This path uses SQLite, in-process state and a local subprocess sandbox, which
-suits personal use and development. Options and troubleshooting are in the
-[one-command install guide](./document/en/deployment/quick-install.md).
+Every option needs an OpenAI-compatible or local model. **The initial account and
+password are both `admin` and must be changed on first sign-in; the Community Edition
+has no self-registration.**
-### Option 2: Docker Compose
+### HugAgent Cloud: Docker Compose (recommended for 24/7)
-Use this when you need PostgreSQL, Redis, an isolated sandbox and persistent
-volumes. Requires Git, Docker and Compose v2.
+Install Git, Docker, and Compose v2 first.
```bash
git clone https://github.com/ZJU-REAL/HugAgentOS.git
cd HugAgentOS
-python3 scripts/configure-ce-docker.py
-docker build -f docker/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
+cp .env.example .env
+mkdir -p data/storage
docker compose build backend
docker compose up -d --build
```
-Docker CE uses OpenSandbox by default. Initialization creates private controller credentials and absolute host paths. It refuses to overwrite an existing .env. Local installation keeps subprocess execution.
-
-Open [http://localhost:3002](http://localhost:3002), then connect a model under
-**Settings → System → Model services**. Profiles, persistence and production
-configuration are covered in the
-[Docker Compose deployment guide](./document/en/deployment/docker-compose.md).
-
-## Why HugAgentOS
-
-The point is not another chat wrapper. It is putting the context, the execution
-capability, and the artifact management an agent needs to finish real work on a
-single path — and raising the domain ontology from a knowledge base to a
-**machine-executable control plane**, so that governed concepts, relations, rules
-and action contracts give the skill, memory and orchestration engines one shared
-business vocabulary.
-
-
-
-
- 🔌 Model-agnostic
- Connect cloud or local models through one model-service configuration,
- without locking the application to a single vendor.
-
-
- 🛠️ Actually does the work
- ReAct orchestrates MCP servers, skills and sandboxes, so the model can
- search, analyse, produce files and call external capabilities.
-
-
-
-
- 🧠 Knowledge and memory
- Private knowledge bases and layered memory supply long-term context across
- files and conversations.
-
-
- 🏠 Your data stays yours
- Application, database and file storage all run on your own infrastructure.
-
-
-
-
- 🛡️ Gated, trustworthy execution
- Candidate plans pass deterministic rule checks, risk-tiered evidence review
- and a gate. A violating action returns with the rule, the evidence and a
- correction — it is never waved through silently.
-
-
- 🔎 Traceable evolution
- Approvals, rejections, evidence and outcomes are all recorded, distilled
- into versioned ontology proposals that take effect only after human review,
- and can be rolled back.
-
-
-
+Open [http://localhost:3002](http://localhost:3002), sign in, and connect a model
+under **Settings → System administration → Model services**. See the
+[Docker Compose guide](./document/en/deployment/docker-compose.md) for persistence
+and production settings, and [Deploy Personal Agent](./document/en/modules/personal-agent.md#deploy)
+to enable it.
-> [!NOTE]
-> The ontology trust control plane is an enterprise target architecture being
-> integrated into the existing harness in stages. It strengthens structured
-> compliance and evidence-based review; it does not promise "zero hallucination"
-> for free text.
+### HugAgent Local: desktop client
-## Core capabilities
+Download the installer for your platform from
+[Releases](https://github.com/ZJU-REAL/HugAgentOS/releases) and choose "Local only",
+"Cloud only" or "Local + cloud" on first launch. The full offline package bundles its
+runtime, so nothing else needs to be installed.
-The Community Edition covers the full loop for a personal agent — conversation,
-execution, consolidation and reuse. Optional components are enabled as needed.
+### Developer trial: one-command install (Linux / macOS / WSL2)
-| Capability | What it gives you |
-|---|---|
-| 💬 **Conversation and plan mode** | SSE streaming, ReAct tool orchestration, deep thinking, plan mode, citations, resumable runs |
-| 📚 **Private knowledge base RAG** | Document chunking, hybrid vector and keyword retrieval, optional reranking, per-user isolation |
-| 🤝 **Personal sub-agents** | Sub-agents with distinct roles, reached by automatic routing or an `@` mention |
-| 🔧 **MCP tool ecosystem** | Built-in web search, page fetch, knowledge retrieval, batch runs, automation, skill management |
-| 🧩 **Agent Skills** | Extend the agent with standardised skill definitions and scripts — built-in, marketplace and personal |
-| ⚙️ **Automation and batch runs** | Create scheduled tasks in natural language; run one process across an Excel sheet, a Word file or a file list |
-| 💬 **Group chat channels** | Feishu / DingTalk / WeCom bots, with optional group listening and history retrieval so the agent can see the conversation around it |
-| 🧪 **Sandbox and artifacts** | Run code locally or in an OpenSandbox container, producing charts, reports, Office files, web pages and data canvases |
-| 🧠 **Three-layer personal memory** | L1 profile in the relational store; optional Milvus vector memory and Neo4j graph memory |
-| 🧬 **Personal evolution** | Settle memory and skills out of your real work, each approved individually before it applies to you, and switchable off |
-| 🗂️ **Personal workspace** | Projects, folders, favourites, conversation sharing and an artifact center |
-| 📊 **Data canvas** | Inspect and edit structured data inside the conversation, keeping analysis and result in one workspace |
+Requires Python 3.11+, Node.js 20+, Git and `curl`. No Docker, PostgreSQL or
+Redis needed.
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh | bash
+```
+
+The installer clones the source into `~/.hugagent/source`, creates an isolated
+Python environment, builds the web app and runs the first-time setup wizard, then
+opens [http://127.0.0.1:3001](http://127.0.0.1:3001). Start it again later with
+`~/.hugagent/venv/bin/hugagent`.
+
+> [!WARNING]
+> It listens on `127.0.0.1` only by default. If you really need remote access, use
+> `hugagent serve --host 0.0.0.0 --port 3001 --no-browser`, and set up a strong
+> password, a firewall and HTTPS first. Don't expose it directly on an untrusted network.
+
+The one-command install uses SQLite, in-process state and a local subprocess
+sandbox, which suits trials and development. See the
+[quick install guide](./document/en/deployment/quick-install.md) for options and
+troubleshooting.
## Architecture
-HugAgentOS separates user channels, agent workflows, reusable capability
-engines, ontology contracts, data governance, and infrastructure into clear
-layers. Action contracts connect the ontology layer to planning, validation,
-and gated execution, while security and platform governance span the complete
-stack.
+HugAgentOS separates user channels, agent workflows, reusable capability engines,
+ontology contracts, data governance, and infrastructure into clear layers. Durable
+runs, leases and recovery run through the execution path, while security and
+platform governance span the complete stack.

@@ -212,35 +280,35 @@ stack.
### Technology stack
-The project combines mature, replaceable open-source components behind clear
-service boundaries.
-
| Layer | Main technologies |
|---|---|
| Agent runtime | AgentScope 2.0, ReAct, Model Context Protocol |
| Backend | Python, FastAPI, SQLAlchemy, Alembic |
| Frontend | React 19, TypeScript, Vite, Zustand, Ant Design |
-| Data and state | SQLite or PostgreSQL 15, in-process state or Redis 7, local file storage |
+| Desktop | Tauri 2, private CPython 3.11 runtime |
+| Data and state | SQLite or PostgreSQL 15, in-process state or Redis 7, local or S3-compatible storage |
+| Sandbox | OpenSandbox, local subprocess |
| Optional memory | Milvus 2.4, Neo4j 5 Community, mem0 |
-| Deployment | One-command local installer, Docker Compose, Nginx |
+| Deployment | Desktop installer, one-command installer, Docker Compose, Nginx |
See the [architecture overview](./document/en/architecture/overview.md) for the
full request lifecycle, container topology, and design decisions.
## Community and Enterprise editions
-Community Edition gives an individual a complete agent workspace. Enterprise
-Edition adds the governance, collaboration, and delivery capabilities needed
-to operate the same experience across an organization. Enterprise-only source
-is physically absent from the Community tree.
+Community Edition lets an individual run the complete harness and Personal Agent.
+Enterprise Edition adds the governance, collaboration, and delivery capabilities
+needed to operate the same experience across an organization. Enterprise-only
+source is physically absent from the Community tree.
| Community Edition | Enterprise Edition adds |
|---|---|
-| Agentic chat, Plan Mode, and personal sub-agents | Teams, organization agents, and permission matrices |
-| 8 general MCP tools, personal skills, and a skill marketplace | Industry data tools, organization governance, and skill review |
-| Private knowledge bases and three-tier personal memory | Public knowledge administration and memory auditing |
+| Durable runs, autonomous loop, Plan Mode, and personal sub-agents | Teams, organization agents, and permission matrices |
+| Personal Agent: background tasks, dedicated computer, channels, schedules, and file memory | User, role, and team authorization, plus cross-device session management |
+| General MCP tools, personal skills, and a skill marketplace | Industry data tools, organization governance, and skill review |
+| Private knowledge bases, three-tier personal memory, and personal evolution | Public knowledge administration and memory auditing |
| Automation, and a personal data canvas | Organization billing, usage reports, and canvas collaboration |
-| OpenSandbox sessions, snapshots, and S3 file storage | Team workspace mounts, Cube, additional cloud providers, and offline delivery |
+| OpenSandbox sessions, snapshots, and S3 file storage; desktop local mode | Team workspace mounts, Cube, additional cloud providers, and offline delivery |
| Local accounts and branding with Powered-by attribution | SSO, compliance auditing, and full white-labeling |
See the [edition overview](./document/en/editions/overview.md) for the complete
@@ -248,26 +316,27 @@ feature boundary and upgrade path.
## Documentation
-The repository includes complete English and Chinese documentation for
-operators, users, and contributors, and you can read it offline.
-
| Goal | English | 中文文档 |
|---|---|---|
| Understand the product | [Introduction](./document/en/getting-started/introduction.md) | [产品简介](./document/zh-CN/getting-started/introduction.md) |
| Run it in 10 minutes | [Quick start](./document/en/getting-started/quick-start.md) | [快速开始](./document/zh-CN/getting-started/quick-start.md) |
+| Run lifecycle and resumable streams | [Chat](./document/en/modules/chat.md) | [智能对话](./document/zh-CN/modules/chat.md) |
+| The always-on personal agent | [Personal Agent](./document/en/modules/personal-agent.md) | [Personal Agent](./document/zh-CN/modules/personal-agent.md) |
+| Long-running self-driven tasks | [Autonomous loop](./document/en/modules/autonomous-loop.md) | [自主循环](./document/zh-CN/modules/autonomous-loop.md) |
+| Desktop local and hybrid modes | [Desktop local mode](./document/en/deployment/desktop-local-mode.md) | [桌面离线本机模式](./document/zh-CN/deployment/desktop-local-mode.md) |
+| Memory and self-evolution | [Memory](./document/en/modules/memory.md) | [记忆系统](./document/zh-CN/modules/memory.md) |
| Configure a deployment | [Deployment](./document/en/deployment/README.md) | [部署指南](./document/zh-CN/deployment/README.md) |
| Explore the system design | [Architecture](./document/en/architecture/overview.md) | [架构总览](./document/zh-CN/architecture/overview.md) |
-| Build a domain ontology | [Domain ontology quickstart](./document/en/getting-started/domain-ontology-quickstart.md) | [快速构建领域本体](./document/zh-CN/getting-started/domain-ontology-quickstart.md) |
-| Learn MCP, skills, memory, and sandboxing | [Modules](./document/en/README.md#modules) | [功能模块](./document/zh-CN/README.md#功能模块) |
+| The ontology control plane | [Domain ontology quickstart](./document/en/getting-started/domain-ontology-quickstart.md) | [快速构建领域本体](./document/zh-CN/getting-started/domain-ontology-quickstart.md) |
| Build backend or frontend features | [Development](./document/en/README.md#development) | [开发指南](./document/zh-CN/README.md#开发指南) |
Start from [document/README.md](./document/README.md) to browse every guide.
## Roadmap
-1. **Seamless cloud/local switching across clients** — one server deployment
- serving multiple clients, keeping conversations, agents, skills, files and task
- state in sync.
+1. **Seamless Cloud/Local switching across clients** — building on today's hybrid
+ mode, one server deployment serving multiple clients, keeping conversations,
+ agents, skills, files and task state in sync.
2. **Adaptive model routing based on Mixture of Agents** — select or combine models
by task complexity, modality, latency and cost: light models for simple work,
stronger ones only when the task warrants it.
@@ -299,5 +368,3 @@ supplementary terms. The terms restrict operating the software as a competing
multi-tenant SaaS offering and require the UI's Powered-by attribution to
remain visible. [LICENSE](./LICENSE) and [NOTICE](./NOTICE) define the complete
rights and obligations for internal use, modification, and distribution.
-
-Each Docker sandbox uses a separate bridge with the trusted backend and controller. Sandbox host ports are not published; peer sandboxes cannot access each other's execd. Use the bundled custom controller image.
diff --git a/README_CN.md b/README_CN.md
index 0502ea1b..a96fd4e2 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -3,16 +3,12 @@
- HugAgentOS:面向企业的本体驱动可信推理 AgentOS
+ HugAgentOS:7×24 小时持久运行的开源 Agent Harness
- 面向企业智能体的开源、自托管底座
-
-
-
- 让模型不只回答问题,还能检索知识、调用工具、处理文件、运行代码,
- 并持续完成真实任务。
+ 把问题交给它,然后合上电脑——
+ 智能体在云端继续干活,做完再告诉你。
@@ -37,8 +33,11 @@
-
-
+
+
+
+
+
@@ -48,16 +47,33 @@
-HugAgentOS 是面向企业级智能体场景的 AgentOS,把领域本体提升为推理、决策与
-行动的控制平面。其开源社区版将智能对话、私有知识库 RAG、子智能体、MCP 工具、
-Agent Skills、沙箱执行、长期记忆、自动化和数据画布整合到一个可私有部署的
-工作空间中。
+模型能力越来越强,但让智能体**真正把一件事干完**,靠的是模型之外的那一层:
+运行生命周期、上下文装配、工具与沙箱、人工审批、记忆、故障恢复。这一层就是
+**Agent Harness**。
+
+HugAgentOS 是一个开源、可自托管的 Agent Harness,核心是 **Durable Agent**:
+你提出的问题在**云端**执行,而不是依赖你的浏览器或电脑。关掉网页、合上笔记本、
+断网下班,任务都会继续推进;你也可以在常用的 IM 里下达任务、接收结果。在此之上,
+我们提供一个 7×24 小时在线的 **Personal Agent**,并以 **HugAgent Cloud**(服务器)
+和 **HugAgent Local**(桌面本机)两种形态运行,二者还可以混合使用。
> [!NOTE]
> 本社区仓库由上游主仓按发布版本自动生成,并标记为 `generated`。
> `src/**` 的修改请通过 Issue 或 Discussion 反馈;文档与示例欢迎直接提交 PR。
> 详细规则见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
+## 一眼看懂
+
+| 场景 | 普通对话式 AI | HugAgentOS |
+|---|---|---|
+| 你合上电脑 | 任务跟着停下 | 任务在云端继续,回来直接看结果 |
+| 服务器重启或宕机 | 进行中的任务丢失 | 其他实例接管,从上次保存的进度继续 |
+| 中途失败重试 | 可能重复发邮件、重复写入 | 不确定是否成功的操作不会自动重做 |
+| 很长的任务 | 受超时和轮数上限约束 | 不设业务时限,跑到做完为止 |
+| 怎么找到你 | 只能等你打开网页 | IM 推送、定时任务、心跳主动提醒 |
+| 在哪运行 | 云端或本地二选一 | HugAgent Cloud / HugAgent Local / 本机 + 云端混合 |
+| 用得越久 | 每次从零开始 | 记忆、做法与技能持续沉淀,逐条审批、可回滚 |
+
## 演示
60 秒速览:同一个任务做两次——中间是受审计的自进化,记忆、技能与编排逐条沉淀,
@@ -65,36 +81,115 @@ Agent Skills、沙箱执行、长期记忆、自动化和数据画布整合到
https://github.com/user-attachments/assets/a268e371-a11f-488b-98d4-08598e2fdf89
-## 快速开始
+## ⏱️ Durable Agent:合上电脑,任务继续
-个人试用用一键安装,长期服务或需要服务隔离用 Docker Compose。两者都需要准备
-OpenAI 兼容模型或本地模型。**初始账号与密码均为 `admin`,首次登录必须修改;
-CE 不提供自助注册。**
+把问题交给 HugAgentOS 后,任务运行在云端的服务和沙箱里,**你的设备只是一个入口**。
+你可以随时离开,也可以换一台设备回来,看到的始终是同一份进度和结果。
-### 方式一:一键安装(Linux / macOS / WSL2)
+```text
+09:00 在飞书里对智能体说:「调研竞品 A、B、C 的定价,整理成表格并写一份对比报告」
+09:01 智能体回复「已开始」,任务在云端沙箱里运行 → 你合上电脑去开会
+10:40 要发一封邮件?飞书里收到审批提醒,你在手机上打开网页点「批准」
+11:30 同一个飞书对话里收到汇报:报告完成
+11:35 接着回一句「再加上 D」→ 同一个任务继续做,不用从头来
+```
-需要 Python 3.11+、Node.js 20+、Git 和 `curl`;不需要 Docker、PostgreSQL 或 Redis。
+**为什么能一直跑下去**
-```bash
-curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh | bash
-```
+- **执行在云端,不依赖你的设备**:后台任务、定时任务和心跳都在服务端运行,任务的工作文件
+ 保存在专属的持久沙箱中。关掉页面不会停止任务,任意设备打开都能接着看实时进度。
+- **服务器出问题也不丢**:任务进度按步写入数据库,并由带租约的 worker 执行。进程或机器中断后,
+ 其他实例接管,从最后保存的进度继续,而不是悄悄消失。
+- **不会重复做有风险的事**:发邮件、写入外部系统等操作都会记录意图与结果;恢复时,不确定是否
+ 已经执行的操作不会自动重做,而是请你确认「已执行 / 未执行」。
+- **跑到做完为止**:长任务和[自主循环](./document/zh-CN/modules/autonomous-loop.md)不设总时长和
+ 业务轮次上限;停滞检测、异常熔断和硬性后备防止失控。等你审批或回答期间,不消耗执行预算。
+- **中途可以改主意**:运行中随时补充要求,智能体在下一步采纳,不用取消重来。
+- **需要你时会找你**:需要审批的操作会在 IM 中提醒、在网页上批准;你没回复之前,任务等待而不是擅自行动。
-安装器会把源码拉到 `~/.hugagent/source`,创建隔离 Python 环境、构建 Web 应用并进入
-首次配置向导,完成后打开 [http://127.0.0.1:3001](http://127.0.0.1:3001)。之后用
-`~/.hugagent/venv/bin/hugagent` 再次启动。
+> [!IMPORTANT]
+> 云端持续运行依赖 PostgreSQL、Redis 与 OpenSandbox,请使用 **HugAgent Cloud**
+> (Docker Compose)部署。一键安装使用 SQLite 与进程内状态,适合个人试用与开发。
-> [!WARNING]
-> 默认仅监听 `127.0.0.1`。确需远程访问时用
-> `hugagent serve --host 0.0.0.0 --port 3001 --no-browser`,并先配置强密码、防火墙
-> 与 HTTPS,不要直接暴露在不受信任的网络中。
+## 🤖 Personal Agent:一个一直在线的个人智能体
-一键安装使用 SQLite、进程内状态与本地子进程沙箱,适合个人试用与开发;选项与故障
-排查见[一键安装指南](./document/zh-CN/deployment/quick-install.md)。
+Personal Agent 构建在上述 Durable Harness 之上:**一个对话入口、一个持续存在的身份、
+一台永久的云端电脑**。你只管说事,它决定是当场回答,还是开一个后台任务去做,
+做完再回来汇报。
+
+| 能力 | 说明 |
+|---|---|
+| 💬 **单一入口,并行后台任务** | 闲聊、查进度当场回答;调研、写报告、跑代码、操作电脑自动转为后台任务(默认并行 3 个,可调 1–8)。补充要求直接说,它自己判断归属哪个任务 |
+| 🖥️ **永久沙盒与云电脑** | 每个用户一台不设到期时间的专属 OpenSandbox,工作目录与浏览器资料独立持久卷;容器丢失时复用卷重建 |
+| ⏰ **定时任务与心跳** | 「每天 8 点提醒我看邮件」创建定时任务;心跳按间隔对照 `HEARTBEAT.md` 自查,无事不打扰,有事才提醒 |
+| 📱 **多渠道在线** | 绑定飞书、钉钉、微信、企业微信机器人;每个联系人或群拥有独立会话,结果发回来源会话 |
+| ✅ **可控的执行权** | 邮件、电脑输入、会话写入需要批准;邮件逐封批准,其他可「本任务内同类操作都允许」;拒绝后改用其他方法 |
+| 🧠 **文件化的人格与记忆** | 工作区里的 `SOUL.md`、`USER.md`、`MEMORY.md` 与每日日志,可在「我的空间」直接查看与编辑;Dreaming 定期把日志整理为长期记忆 |
+| 💻 **连接你的电脑** | 在桌面客户端「本机 + 云端」模式下一键连接 Mac / Windows / Linux,按授权接管屏幕或管理本机会话;设备只出站轮询,不开放入站端口 |
+
+Personal Agent 在社区版中默认关闭,在 HugAgent Cloud 部署中设置
+`PERSONAL_AGENT_ENABLED=true` 并启用 `personal-agent` Compose profile 后即可使用。
+完整说明见 [Personal Agent 文档](./document/zh-CN/modules/personal-agent.md)。
+
+## ☁️ HugAgent Cloud 与 💻 HugAgent Local
+
+同一套 Harness,按你的数据和算力放在哪里来选择运行形态。
+
+| | **HugAgent Cloud** | **HugAgent Local** | **本机 + 云端(混合)** |
+|---|---|---|---|
+| 运行位置 | 你的服务器 / 私有云 | 你的电脑 | 云端为控制面,本机为执行面 |
+| 入口 | 浏览器,多端访问 | 桌面客户端(Windows / macOS / Linux) | 桌面客户端 |
+| 数据与状态 | PostgreSQL、Redis、S3 兼容存储 | 本地 SQLite、Milvus Lite、本地文件 | 云端项目存云端,本地项目留本机 |
+| 执行环境 | OpenSandbox 隔离沙箱,快照恢复 | 本机真实项目目录,按目录授权与权限档执行 | 两者兼有 |
+| 适合 | 7×24 Personal Agent、长跑任务、多渠道接入 | 离线、敏感数据、本地代码与文件 | 云端常驻 + 操作本机电脑与项目 |
+| 部署方式 | Docker Compose | 桌面离线安装包;或一键安装脚本 | 桌面客户端选择「本机 + 云端」 |
+
+- **HugAgent Cloud**:完整的 Durable Harness。适合需要一直在线、跨设备访问、接入 IM 渠道的场景。
+ 每个 Docker 沙箱与受信任的后端和控制器使用独立网桥,不发布沙箱宿主端口,沙箱之间不能互访 execd。
+- **HugAgent Local**:桌面安装包自带私有 Python 运行时与服务,**不需要安装 Python、Docker
+ 或数据库**,可完全离线运行并接入本地模型;绑定本地项目后,Bash 与文件工具直接在项目真实
+ 目录中工作。升级失败自动回滚到上一版本与数据备份。
+- **混合模式**:一套云端账号,云端会话存云端;只有本地项目的对话、文件与执行留在本机。
+ Personal Agent 可以借此「连接这台电脑」,让云端常驻的智能体操作你的本机。
+
+详见[桌面离线本机模式](./document/zh-CN/deployment/desktop-local-mode.md)与
+[本机项目工作目录](./document/zh-CN/deployment/local-project-workspace.md)。
+
+## 🧬 自进化:从真实工作中变强,每一步都可审计
+
+HugAgentOS 不让智能体每次从零开始,也不让它「悄悄学坏」。
+
+- **从轨迹中学做法**:后置记忆流水线会识别「失败 → 用户纠正 → 再次成功」这类经过结果验证
+ 的轨迹,沉淀为可复用的**做法(procedural)**,而不是记住会过期的事实;稳定做法可以进一步
+ 编译为技能。
+- **三层记忆**:L1 个人画像、L2 向量做法记忆(Milvus,可选)、L3 关系图谱(Neo4j,可选);
+ 写入走持久 Outbox,崩溃重放不会重复新增,脱敏闸门过滤凭据。
+- **逐条审批、可回滚**:记忆与技能的进化建议以卡片形式出现,经你确认才生效,可随时关闭。
+- **Personal Agent 的 Dreaming**:每天定时把日志整理为 `MEMORY.md` / `USER.md` 的更新,
+ `DREAMS.md` 记录来源、去重与冲突;只自动提升你明确说「记住」的内容。
+- **可溯的本体进化**:审批、驳回、证据与执行结果留痕,整理为版本化的本体建议,人工审核后生效。
+
+## 🧰 更多 Harness 能力
+
+| 能力 | 说明 |
+|---|---|
+| 🔁 **自主循环** | 目标跨轮存活、做完为止;maker≠checker:worker 干活、只读评审员亲验产出,命令可判定的需求由 driver 机检 |
+| 🗺️ **计划模式** | 先出计划再逐步执行,步骤级绑定 MCP / 技能 / 子智能体,同样支持断线续播 |
+| 🤝 **子智能体** | 内置 explorer / worker / reviewer,可创建个人子智能体,自动路由或 `@` 提及协同 |
+| 🔧 **MCP · Skills · 插件** | 内置联网搜索、网页抓取、知识检索、图表、自动化等工具;MCP 市场、私有 MCP、Agent Skills 与插件 |
+| 🧪 **沙箱与产物** | OpenSandbox 或子进程沙箱执行代码,产出图表、报告、Office 文件、网页与数据画布 |
+| 📚 **私有知识库 RAG** | 文档分块,向量与关键词混合检索,可选重排 |
+| 🛡️ **可选的本体控制平面** | 导入 Domain Pack 后,工具执行前做确定性规则检查,高风险答案由独立评审员或委员会复核;关闭时完全不改变原流程 |
+| 🔌 **模型可替换** | 统一的模型服务配置接入云端或本地 OpenAI 兼容模型,评审、规划、记忆等角色可分别指定模型 |
-### 方式二:Docker Compose
+## 快速开始
-需要 PostgreSQL、Redis、隔离沙箱与持久化卷时使用。需先安装 Git、Docker 与
-Compose v2。
+所有方式都需要准备 OpenAI 兼容模型或本地模型。**初始账号与密码均为 `admin`,首次登录必须修改;
+CE 不提供自助注册。**
+
+### HugAgent Cloud:Docker Compose(推荐用于 7×24)
+
+需先安装 Git、Docker 与 Compose v2。
```bash
git clone https://github.com/ZJU-REAL/HugAgentOS.git
@@ -106,112 +201,75 @@ docker compose up -d --build
```
打开 [http://localhost:3002](http://localhost:3002),登录后进入「设置 → 系统管理 →
-模型服务」接入模型。Profiles、持久化与生产配置见
-[Docker Compose 部署指南](./document/zh-CN/deployment/docker-compose.md)。
-
-## 为什么是 HugAgentOS
-
-重点不是再包装一个聊天界面,而是把智能体完成任务所需的上下文、执行能力和产物管理
-放在同一条链路里;并把领域本体从「知识库」提升为**机器可执行的控制平面**——受控的
-概念、关系、铁律与 Action 契约,让技能、记忆和编排三大引擎共享同一套业务语言。
-
-
-
-
- 🔌 模型可替换
- 统一的模型服务配置接入云端或本地模型,不锁定单一供应商。
-
-
- 🛠️ 能执行任务
- ReAct 编排 MCP、技能与沙箱,让模型能搜索、分析、生成文件并调用外部能力。
-
-
-
-
- 🧠 有知识与记忆
- 私有知识库与分层记忆提供跨文件、跨会话的长期上下文。
-
-
- 🏠 数据可自持
- 应用、数据库与文件存储都可运行在自己的基础设施中。
-
-
-
-
- 🛡️ 门控可信执行
- 候选计划依次经过确定性规则检查、风险分级证据评审与门控执行;违规动作会带着
- 规则、证据和修正建议返回,不会静默放行。
-
-
- 🔎 可溯的持续进化
- 审批、驳回、证据与执行结果均留痕,整理为版本化本体建议,人工审核后生效并可回滚。
-
-
-
+模型服务」接入模型。启用 Personal Agent、持久化与生产配置见
+[Docker Compose 部署指南](./document/zh-CN/deployment/docker-compose.md)和
+[Personal Agent 部署](./document/zh-CN/modules/personal-agent.md#部署)。
-> [!NOTE]
-> 本体可信控制平面是正在现有 Harness 上分阶段集成的企业级目标架构。它强化结构化
-> 合规和基于证据的评审,但不对自由文本作「零幻觉」承诺。
+### HugAgent Local:桌面客户端
-## 核心能力
+从 [Releases](https://github.com/ZJU-REAL/HugAgentOS/releases) 下载对应平台的桌面安装包,
+首次启动选择「仅本机」「仅云端」或「本机 + 云端」。完整离线包自带运行时,无需额外安装依赖。
-社区版覆盖个人智能体从对话、执行到沉淀复用的完整闭环,可选组件按需启用。
+### 开发者试用:一键安装(Linux / macOS / WSL2)
-| 能力 | 说明 |
-|---|---|
-| 💬 **智能对话与计划模式** | SSE 流式、ReAct 工具编排、深度思考、计划模式、引用溯源、断线续播 |
-| 📚 **私有知识库 RAG** | 文档分块、向量与关键词混合检索、可选重排、个人知识库隔离 |
-| 🤝 **个人子智能体** | 创建不同角色的子智能体,自动路由或 `@` 提及协同 |
-| 🔧 **MCP 工具生态** | 内置联网搜索、网页抓取、知识检索、图表、报告、批量、自动化、技能管理 |
-| 🧩 **Agent Skills** | 标准化技能说明 + 脚本扩展,支持内置、市场与个人技能 |
-| ⚙️ **定时自动化** | 自然语言创建定时任务 |
-| 💬 **群聊接入** | 飞书 / 钉钉 / 企微机器人;可选群聊旁听与历史回溯,让智能体看得到群里的上下文 |
-| 🧪 **沙箱与产物** | 子进程或轻量容器沙箱执行代码,产出图表、报告、Office 文件、网页与数据画布 |
-| 🧠 **三层个人记忆** | L1 画像存关系库;可选 Milvus 向量记忆与 Neo4j 图谱记忆 |
-| 🧬 **个人进化** | 从你的真实工作沉淀记忆与技能,逐条审批后才对你生效,可随时关闭 |
-| 🗂️ **个人工作空间** | 项目、文件夹、收藏、会话分享与产物中心 |
-| 📊 **数据画布** | 会话内查看与编辑结构化数据,分析过程与结果同处一个工作区 |
+需要 Python 3.11+、Node.js 20+、Git 和 `curl`;不需要 Docker、PostgreSQL 或 Redis。
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh | bash
+```
+
+安装器会把源码拉到 `~/.hugagent/source`,创建隔离 Python 环境、构建 Web 应用并进入
+首次配置向导,完成后打开 [http://127.0.0.1:3001](http://127.0.0.1:3001)。之后用
+`~/.hugagent/venv/bin/hugagent` 再次启动。
+
+> [!WARNING]
+> 默认仅监听 `127.0.0.1`。确需远程访问时用
+> `hugagent serve --host 0.0.0.0 --port 3001 --no-browser`,并先配置强密码、防火墙
+> 与 HTTPS,不要直接暴露在不受信任的网络中。
+
+一键安装使用 SQLite、进程内状态与本地子进程沙箱,适合试用与开发;选项与故障
+排查见[一键安装指南](./document/zh-CN/deployment/quick-install.md)。
## 系统架构
HugAgentOS 将用户渠道、智能体工作流、可复用能力引擎、本体契约、数据治理和
-基础设施分层组织。Action 契约把本体层与计划生成、审批校验和门控执行连接起来,
-安全管理与平台监管则贯穿完整技术栈。
+基础设施分层组织;Durable Run、租约与恢复机制贯穿执行链路,安全管理与平台监管
+贯穿完整技术栈。

> [!NOTE]
-> 架构图展示 HugAgentOS 的完整产品架构。其中部分治理、协作、网关和持久沙箱
-> 能力仅在商业版中提供。
+> 架构图展示 HugAgentOS 的完整产品架构。其中部分治理、协作和网关能力仅在商业版中提供。
### 技术栈
-项目选择成熟、可替换的开源组件,并通过清晰的服务边界组合为完整产品。
-
| 层级 | 主要技术 |
|---|---|
| 智能体运行时 | AgentScope 2.0、ReAct、Model Context Protocol |
| 后端 | Python、FastAPI、SQLAlchemy、Alembic |
| 前端 | React 19、TypeScript、Vite、Zustand、Ant Design |
-| 数据与状态 | SQLite 或 PostgreSQL 15、进程内状态或 Redis 7、本地文件存储 |
+| 桌面端 | Tauri 2、私有 CPython 3.11 运行时 |
+| 数据与状态 | SQLite 或 PostgreSQL 15、进程内状态或 Redis 7、本地或 S3 兼容存储 |
+| 沙箱 | OpenSandbox、本地子进程 |
| 可选记忆 | Milvus 2.4、Neo4j 5 Community、mem0 |
-| 部署 | 本地一键安装、Docker Compose、Nginx |
+| 部署 | 桌面安装包、一键安装、Docker Compose、Nginx |
更详细的请求生命周期、容器拓扑和关键设计决策见
[架构总览](./document/zh-CN/architecture/overview.md)。
## 社区版与商业版
-社区版让个人把智能体能力完整运行起来;商业版在相同产品体验上补充组织级治理、
-协作和交付能力。商业版能力不会以关闭开关的形式混入社区版源码。
+社区版让个人把完整的 Harness 与 Personal Agent 运行起来;商业版在相同产品体验上补充
+组织级治理、协作和交付能力。商业版能力不会以关闭开关的形式混入社区版源码。
| 社区版(CE) | 商业版(EE)新增 |
|---|---|
-| 智能对话、计划模式与个人子智能体 | 团队、成员、组织级智能体库与权限矩阵 |
-| 7 个通用 MCP、个人技能与技能市场 | 行业数据工具、组织级能力治理与技能审核 |
-| 私有知识库、三层个人记忆 | 公共知识库管理与记忆审计 |
+| Durable Run、自主循环、计划模式与个人子智能体 | 团队、成员、组织级智能体库与权限矩阵 |
+| Personal Agent:后台任务、专属电脑、渠道、调度与文件记忆 | 用户、角色、团队授权治理与跨设备会话管理 |
+| 通用 MCP、个人技能与技能市场 | 行业数据工具、组织级能力治理与技能审核 |
+| 私有知识库、三层个人记忆与个人进化 | 公共知识库管理与记忆审计 |
| 自动化、个人数据画布 | 组织计费、用量汇总与画布多人协同 |
-| OpenSandbox、快照与 S3 文件存储 | 团队挂载、其他云存储与离线商业交付 |
+| OpenSandbox、快照与 S3 文件存储;桌面本机模式 | 团队挂载、其他云存储与离线商业交付 |
| 本地账号与保留 Powered-by 的品牌配置 | SSO、审计合规与完整 white-label |
功能边界与升级路径以
@@ -219,24 +277,26 @@ HugAgentOS 将用户渠道、智能体工作流、可复用能力引擎、本体
## 文档
-仓库内提供完整的中英文文档,从首次部署到架构和二次开发均可离线阅读。
-
| 你想了解 | 中文文档 | English |
|---|---|---|
| 项目定位与整体能力 | [产品简介](./document/zh-CN/getting-started/introduction.md) | [Introduction](./document/en/getting-started/introduction.md) |
| 10 分钟运行项目 | [快速开始](./document/zh-CN/getting-started/quick-start.md) | [Quick start](./document/en/getting-started/quick-start.md) |
+| Run 生命周期与断线续播 | [智能对话](./document/zh-CN/modules/chat.md) | [Chat](./document/en/modules/chat.md) |
+| 7×24 个人智能体 | [Personal Agent](./document/zh-CN/modules/personal-agent.md) | [Personal Agent](./document/en/modules/personal-agent.md) |
+| 长时运行的自驱动任务 | [自主循环](./document/zh-CN/modules/autonomous-loop.md) | [Autonomous loop](./document/en/modules/autonomous-loop.md) |
+| 桌面本机与混合模式 | [桌面离线本机模式](./document/zh-CN/deployment/desktop-local-mode.md) | [Desktop local mode](./document/en/deployment/desktop-local-mode.md) |
+| 记忆与自进化 | [记忆系统](./document/zh-CN/modules/memory.md) | [Memory](./document/en/modules/memory.md) |
| 生产部署与环境变量 | [部署指南](./document/zh-CN/deployment/README.md) | [Deployment](./document/en/deployment/README.md) |
| 系统设计与请求链路 | [架构总览](./document/zh-CN/architecture/overview.md) | [Architecture](./document/en/architecture/overview.md) |
-| 快速构建领域本体 | [领域本体快速入门](./document/zh-CN/getting-started/domain-ontology-quickstart.md) | [Domain ontology quickstart](./document/en/getting-started/domain-ontology-quickstart.md) |
-| MCP、技能、记忆与沙箱 | [功能模块](./document/zh-CN/README.md#功能模块) | [Modules](./document/en/README.md#modules) |
+| 领域本体控制平面 | [领域本体快速入门](./document/zh-CN/getting-started/domain-ontology-quickstart.md) | [Domain ontology quickstart](./document/en/getting-started/domain-ontology-quickstart.md) |
| 后端与前端开发 | [开发指南](./document/zh-CN/README.md#开发指南) | [Development](./document/en/README.md#development) |
所有文档入口见 [document/README.md](./document/README.md)。
## 路线图
-1. **云端 / 本地无缝切换与多端互通** —— 一套服务端连接多客户端,同步会话、智能体、
- 技能、文件与任务状态。
+1. **Cloud / Local 无缝切换与多端互通** —— 在现有混合模式基础上,一套服务端连接多客户端,
+ 同步会话、智能体、技能、文件与任务状态。
2. **基于 MoA 的自适应模型路由** —— 按任务复杂度、模态、时延与成本选择或组合模型,
简单任务用轻量模型,复杂任务再升级。
3. **更丰富的扩展生态** —— 持续扩充智能体、技能、MCP 服务与插件,完善发现、安装、
diff --git a/desktop/requirements-desktop-linux-aarch64-py311.lock b/desktop/requirements-desktop-linux-aarch64-py311.lock
index 27cb3904..1420921a 100644
--- a/desktop/requirements-desktop-linux-aarch64-py311.lock
+++ b/desktop/requirements-desktop-linux-aarch64-py311.lock
@@ -1,8 +1,8 @@
# This file is generated. Do not edit it by hand.
# Regenerate with: npm --prefix desktop run lock:desktop
-# input-sha256: d18da0b813b601a5c727eb280b730ddb3b307fb487ab2a2bb28d4c568a336990
+# input-sha256: b9680ef14192ea099c4a7159c08c064cbd555bd681c00c49c5c74b1f7f3d4046
# Target: CPython 3.11 on Linux aarch64 (UOS 1070 / manylinux 2.28)
-agentscope==2.0.0
+agentscope @ https://github.com/agentscope-ai/agentscope/archive/ed357feb78a45ff02df155bb5610eab9bf543832.tar.gz
# via -r desktop/requirements-desktop.txt
aiofiles==25.1.0
# via agentscope
@@ -105,8 +105,6 @@ defusedxml==0.7.1
# via
# -r desktop/requirements-desktop.txt
# markitdown
-deprecated==1.3.1
- # via limits
dingtalk-stream==0.24.3
# via -r desktop/requirements-desktop.txt
distro==1.9.0
@@ -183,18 +181,18 @@ httpx==0.28.1
# huggingface-hub
# lark-oapi
# litellm
- # mcp
# mem0ai
# ollama
# openai
# qdrant-client
# tavily-python
httpx-sse==0.4.3
+ # via dashscope
+httpx2==2.12.0
# via
- # dashscope
+ # agentscope
+ # anthropic
# mcp
-httpx2==2.12.0
- # via anthropic
huggingface-hub==1.28.0
# via tokenizers
hyperframe==6.1.0
@@ -220,7 +218,7 @@ jmespath==1.1.0
# via
# boto3
# botocore
-json-repair==0.63.3
+json-repair==0.64.1
# via agentscope
json5==0.15.0
# via agentscope
@@ -228,6 +226,7 @@ jsonschema==4.26.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+ # json-repair
# litellm
# mcp
jsonschema-specifications==2025.9.1
@@ -236,8 +235,6 @@ kiwisolver==1.5.0
# via matplotlib
lark-oapi==1.7.3
# via -r desktop/requirements-desktop.txt
-limits==5.8.0
- # via slowapi
linkify-it-py==2.1.0
# via markdown-it-py
litellm==1.98.0
@@ -270,10 +267,12 @@ matplotlib==3.11.1
# via
# -r desktop/requirements-desktop.txt
# seaborn
-mcp==1.29.0
+mcp==2.3.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+mcp-types==2.3.0
+ # via mcp
mdit-py-plugins==0.6.1
# via textual
mdurl==0.1.2
@@ -316,6 +315,7 @@ openpyxl==3.1.5
opentelemetry-api==1.44.0
# via
# agentscope
+ # mcp
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
@@ -348,7 +348,6 @@ packaging==26.3
# via
# faiss-cpu
# huggingface-hub
- # limits
# matplotlib
# onnxruntime
# pikepdf
@@ -415,8 +414,10 @@ pydantic==2.13.4
# dashscope
# fastapi
# google-genai
+ # json-repair
# litellm
# mcp
+ # mcp-types
# mem0ai
# ollama
# openai
@@ -425,9 +426,7 @@ pydantic==2.13.4
pydantic-core==2.46.4
# via pydantic
pydantic-settings==2.15.0
- # via
- # litellm
- # mcp
+ # via litellm
pyee==13.0.1
# via playwright
pygments==2.21.0
@@ -441,7 +440,9 @@ pymilvus==2.5.18
pyparsing==3.3.2
# via matplotlib
pypdf==6.16.2
- # via -r desktop/requirements-desktop.txt
+ # via
+ # -r desktop/requirements-desktop.txt
+ # agentscope
pypdf2==3.0.1
# via -r desktop/requirements-desktop.txt
pypdfium2==5.13.0
@@ -518,11 +519,10 @@ requests-toolbelt==1.0.0
# via lark-oapi
rich==15.0.0
# via
+ # agentscope
# dashscope
# textual
# typer
-ripgrep==14.1.0
- # via agentscope
rpds-py==2026.6.3
# via
# jsonschema
@@ -549,8 +549,6 @@ six==1.17.0
# via
# markdownify
# python-dateutil
-slowapi==0.1.10
- # via -r desktop/requirements-desktop.txt
sniffio==1.3.1
# via
# anthropic
@@ -564,7 +562,9 @@ sqlalchemy==2.0.52
# alembic
# mem0ai
sse-starlette==3.0.3
- # via mcp
+ # via
+ # -r desktop/requirements-desktop.txt
+ # mcp
starlette==0.46.2
# via
# fastapi
@@ -615,8 +615,8 @@ typing-extensions==4.16.0
# grpcio
# httpx2
# huggingface-hub
- # limits
# mcp
+ # mcp-types
# openai
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-grpc
@@ -639,6 +639,8 @@ typing-inspection==0.4.4
# mcp
# pydantic
# pydantic-settings
+tzdata==2026.5
+ # via agentscope
uc-micro-py==2.0.0
# via linkify-it-py
ujson==5.13.0
@@ -667,8 +669,6 @@ werkzeug==3.1.8
# via -r desktop/requirements-desktop.txt
wheel==0.48.0
# via -r desktop/requirements-desktop.txt
-wrapt==2.3.0
- # via deprecated
wsproto==1.3.2
# via simple-websocket
xlsxwriter==3.2.9
diff --git a/desktop/requirements-desktop-linux-x86_64-py311.lock b/desktop/requirements-desktop-linux-x86_64-py311.lock
index 1ae48d54..a71239c2 100644
--- a/desktop/requirements-desktop-linux-x86_64-py311.lock
+++ b/desktop/requirements-desktop-linux-x86_64-py311.lock
@@ -1,8 +1,8 @@
# This file is generated. Do not edit it by hand.
# Regenerate with: npm --prefix desktop run lock:desktop
-# input-sha256: d18da0b813b601a5c727eb280b730ddb3b307fb487ab2a2bb28d4c568a336990
+# input-sha256: b9680ef14192ea099c4a7159c08c064cbd555bd681c00c49c5c74b1f7f3d4046
# Target: CPython 3.11 on Linux x86_64
-agentscope==2.0.0
+agentscope @ https://github.com/agentscope-ai/agentscope/archive/ed357feb78a45ff02df155bb5610eab9bf543832.tar.gz
# via -r desktop/requirements-desktop.txt
aiofiles==25.1.0
# via agentscope
@@ -31,6 +31,7 @@ anyio==4.14.2
# anthropic
# google-genai
# httpx
+ # httpx2
# mcp
# openai
# sse-starlette
@@ -98,8 +99,6 @@ defusedxml==0.7.1
# via
# -r desktop/requirements-desktop.txt
# markitdown
-deprecated==1.3.1
- # via limits
dingtalk-stream==0.24.3
# via -r desktop/requirements-desktop.txt
distro==1.9.0
@@ -155,6 +154,7 @@ grpcio==1.83.0
h11==0.16.0
# via
# httpcore
+ # httpcore2
# uvicorn
# wsproto
h2==4.4.0
@@ -165,6 +165,8 @@ hpack==4.2.0
# via h2
httpcore==1.0.9
# via httpx
+httpcore2==2.16.0
+ # via httpx2
httpx==0.28.1
# via
# -r desktop/requirements-desktop.txt
@@ -175,15 +177,16 @@ httpx==0.28.1
# huggingface-hub
# lark-oapi
# litellm
- # mcp
# mem0ai
# ollama
# openai
# qdrant-client
# tavily-python
httpx-sse==0.4.3
+ # via dashscope
+httpx2==2.16.0
# via
- # dashscope
+ # agentscope
# mcp
huggingface-hub==1.26.0
# via tokenizers
@@ -193,6 +196,7 @@ idna==3.18
# via
# anyio
# httpx
+ # httpx2
# requests
# yarl
importlib-metadata==8.9.0
@@ -205,7 +209,7 @@ jiter==0.16.0
# via
# anthropic
# openai
-json-repair==0.61.7
+json-repair==0.64.1
# via agentscope
json5==0.15.0
# via agentscope
@@ -213,6 +217,7 @@ jsonschema==4.26.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+ # json-repair
# litellm
# mcp
jsonschema-specifications==2025.9.1
@@ -221,8 +226,6 @@ kiwisolver==1.5.0
# via matplotlib
lark-oapi==1.7.1
# via -r desktop/requirements-desktop.txt
-limits==5.8.0
- # via slowapi
litellm==1.94.1
# via -r desktop/requirements-desktop.txt
lxml==6.1.1
@@ -250,10 +253,12 @@ matplotlib==3.11.1
# via
# -r desktop/requirements-desktop.txt
# seaborn
-mcp==1.29.0
+mcp==2.3.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+mcp-types==2.3.0
+ # via mcp
mdurl==0.1.2
# via markdown-it-py
mem0ai==2.0.14
@@ -294,6 +299,7 @@ openpyxl==3.1.5
opentelemetry-api==1.44.0
# via
# agentscope
+ # mcp
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
@@ -326,7 +332,6 @@ packaging==26.2
# via
# faiss-cpu
# huggingface-hub
- # limits
# matplotlib
# onnxruntime
# pikepdf
@@ -388,17 +393,16 @@ pydantic==2.13.4
# anthropic
# fastapi
# google-genai
+ # json-repair
# litellm
# mcp
+ # mcp-types
# mem0ai
# ollama
# openai
- # pydantic-settings
# qdrant-client
pydantic-core==2.46.4
# via pydantic
-pydantic-settings==2.14.2
- # via mcp
pyee==13.0.1
# via playwright
pygments==2.20.0
@@ -410,7 +414,9 @@ pymilvus==2.5.18
pyparsing==3.3.2
# via matplotlib
pypdf==6.14.2
- # via -r desktop/requirements-desktop.txt
+ # via
+ # -r desktop/requirements-desktop.txt
+ # agentscope
pypdf2==3.0.1
# via -r desktop/requirements-desktop.txt
pypdfium2==5.12.1
@@ -429,7 +435,6 @@ python-dotenv==1.2.2
# -r desktop/requirements-desktop.txt
# litellm
# magika
- # pydantic-settings
# pymilvus
python-engineio==4.13.3
# via python-socketio
@@ -485,10 +490,9 @@ requests-toolbelt==1.0.0
# via lark-oapi
rich==15.0.0
# via
+ # agentscope
# dashscope
# typer
-ripgrep==14.1.0
- # via agentscope
rpds-py==2026.6.3
# via
# jsonschema
@@ -513,8 +517,6 @@ six==1.17.0
# via
# markdownify
# python-dateutil
-slowapi==0.1.10
- # via -r desktop/requirements-desktop.txt
sniffio==1.3.1
# via
# anthropic
@@ -528,7 +530,9 @@ sqlalchemy==2.0.51
# alembic
# mem0ai
sse-starlette==3.0.3
- # via mcp
+ # via
+ # -r desktop/requirements-desktop.txt
+ # mcp
starlette==0.46.2
# via
# fastapi
@@ -556,6 +560,10 @@ tree-sitter==0.26.0
# via agentscope
tree-sitter-bash==0.25.1
# via agentscope
+truststore==0.10.5
+ # via
+ # httpcore2
+ # httpx2
typer==0.27.0
# via dashscope
typing-extensions==4.16.0
@@ -569,9 +577,10 @@ typing-extensions==4.16.0
# fastapi
# google-genai
# grpcio
+ # httpx2
# huggingface-hub
- # limits
# mcp
+ # mcp-types
# openai
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-grpc
@@ -592,7 +601,8 @@ typing-inspection==0.4.2
# via
# mcp
# pydantic
- # pydantic-settings
+tzdata==2026.5
+ # via agentscope
ujson==5.13.0
# via pymilvus
urllib3==2.7.0
@@ -616,8 +626,6 @@ werkzeug==3.1.8
# via -r desktop/requirements-desktop.txt
wheel==0.47.0
# via -r desktop/requirements-desktop.txt
-wrapt==2.3.0
- # via deprecated
wsproto==1.3.2
# via simple-websocket
xlsxwriter==3.2.9
diff --git a/desktop/requirements-desktop-macos-aarch64-py311.lock b/desktop/requirements-desktop-macos-aarch64-py311.lock
index 887a7ba2..9df1e05d 100644
--- a/desktop/requirements-desktop-macos-aarch64-py311.lock
+++ b/desktop/requirements-desktop-macos-aarch64-py311.lock
@@ -1,8 +1,8 @@
# This file is generated. Do not edit it by hand.
# Regenerate with: npm --prefix desktop run lock:desktop
-# input-sha256: 207ac56d3accd481bbd7d5fdd7de4ab076f3184f1dc6c7d0bd6de1d1b958d38a
+# input-sha256: 2580e67756923a30f416529f071ff7372044a681fb55cd05ef896e558846dd73
# Target: CPython 3.11 on macOS Apple Silicon
-agentscope==2.0.0
+agentscope @ https://github.com/agentscope-ai/agentscope/archive/ed357feb78a45ff02df155bb5610eab9bf543832.tar.gz
# via -r desktop/requirements-desktop.txt
aiofiles==25.1.0
# via agentscope
@@ -31,6 +31,7 @@ anyio==4.14.2
# anthropic
# google-genai
# httpx
+ # httpx2
# mcp
# openai
# sse-starlette
@@ -101,9 +102,7 @@ defusedxml==0.7.1
# -r desktop/requirements-desktop.txt
# markitdown
deprecated==1.3.1
- # via
- # limits
- # pikepdf
+ # via pikepdf
dingtalk-stream==0.24.3
# via -r desktop/requirements-desktop.txt
distro==1.9.0
@@ -157,6 +156,7 @@ grpcio==1.83.0
h11==0.16.0
# via
# httpcore
+ # httpcore2
# uvicorn
# wsproto
h2==4.4.0
@@ -167,6 +167,8 @@ hpack==4.2.0
# via h2
httpcore==1.0.9
# via httpx
+httpcore2==2.16.0
+ # via httpx2
httpx==0.28.1
# via
# -r desktop/requirements-desktop.txt
@@ -177,15 +179,16 @@ httpx==0.28.1
# huggingface-hub
# lark-oapi
# litellm
- # mcp
# mem0ai
# ollama
# openai
# qdrant-client
# tavily-python
httpx-sse==0.4.3
+ # via dashscope
+httpx2==2.16.0
# via
- # dashscope
+ # agentscope
# mcp
huggingface-hub==1.26.0
# via tokenizers
@@ -197,6 +200,7 @@ idna==3.18
# via
# anyio
# httpx
+ # httpx2
# requests
# yarl
importlib-metadata==8.9.0
@@ -209,7 +213,7 @@ jiter==0.16.0
# via
# anthropic
# openai
-json-repair==0.61.7
+json-repair==0.64.1
# via agentscope
json5==0.15.0
# via agentscope
@@ -217,6 +221,7 @@ jsonschema==4.26.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+ # json-repair
# litellm
# mcp
jsonschema-specifications==2025.9.1
@@ -225,8 +230,6 @@ kiwisolver==1.5.0
# via matplotlib
lark-oapi==1.7.1
# via -r desktop/requirements-desktop.txt
-limits==5.8.0
- # via slowapi
litellm==1.91.4
# via -r desktop/requirements-desktop.txt
lxml==6.1.1
@@ -254,10 +257,12 @@ matplotlib==3.11.1
# via
# -r desktop/requirements-desktop.txt
# seaborn
-mcp==1.29.0
+mcp==2.3.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+mcp-types==2.3.0
+ # via mcp
mdurl==0.1.2
# via markdown-it-py
mem0ai==2.0.14
@@ -300,6 +305,7 @@ openpyxl==3.1.5
opentelemetry-api==1.44.0
# via
# agentscope
+ # mcp
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
@@ -332,7 +338,6 @@ packaging==26.2
# via
# faiss-cpu
# huggingface-hub
- # limits
# matplotlib
# onnxruntime
# pikepdf
@@ -396,17 +401,16 @@ pydantic==2.13.4
# anthropic
# fastapi
# google-genai
+ # json-repair
# litellm
# mcp
+ # mcp-types
# mem0ai
# ollama
# openai
- # pydantic-settings
# qdrant-client
pydantic-core==2.46.4
# via pydantic
-pydantic-settings==2.14.2
- # via mcp
pyee==13.0.1
# via playwright
pygments==2.20.0
@@ -418,7 +422,9 @@ pymilvus==2.5.18
pyparsing==3.3.2
# via matplotlib
pypdf==6.14.2
- # via -r desktop/requirements-desktop.txt
+ # via
+ # -r desktop/requirements-desktop.txt
+ # agentscope
pypdf2==3.0.1
# via -r desktop/requirements-desktop.txt
pypdfium2==5.12.1
@@ -437,7 +443,6 @@ python-dotenv==1.2.2
# -r desktop/requirements-desktop.txt
# litellm
# magika
- # pydantic-settings
# pymilvus
python-engineio==4.13.3
# via python-socketio
@@ -493,10 +498,9 @@ requests-toolbelt==1.0.0
# via lark-oapi
rich==15.0.0
# via
+ # agentscope
# dashscope
# typer
-ripgrep==15.1.0
- # via agentscope
rpds-py==2026.6.3
# via
# jsonschema
@@ -521,8 +525,6 @@ six==1.17.0
# via
# markdownify
# python-dateutil
-slowapi==0.1.10
- # via -r desktop/requirements-desktop.txt
sniffio==1.3.1
# via
# anthropic
@@ -536,7 +538,9 @@ sqlalchemy==2.0.51
# alembic
# mem0ai
sse-starlette==3.0.3
- # via mcp
+ # via
+ # -r desktop/requirements-desktop.txt
+ # mcp
starlette==0.46.2
# via
# fastapi
@@ -566,6 +570,10 @@ tree-sitter==0.26.0
# via agentscope
tree-sitter-bash==0.25.1
# via agentscope
+truststore==0.10.5
+ # via
+ # httpcore2
+ # httpx2
typer==0.27.0
# via dashscope
typing-extensions==4.16.0
@@ -579,9 +587,10 @@ typing-extensions==4.16.0
# fastapi
# google-genai
# grpcio
+ # httpx2
# huggingface-hub
- # limits
# mcp
+ # mcp-types
# openai
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-grpc
@@ -602,7 +611,8 @@ typing-inspection==0.4.2
# via
# mcp
# pydantic
- # pydantic-settings
+tzdata==2026.5
+ # via agentscope
ujson==5.13.0
# via pymilvus
urllib3==2.7.0
diff --git a/desktop/requirements-desktop-macos-x86_64-py311.lock b/desktop/requirements-desktop-macos-x86_64-py311.lock
index 4118f46d..39a17b34 100644
--- a/desktop/requirements-desktop-macos-x86_64-py311.lock
+++ b/desktop/requirements-desktop-macos-x86_64-py311.lock
@@ -1,8 +1,8 @@
# This file is generated. Do not edit it by hand.
# Regenerate with: npm --prefix desktop run lock:desktop
-# input-sha256: 207ac56d3accd481bbd7d5fdd7de4ab076f3184f1dc6c7d0bd6de1d1b958d38a
+# input-sha256: 2580e67756923a30f416529f071ff7372044a681fb55cd05ef896e558846dd73
# Target: CPython 3.11 on macOS Intel
-agentscope==2.0.0
+agentscope @ https://github.com/agentscope-ai/agentscope/archive/ed357feb78a45ff02df155bb5610eab9bf543832.tar.gz
# via -r desktop/requirements-desktop.txt
aiofiles==25.1.0
# via agentscope
@@ -31,6 +31,7 @@ anyio==4.14.2
# anthropic
# google-genai
# httpx
+ # httpx2
# mcp
# openai
# sse-starlette
@@ -101,9 +102,7 @@ defusedxml==0.7.1
# -r desktop/requirements-desktop.txt
# markitdown
deprecated==1.3.1
- # via
- # limits
- # pikepdf
+ # via pikepdf
dingtalk-stream==0.24.3
# via -r desktop/requirements-desktop.txt
distro==1.9.0
@@ -159,6 +158,7 @@ grpcio==1.83.0
h11==0.16.0
# via
# httpcore
+ # httpcore2
# uvicorn
# wsproto
h2==4.4.0
@@ -169,6 +169,8 @@ hpack==4.2.0
# via h2
httpcore==1.0.9
# via httpx
+httpcore2==2.16.0
+ # via httpx2
httpx==0.28.1
# via
# -r desktop/requirements-desktop.txt
@@ -179,15 +181,16 @@ httpx==0.28.1
# huggingface-hub
# lark-oapi
# litellm
- # mcp
# mem0ai
# ollama
# openai
# qdrant-client
# tavily-python
httpx-sse==0.4.3
+ # via dashscope
+httpx2==2.16.0
# via
- # dashscope
+ # agentscope
# mcp
huggingface-hub==1.26.0
# via tokenizers
@@ -199,6 +202,7 @@ idna==3.18
# via
# anyio
# httpx
+ # httpx2
# requests
# yarl
importlib-metadata==8.9.0
@@ -211,7 +215,7 @@ jiter==0.16.0
# via
# anthropic
# openai
-json-repair==0.61.7
+json-repair==0.64.1
# via agentscope
json5==0.15.0
# via agentscope
@@ -219,6 +223,7 @@ jsonschema==4.26.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+ # json-repair
# litellm
# mcp
jsonschema-specifications==2025.9.1
@@ -227,8 +232,6 @@ kiwisolver==1.5.0
# via matplotlib
lark-oapi==1.7.1
# via -r desktop/requirements-desktop.txt
-limits==5.8.0
- # via slowapi
litellm==1.91.4
# via -r desktop/requirements-desktop.txt
lxml==6.1.1
@@ -256,10 +259,12 @@ matplotlib==3.11.1
# via
# -r desktop/requirements-desktop.txt
# seaborn
-mcp==1.29.0
+mcp==2.3.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+mcp-types==2.3.0
+ # via mcp
mdurl==0.1.2
# via markdown-it-py
mem0ai==2.0.14
@@ -302,6 +307,7 @@ openpyxl==3.1.5
opentelemetry-api==1.44.0
# via
# agentscope
+ # mcp
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
@@ -334,7 +340,6 @@ packaging==26.2
# via
# faiss-cpu
# huggingface-hub
- # limits
# matplotlib
# onnxruntime
# pikepdf
@@ -398,17 +403,16 @@ pydantic==2.13.4
# anthropic
# fastapi
# google-genai
+ # json-repair
# litellm
# mcp
+ # mcp-types
# mem0ai
# ollama
# openai
- # pydantic-settings
# qdrant-client
pydantic-core==2.46.4
# via pydantic
-pydantic-settings==2.14.2
- # via mcp
pyee==13.0.1
# via playwright
pygments==2.20.0
@@ -420,7 +424,9 @@ pymilvus==2.5.18
pyparsing==3.3.2
# via matplotlib
pypdf==6.14.2
- # via -r desktop/requirements-desktop.txt
+ # via
+ # -r desktop/requirements-desktop.txt
+ # agentscope
pypdf2==3.0.1
# via -r desktop/requirements-desktop.txt
pypdfium2==5.12.1
@@ -439,7 +445,6 @@ python-dotenv==1.2.2
# -r desktop/requirements-desktop.txt
# litellm
# magika
- # pydantic-settings
# pymilvus
python-engineio==4.13.3
# via python-socketio
@@ -495,10 +500,9 @@ requests-toolbelt==1.0.0
# via lark-oapi
rich==15.0.0
# via
+ # agentscope
# dashscope
# typer
-ripgrep==14.1.0
- # via agentscope
rpds-py==2026.6.3
# via
# jsonschema
@@ -523,8 +527,6 @@ six==1.17.0
# via
# markdownify
# python-dateutil
-slowapi==0.1.10
- # via -r desktop/requirements-desktop.txt
sniffio==1.3.1
# via
# anthropic
@@ -538,7 +540,9 @@ sqlalchemy==2.0.51
# alembic
# mem0ai
sse-starlette==3.0.3
- # via mcp
+ # via
+ # -r desktop/requirements-desktop.txt
+ # mcp
starlette==0.46.2
# via
# fastapi
@@ -568,6 +572,10 @@ tree-sitter==0.26.0
# via agentscope
tree-sitter-bash==0.25.1
# via agentscope
+truststore==0.10.5
+ # via
+ # httpcore2
+ # httpx2
typer==0.27.0
# via dashscope
typing-extensions==4.16.0
@@ -581,9 +589,10 @@ typing-extensions==4.16.0
# fastapi
# google-genai
# grpcio
+ # httpx2
# huggingface-hub
- # limits
# mcp
+ # mcp-types
# openai
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-grpc
@@ -604,7 +613,8 @@ typing-inspection==0.4.2
# via
# mcp
# pydantic
- # pydantic-settings
+tzdata==2026.5
+ # via agentscope
ujson==5.13.0
# via pymilvus
urllib3==2.7.0
diff --git a/desktop/requirements-desktop-windows-py311.lock b/desktop/requirements-desktop-windows-py311.lock
index 36895fe1..48953834 100644
--- a/desktop/requirements-desktop-windows-py311.lock
+++ b/desktop/requirements-desktop-windows-py311.lock
@@ -1,8 +1,8 @@
# This file is generated. Do not edit it by hand.
# Regenerate with: npm --prefix desktop run lock:windows
-# input-sha256: d18da0b813b601a5c727eb280b730ddb3b307fb487ab2a2bb28d4c568a336990
+# input-sha256: b9680ef14192ea099c4a7159c08c064cbd555bd681c00c49c5c74b1f7f3d4046
# Target: CPython 3.11 on Windows x86_64
-agentscope==2.0.0
+agentscope @ https://github.com/agentscope-ai/agentscope/archive/ed357feb78a45ff02df155bb5610eab9bf543832.tar.gz
# via -r desktop/requirements-desktop.txt
aiofiles==25.1.0
# via agentscope
@@ -31,6 +31,7 @@ anyio==4.14.2
# anthropic
# google-genai
# httpx
+ # httpx2
# mcp
# openai
# sse-starlette
@@ -106,8 +107,6 @@ defusedxml==0.7.1
# via
# -r desktop/requirements-desktop.txt
# markitdown
-deprecated==1.3.1
- # via limits
dingtalk-stream==0.24.3
# via -r desktop/requirements-desktop.txt
distro==1.9.0
@@ -163,6 +162,7 @@ grpcio==1.83.0
h11==0.16.0
# via
# httpcore
+ # httpcore2
# uvicorn
# wsproto
h2==4.4.0
@@ -173,6 +173,8 @@ hpack==4.2.0
# via h2
httpcore==1.0.9
# via httpx
+httpcore2==2.16.0
+ # via httpx2
httpx==0.28.1
# via
# -r desktop/requirements-desktop.txt
@@ -183,15 +185,16 @@ httpx==0.28.1
# huggingface-hub
# lark-oapi
# litellm
- # mcp
# mem0ai
# ollama
# openai
# qdrant-client
# tavily-python
httpx-sse==0.4.3
+ # via dashscope
+httpx2==2.16.0
# via
- # dashscope
+ # agentscope
# mcp
huggingface-hub==1.26.0
# via tokenizers
@@ -203,6 +206,7 @@ idna==3.18
# via
# anyio
# httpx
+ # httpx2
# requests
# yarl
importlib-metadata==8.9.0
@@ -215,7 +219,7 @@ jiter==0.16.0
# via
# anthropic
# openai
-json-repair==0.61.7
+json-repair==0.64.1
# via agentscope
json5==0.15.0
# via agentscope
@@ -223,6 +227,7 @@ jsonschema==4.26.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+ # json-repair
# litellm
# mcp
jsonschema-specifications==2025.9.1
@@ -231,8 +236,6 @@ kiwisolver==1.5.0
# via matplotlib
lark-oapi==1.7.1
# via -r desktop/requirements-desktop.txt
-limits==5.8.0
- # via slowapi
litellm==1.94.1
# via -r desktop/requirements-desktop.txt
lxml==6.1.1
@@ -260,10 +263,12 @@ matplotlib==3.11.1
# via
# -r desktop/requirements-desktop.txt
# seaborn
-mcp==1.29.0
+mcp==2.3.0
# via
# -r desktop/requirements-desktop.txt
# agentscope
+mcp-types==2.3.0
+ # via mcp
mdurl==0.1.2
# via markdown-it-py
mem0ai==2.0.14
@@ -304,6 +309,7 @@ openpyxl==3.1.5
opentelemetry-api==1.44.0
# via
# agentscope
+ # mcp
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
@@ -336,7 +342,6 @@ packaging==26.2
# via
# faiss-cpu
# huggingface-hub
- # limits
# matplotlib
# onnxruntime
# pikepdf
@@ -398,17 +403,16 @@ pydantic==2.13.4
# anthropic
# fastapi
# google-genai
+ # json-repair
# litellm
# mcp
+ # mcp-types
# mem0ai
# ollama
# openai
- # pydantic-settings
# qdrant-client
pydantic-core==2.46.4
# via pydantic
-pydantic-settings==2.14.2
- # via mcp
pyee==13.0.1
# via playwright
pygments==2.20.0
@@ -420,7 +424,9 @@ pymilvus==2.5.18
pyparsing==3.3.2
# via matplotlib
pypdf==6.14.2
- # via -r desktop/requirements-desktop.txt
+ # via
+ # -r desktop/requirements-desktop.txt
+ # agentscope
pypdf2==3.0.1
# via -r desktop/requirements-desktop.txt
pypdfium2==5.12.1
@@ -441,7 +447,6 @@ python-dotenv==1.2.2
# -r desktop/requirements-desktop.txt
# litellm
# magika
- # pydantic-settings
# pymilvus
python-engineio==4.13.3
# via python-socketio
@@ -501,10 +506,9 @@ requests-toolbelt==1.0.0
# via lark-oapi
rich==15.0.0
# via
+ # agentscope
# dashscope
# typer
-ripgrep==14.1.0
- # via agentscope
rpds-py==2026.6.3
# via
# jsonschema
@@ -529,8 +533,6 @@ six==1.17.0
# via
# markdownify
# python-dateutil
-slowapi==0.1.10
- # via -r desktop/requirements-desktop.txt
sniffio==1.3.1
# via
# anthropic
@@ -544,7 +546,9 @@ sqlalchemy==2.0.51
# alembic
# mem0ai
sse-starlette==3.0.3
- # via mcp
+ # via
+ # -r desktop/requirements-desktop.txt
+ # mcp
starlette==0.46.2
# via
# fastapi
@@ -574,6 +578,10 @@ tree-sitter==0.26.0
# via agentscope
tree-sitter-bash==0.25.1
# via agentscope
+truststore==0.10.5
+ # via
+ # httpcore2
+ # httpx2
typer==0.27.0
# via dashscope
typing-extensions==4.16.0
@@ -587,9 +595,10 @@ typing-extensions==4.16.0
# fastapi
# google-genai
# grpcio
+ # httpx2
# huggingface-hub
- # limits
# mcp
+ # mcp-types
# openai
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-grpc
@@ -610,9 +619,10 @@ typing-inspection==0.4.2
# via
# mcp
# pydantic
- # pydantic-settings
tzdata==2026.3
- # via pandas
+ # via
+ # agentscope
+ # pandas
ujson==5.13.0
# via pymilvus
urllib3==2.7.0
@@ -636,8 +646,6 @@ werkzeug==3.1.8
# via -r desktop/requirements-desktop.txt
wheel==0.47.0
# via -r desktop/requirements-desktop.txt
-wrapt==2.3.0
- # via deprecated
wsproto==1.3.2
# via simple-websocket
xlsxwriter==3.2.9
diff --git a/desktop/requirements-desktop.txt b/desktop/requirements-desktop.txt
index e2937d7a..9b2d2192 100644
--- a/desktop/requirements-desktop.txt
+++ b/desktop/requirements-desktop.txt
@@ -18,17 +18,19 @@ uv==0.11.33
# Core web service and orchestration.
fastapi==0.115.12
uvicorn>=0.27.0
-pydantic>=2.6.0
-anyio>=4.2.0
+pydantic>=2.12.0
+anyio>=4.9.0
python-dotenv>=1.0.0
requests>=2.31.0
httpx>=0.27.0
jsonschema>=4.20.0
-agentscope==2.0.0
+agentscope @ https://github.com/agentscope-ai/agentscope/archive/ed357feb78a45ff02df155bb5610eab9bf543832.tar.gz
litellm>=1.50.0
google-genai>=0.3.0
ollama>=0.3.0
-mcp>=1.0.0,<2.0.0
+mcp==2.3.0
+# Keep the SSE dependency within FastAPI 0.115 Starlette constraints.
+sse-starlette==3.0.3
tavily-python>=0.5.0
beautifulsoup4>=4.12.0
@@ -40,7 +42,6 @@ redis>=5.0.0
werkzeug>=3.0.0
python-multipart>=0.0.9
structlog>=23.2.0
-slowapi>=0.1.9
argon2-cffi>=23.1.0
croniter>=2.0.0
pytz>=2024.1
diff --git a/desktop/scripts/build-runtime.mjs b/desktop/scripts/build-runtime.mjs
index bb4a1430..dc159fc4 100644
--- a/desktop/scripts/build-runtime.mjs
+++ b/desktop/scripts/build-runtime.mjs
@@ -112,6 +112,9 @@ export function buildDesktopRuntime({ desktopDir, repoRoot, sourceRoot, python }
"--break-system-packages",
"--only-binary",
":all:",
+ // AgentScope is pinned to a reviewed source commit; other packages require wheels.
+ "--no-binary",
+ "agentscope",
join(repoRoot, config.lockFile),
],
{ cwd: repoRoot },
diff --git a/desktop/scripts/desktop-dependencies.test.mjs b/desktop/scripts/desktop-dependencies.test.mjs
index 2f28f373..c6540fbc 100644
--- a/desktop/scripts/desktop-dependencies.test.mjs
+++ b/desktop/scripts/desktop-dependencies.test.mjs
@@ -18,11 +18,26 @@ import {
WINDOWS_DESKTOP_LOCK_FILE,
desktopDependencyFingerprint,
readAndValidateWindowsDesktopLock,
+ readAndValidateDesktopLock,
} from "./desktop-dependencies.mjs";
const desktopDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = resolve(desktopDir, "..");
+function assertExactLock(lock) {
+ const source = readFileSync(join(repoRoot, DESKTOP_REQUIREMENTS_FILE), "utf8")
+ .match(/^agentscope @ https:\/\/github\.com\/agentscope-ai\/agentscope\/archive\/[a-f0-9]{40}\.tar\.gz$/m)?.[0];
+ assert.ok(source, "AgentScope must use an immutable commit archive");
+ assert.ok(readFileSync(join(repoRoot, "requirements.txt"), "utf8").split(/\r?\n/).includes(source));
+ assert.ok(lock.split(/\r?\n/).includes(source));
+ for (const line of lock.split(/\r?\n/)) {
+ if (/^[A-Za-z0-9_.-]+/.test(line) && line !== source) {
+ assert.match(line, /^[A-Za-z0-9_.-]+==[^\s]+$/);
+ }
+ }
+ assert.match(lock, /^mcp==2\.3\.0$/m);
+}
+
function requirementNames(contents) {
return new Set(
contents
@@ -73,14 +88,7 @@ test("Windows Python 3.11 lock is exact and matches its desktop input", () => {
const lock = readAndValidateWindowsDesktopLock(repoRoot);
const names = requirementNames(lock);
assert.ok(names.size > 100, "the transitive desktop lock must be complete");
- for (const line of lock.split(/\r?\n/)) {
- if (/^[A-Za-z0-9_.-]+/.test(line)) {
- assert.match(line, /^[A-Za-z0-9_.-]+==[^\s]+$/);
- }
- }
- const mcpVersion = lock.match(/^mcp==([^\s]+)$/m)?.[1];
- assert.ok(mcpVersion, "the lock must contain mcp");
- assert.equal(Number(mcpVersion.split(".")[0]), 1);
+ assertExactLock(lock);
assert.match(lock, /^pymilvus==2\.5\.18$/m);
assert.match(lock, /^milvus-lite==3\.2\.2$/m);
// Some platform locks pull boto3 through LiteLLM; it is not a storage backend.
@@ -124,14 +132,9 @@ test("desktop dependency hash is independent of checkout line endings", () => {
test("all supported desktop targets have exact Python 3.11 locks", () => {
for (const [target, config] of Object.entries(DESKTOP_TARGETS)) {
- const lock = readFileSync(join(repoRoot, config.lockFile), "utf8");
- assert.match(lock, /# input-sha256: [a-f0-9]{64}/);
+ const lock = readAndValidateDesktopLock(repoRoot, target);
assert.ok(requirementNames(lock).size > 100, `${target} lock is incomplete`);
- for (const line of lock.split(/\r?\n/)) {
- if (/^[A-Za-z0-9_.-]+/.test(line)) {
- assert.match(line, /^[A-Za-z0-9_.-]+==[^\s]+$/);
- }
- }
+ assertExactLock(lock);
}
});
diff --git a/docker-compose.yml b/docker-compose.yml
index c8d35b9b..c6c2cc8e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -73,8 +73,7 @@ services:
BRAND_POWERED_BY: ${BRAND_POWERED_BY:-true}
DATABASE_URL: postgresql://hugagent_user:${DB_PASSWORD:-hugagent_dev_password}@postgres:5432/hugagent
APPLICATION_DATABASE_URL: ${APPLICATION_DATABASE_URL:-}
- DB_POOL_SIZE: ${DB_POOL_SIZE:-100}
- DB_POOL_MAX_OVERFLOW: ${DB_POOL_MAX_OVERFLOW:-20}
+ WORKER_THREADS: ${WORKER_THREADS:-40}
DB_POOL_TIMEOUT: ${DB_POOL_TIMEOUT:-30}
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-2}
ENV: ${ENV:-dev}
@@ -128,8 +127,6 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:5173}
- RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
- RATE_LIMIT_DEFAULT: ${RATE_LIMIT_DEFAULT:-100/minute}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_FORMAT: ${LOG_FORMAT:-json}
LOG_TO_FILE: ${LOG_TO_FILE:-false}
@@ -270,8 +267,7 @@ services:
environment:
TZ: ${TZ:-Asia/Shanghai}
DATABASE_URL: postgresql://hugagent_user:${DB_PASSWORD:-hugagent_dev_password}@postgres:5432/hugagent
- DB_POOL_SIZE: ${DB_POOL_SIZE:-100}
- DB_POOL_MAX_OVERFLOW: ${DB_POOL_MAX_OVERFLOW:-20}
+ WORKER_THREADS: ${WORKER_THREADS:-40}
DB_POOL_TIMEOUT: ${DB_POOL_TIMEOUT:-30}
STORAGE_TYPE: ${STORAGE_TYPE:-s3}
STORAGE_PATH: /app/storage
@@ -369,7 +365,7 @@ services:
- script_runner
build:
context: .
- dockerfile: docker/Dockerfile.script-runner
+ dockerfile: docker/sandbox/Dockerfile.script-runner
container_name: hugagent-script-runner
logging: *id001
environment:
@@ -414,8 +410,8 @@ services:
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:?COMPOSE_PROJECT_NAME must be explicit}
OPENSANDBOX_API_KEY: ${OPENSANDBOX_API_KEY:-}
volumes:
- - ./docker/opensandbox-config.toml.tpl:/tpl/config.toml.tpl:ro
- - ./docker/render-opensandbox-config.py:/tpl/render.py:ro
+ - ./docker/opensandbox-server/opensandbox-config.toml.tpl:/tpl/config.toml.tpl:ro
+ - ./docker/opensandbox-server/render-opensandbox-config.py:/tpl/render.py:ro
- opensandbox_config:/out
command:
- python
@@ -429,7 +425,7 @@ services:
image: hugagent-opensandbox-server:v0.1.13-3
build:
context: .
- dockerfile: docker/Dockerfile.opensandbox-server
+ dockerfile: docker/opensandbox-server/Dockerfile.opensandbox-server
container_name: hugagent-opensandbox
logging: *id001
depends_on:
@@ -438,6 +434,7 @@ services:
environment:
TZ: ${TZ:-Asia/Shanghai}
OPENSANDBOX_INSECURE_SERVER: 'NO'
+ OPENSANDBOX_POOL_MAX_TOTAL: ${OPENSANDBOX_POOL_MAX_TOTAL:-20}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- opensandbox_config:/etc/opensandbox:ro
diff --git a/docker/Dockerfile.opensandbox-server.dockerignore b/docker/Dockerfile.opensandbox-server.dockerignore
deleted file mode 100644
index 2b3b7e78..00000000
--- a/docker/Dockerfile.opensandbox-server.dockerignore
+++ /dev/null
@@ -1,16 +0,0 @@
-**
-!docker/
-!docker/patch_opensandbox_server.py
-!docker/opensandbox_expirations.py
-!docker/opensandbox_mount_worker.py
-!docker/opensandbox_mount_recovery.py
-!docker/opensandbox_object_mounts.py
-!src/
-!src/backend/
-!src/backend/core/
-!src/backend/core/sandbox/
-!src/backend/core/sandbox/sessions/
-!src/backend/core/sandbox/sessions/metadata.py
-!src/backend/tests/
-!src/backend/tests/sandbox/
-!src/backend/tests/sandbox/test_opensandbox_controller_patch.py
diff --git a/docker/Dockerfile.personal-agent-test b/docker/Dockerfile.personal-agent-test
deleted file mode 100644
index d689f1ab..00000000
--- a/docker/Dockerfile.personal-agent-test
+++ /dev/null
@@ -1,6 +0,0 @@
-FROM hugagent-backend:latest
-USER root
-COPY --chown=appuser:appuser src/backend /app/src/backend
-COPY --chown=appuser:appuser alembic.ini /app/alembic.ini
-USER appuser
-CMD ["bash", "/app/src/backend/scripts/backend_entrypoint.sh"]
diff --git a/docker-compose.application-hosting.yml b/docker/application-hosting/compose.application-hosting.yml
similarity index 94%
rename from docker-compose.application-hosting.yml
rename to docker/application-hosting/compose.application-hosting.yml
index 46517f82..3f8c5808 100644
--- a/docker-compose.application-hosting.yml
+++ b/docker/application-hosting/compose.application-hosting.yml
@@ -1,5 +1,5 @@
# Opt-in application data database, separate from the platform postgres service.
-# docker compose -f docker-compose.yml -f docker-compose.application-hosting.yml up -d
+# docker compose -f docker-compose.yml -f docker/application-hosting/compose.application-hosting.yml up -d
services:
application-postgres:
image: postgres:15-alpine
diff --git a/docker/Dockerfile.object-storage b/docker/object-storage/Dockerfile.object-storage
similarity index 100%
rename from docker/Dockerfile.object-storage
rename to docker/object-storage/Dockerfile.object-storage
diff --git a/docker/compose.object-storage.yml b/docker/object-storage/compose.object-storage.yml
similarity index 91%
rename from docker/compose.object-storage.yml
rename to docker/object-storage/compose.object-storage.yml
index b0c46c20..ba0b8334 100644
--- a/docker/compose.object-storage.yml
+++ b/docker/object-storage/compose.object-storage.yml
@@ -4,7 +4,7 @@ services:
image: ${COMPOSE_PROJECT_NAME:-hugagent}-object-storage:RELEASE.2023-03-13T19-46-17Z
build:
context: ..
- dockerfile: docker/Dockerfile.object-storage
+ dockerfile: docker/object-storage/Dockerfile.object-storage
command: ["server", "/data", "--console-address", ":9001"]
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set a random S3_ACCESS_KEY}
@@ -31,7 +31,7 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
volumes:
- - ./docker/init-object-storage.py:/init-object-storage.py:ro
+ - ./docker/object-storage/init-object-storage.py:/init-object-storage.py:ro
depends_on:
object-storage: {condition: service_healthy}
networks: [hugagent-network]
diff --git a/docker/init-object-storage.py b/docker/object-storage/init-object-storage.py
similarity index 100%
rename from docker/init-object-storage.py
rename to docker/object-storage/init-object-storage.py
diff --git a/docker/Dockerfile.opensandbox-server b/docker/opensandbox-server/Dockerfile.opensandbox-server
similarity index 52%
rename from docker/Dockerfile.opensandbox-server
rename to docker/opensandbox-server/Dockerfile.opensandbox-server
index 95b92d71..9ff52a54 100644
--- a/docker/Dockerfile.opensandbox-server
+++ b/docker/opensandbox-server/Dockerfile.opensandbox-server
@@ -2,12 +2,13 @@
FROM opensandbox/server:v0.1.13
USER root
RUN apt-get update && apt-get install -y --no-install-recommends s3fs fuse && rm -rf /var/lib/apt/lists/*
-COPY docker/opensandbox_expirations.py /app/opensandbox_server/services/docker/expirations.py
-COPY docker/opensandbox_mount_worker.py /app/opensandbox_server/services/docker/mount_worker.py
+COPY docker/opensandbox-server/opensandbox_capacity.py /app/opensandbox_server/services/docker/capacity.py
+COPY docker/opensandbox-server/opensandbox_expirations.py /app/opensandbox_server/services/docker/expirations.py
+COPY docker/opensandbox-server/opensandbox_mount_worker.py /app/opensandbox_server/services/docker/mount_worker.py
COPY src/backend/core/sandbox/sessions/metadata.py /app/opensandbox_server/services/docker/sandbox_metadata.py
-COPY docker/opensandbox_mount_recovery.py /app/opensandbox_server/services/docker/mount_recovery.py
-COPY docker/opensandbox_object_mounts.py /app/opensandbox_server/services/docker/object_mounts.py
-COPY docker/patch_opensandbox_server.py /tmp/hugagent-controller/patch.py
+COPY docker/opensandbox-server/opensandbox_mount_recovery.py /app/opensandbox_server/services/docker/mount_recovery.py
+COPY docker/opensandbox-server/opensandbox_object_mounts.py /app/opensandbox_server/services/docker/object_mounts.py
+COPY docker/opensandbox-server/patch_opensandbox_server.py /tmp/hugagent-controller/patch.py
COPY src/backend/tests/sandbox/test_opensandbox_controller_patch.py /tmp/hugagent-controller/test_patch.py
RUN OPENSANDBOX_PATCH_SCRIPT=/tmp/hugagent-controller/patch.py \
OPENSANDBOX_TEST_SOURCE=/app/opensandbox_server \
diff --git a/docker/opensandbox-server/Dockerfile.opensandbox-server.dockerignore b/docker/opensandbox-server/Dockerfile.opensandbox-server.dockerignore
new file mode 100644
index 00000000..9f99abbe
--- /dev/null
+++ b/docker/opensandbox-server/Dockerfile.opensandbox-server.dockerignore
@@ -0,0 +1,18 @@
+**
+!docker/
+!docker/opensandbox-server/
+!docker/opensandbox-server/opensandbox_capacity.py
+!docker/opensandbox-server/patch_opensandbox_server.py
+!docker/opensandbox-server/opensandbox_expirations.py
+!docker/opensandbox-server/opensandbox_mount_worker.py
+!docker/opensandbox-server/opensandbox_mount_recovery.py
+!docker/opensandbox-server/opensandbox_object_mounts.py
+!src/
+!src/backend/
+!src/backend/core/
+!src/backend/core/sandbox/
+!src/backend/core/sandbox/sessions/
+!src/backend/core/sandbox/sessions/metadata.py
+!src/backend/tests/
+!src/backend/tests/sandbox/
+!src/backend/tests/sandbox/test_opensandbox_controller_patch.py
diff --git a/docker/opensandbox-config.toml.tpl b/docker/opensandbox-server/opensandbox-config.toml.tpl
similarity index 100%
rename from docker/opensandbox-config.toml.tpl
rename to docker/opensandbox-server/opensandbox-config.toml.tpl
diff --git a/docker/opensandbox-server/opensandbox_capacity.py b/docker/opensandbox-server/opensandbox_capacity.py
new file mode 100644
index 00000000..8c1c0cbb
--- /dev/null
+++ b/docker/opensandbox-server/opensandbox_capacity.py
@@ -0,0 +1,31 @@
+"""Serialize Docker admission, not sandbox initialization or mount recovery."""
+import fcntl
+import hashlib
+import os
+from contextlib import contextmanager
+from pathlib import Path
+
+from fastapi import HTTPException
+
+
+@contextmanager
+def sandbox_capacity(service):
+ # File locking also fences multiple controller processes sharing the volume.
+ # Docker inventory survives controller/backend restart; no reservation ledger.
+ root = Path("/var/lib/opensandbox")
+ root.mkdir(parents=True, exist_ok=True)
+ namespace = hashlib.sha256(service.network_mode.encode()).hexdigest()[:24]
+ limit = int(os.environ.get("OPENSANDBOX_POOL_MAX_TOTAL", "20"))
+ if limit < 1:
+ raise HTTPException(503, "Sandbox capacity must be positive")
+ with (root / ("capacity-" + namespace + ".lock")).open("a") as lock:
+ try:
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ raise HTTPException(429, "Sandbox creation busy; retry later") from None
+ try:
+ if len(service._owned_sandboxes()) >= limit:
+ raise HTTPException(429, "Deployment sandbox capacity reached")
+ yield
+ finally:
+ fcntl.flock(lock, fcntl.LOCK_UN)
diff --git a/docker/opensandbox_expirations.py b/docker/opensandbox-server/opensandbox_expirations.py
similarity index 100%
rename from docker/opensandbox_expirations.py
rename to docker/opensandbox-server/opensandbox_expirations.py
diff --git a/docker/opensandbox_mount_recovery.py b/docker/opensandbox-server/opensandbox_mount_recovery.py
similarity index 91%
rename from docker/opensandbox_mount_recovery.py
rename to docker/opensandbox-server/opensandbox_mount_recovery.py
index b7efa6b0..09c608a2 100644
--- a/docker/opensandbox_mount_recovery.py
+++ b/docker/opensandbox-server/opensandbox_mount_recovery.py
@@ -55,7 +55,7 @@ def _specifications(service, container, request):
def _healthy(container, specs):
if not specs:
return True
- script = """import json,os,sys
+ script = """import errno,json,os,sys
from pathlib import Path
mounts = {}
for line in Path('/proc/self/mountinfo').read_text().splitlines():
@@ -63,14 +63,23 @@ def _healthy(container, specs):
separator = fields.index('-')
mounts[fields[4]] = (fields[separator + 1], fields[5].split(','))
for target, readonly in json.loads(sys.argv[1]):
+ if target not in mounts:
+ sys.exit(10)
filesystem, options = mounts[target]
- assert filesystem.startswith('fuse'), 'Object filesystem is absent'
- assert ('ro' in options) == readonly, 'Mount access mode differs'
- os.stat(target)
+ if not filesystem.startswith('fuse') or ('ro' in options) != readonly:
+ sys.exit(10)
+ try:
+ os.stat(target)
+ except OSError as exc:
+ sys.exit(10 if exc.errno == errno.ENOTCONN else 11)
"""
result = container.exec_run(["timeout", "4", "python3", "-c", script,
json.dumps([(s["target"], s["read_only"]) for s in specs])], user="0")
- return result.exit_code == 0
+ if result.exit_code == 0:
+ return True
+ if result.exit_code == 10:
+ return False
+ raise HTTPException(503, "Object mount health is uncertain; existing connection retained")
def remove_helper(service, sandbox_id):
from docker.errors import NotFound
@@ -127,7 +136,8 @@ def ensure_mounts(service, sandbox_id, request):
if api.exec_inspect(execution["Id"])["ExitCode"] != 0 or not _healthy(container, specs):
raise HTTPException(503, "Object mount recovery failed; workspace retained")
except Exception:
- remove_helper(service, sandbox_id)
+ # The helper may own live writes even if verification timed out.
+ # A later confirmed-broken probe can replace it safely.
raise
return {"epoch": EPOCH, "recovered": True}
diff --git a/docker/opensandbox_mount_worker.py b/docker/opensandbox-server/opensandbox_mount_worker.py
similarity index 100%
rename from docker/opensandbox_mount_worker.py
rename to docker/opensandbox-server/opensandbox_mount_worker.py
diff --git a/docker/opensandbox_object_mounts.py b/docker/opensandbox-server/opensandbox_object_mounts.py
similarity index 100%
rename from docker/opensandbox_object_mounts.py
rename to docker/opensandbox-server/opensandbox_object_mounts.py
diff --git a/docker/patch_opensandbox_server.py b/docker/opensandbox-server/patch_opensandbox_server.py
similarity index 96%
rename from docker/patch_opensandbox_server.py
rename to docker/opensandbox-server/patch_opensandbox_server.py
index 5460b21a..9bec849e 100644
--- a/docker/patch_opensandbox_server.py
+++ b/docker/opensandbox-server/patch_opensandbox_server.py
@@ -110,6 +110,8 @@ def patched_sources(root: Path) -> dict[str, str]:
" async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:\n"
" return await run_in_threadpool(self._create_sandbox_sync, request)\n\n"
" def _create_sandbox_sync(self, request: CreateSandboxRequest) -> CreateSandboxResponse:\n")
+ source = replace_once(source, "import inspect\n",
+ "import inspect\nfrom .capacity import sandbox_capacity\n")
sources[name] = source
name = "api/lifecycle.py"
@@ -152,6 +154,15 @@ def patched_sources(root: Path) -> dict[str, str]:
patch_shared_lifecycle(sources)
patch_durable_expirations(sources)
patch_namespace_mounts(sources)
+ name = "services/docker/docker_service.py"
+ source = sources[name]
+ start = source.index(" created_container = self._create_and_start_container(")
+ end = source.index("\n )", start) + len("\n )")
+ block = source[start:end]
+ source = (source[:start] + " with sandbox_capacity(self):\n"
+ + "\n".join(" " + line for line in block.splitlines()) + source[end:])
+ sources[name] = source
+
name = "services/docker/docker_service.py"
sources[name] = replace_once(sources[name], "import inspect\n",
"import inspect\nfrom .object_mounts import install as install_object_mounts\ninstall_object_mounts()\n")
diff --git a/docker/render-opensandbox-config.py b/docker/opensandbox-server/render-opensandbox-config.py
similarity index 100%
rename from docker/render-opensandbox-config.py
rename to docker/opensandbox-server/render-opensandbox-config.py
diff --git a/docker/Dockerfile.personal-agent b/docker/personal-agent/Dockerfile.personal-agent
similarity index 90%
rename from docker/Dockerfile.personal-agent
rename to docker/personal-agent/Dockerfile.personal-agent
index 5a70f2c7..b9106b79 100644
--- a/docker/Dockerfile.personal-agent
+++ b/docker/personal-agent/Dockerfile.personal-agent
@@ -9,7 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends xfce4-terminal
# Live screen for the owner: VNC on a private socket, behind a token-checked WebSocket.
RUN apt-get update && apt-get install -y --no-install-recommends x11vnc && rm -rf /var/lib/apt/lists/*
# A browser launcher with the flags this container needs, registered as the default browser.
-COPY docker/personal-agent-desktop/ /opt/personal-agent/desktop/
+COPY docker/personal-agent/desktop/ /opt/personal-agent/desktop/
RUN install -m 755 /opt/personal-agent/desktop/pa-browser /usr/local/bin/pa-browser \
&& install -m 644 /opt/personal-agent/desktop/pa-browser.desktop /usr/share/applications/pa-browser.desktop \
&& install -d /usr/share/xfce4/helpers \
@@ -18,12 +18,12 @@ RUN install -m 755 /opt/personal-agent/desktop/pa-browser /usr/local/bin/pa-brow
&& install -m 755 /opt/personal-agent/desktop/pa-stream.py /usr/local/bin/pa-stream
RUN useradd --create-home --uid 1001 --shell /bin/bash pa-desktop && chmod 700 /home/pa-desktop && mkdir -p /opt/personal-agent
COPY src/backend/personal_agent/device_link/driver.py /opt/personal-agent/computer-driver.py
-COPY docker/personal-agent-entrypoint.sh /opt/personal-agent/start.sh
+COPY docker/personal-agent/personal-agent-entrypoint.sh /opt/personal-agent/start.sh
RUN chmod 755 /opt/personal-agent/start.sh /opt/personal-agent/computer-driver.py
RUN find / -xdev -type f -perm /6000 -exec chmod a-s {} + 2>/dev/null && rm -f /etc/sudoers.d/ubuntu
# Lightweight client only. The cloud computer does not run another backend.
COPY src/backend/hugagent_cli /opt/hugagent-cli/hugagent_cli
-COPY docker/hugagent-cli-entrypoint.sh /usr/local/bin/hugagent
+COPY docker/personal-agent/hugagent-cli-entrypoint.sh /usr/local/bin/hugagent
RUN chmod 755 /usr/local/bin/hugagent
diff --git a/docker/compose.personal-agent.yml b/docker/personal-agent/compose.personal-agent.yml
similarity index 91%
rename from docker/compose.personal-agent.yml
rename to docker/personal-agent/compose.personal-agent.yml
index 86b8962a..d0afeb85 100644
--- a/docker/compose.personal-agent.yml
+++ b/docker/personal-agent/compose.personal-agent.yml
@@ -14,14 +14,14 @@ services:
PERSONAL_AGENT_OPENSANDBOX_API_KEY: ${PERSONAL_AGENT_OPENSANDBOX_API_KEY:?Set a private controller key}
PERSONAL_AGENT_COMPUTE_NETWORK: ${COMPOSE_PROJECT_NAME:-hugagent}_personal-agent-compute
volumes:
- - ./docker/render-personal-agent-config.py:/render.py:ro
+ - ./docker/personal-agent/render-personal-agent-config.py:/render.py:ro
- personal_agent_config:/out
command: ["python", "/render.py"]
personal-agent-opensandbox:
image: hugagent-opensandbox-server:v0.1.13-1
build:
context: .
- dockerfile: docker/Dockerfile.opensandbox-server
+ dockerfile: docker/opensandbox-server/Dockerfile.opensandbox-server
profiles: [personal-agent]
restart: unless-stopped
depends_on:
diff --git a/docker/personal-agent-desktop/helpers.rc b/docker/personal-agent/desktop/helpers.rc
similarity index 100%
rename from docker/personal-agent-desktop/helpers.rc
rename to docker/personal-agent/desktop/helpers.rc
diff --git a/docker/personal-agent-desktop/mimeapps.list b/docker/personal-agent/desktop/mimeapps.list
similarity index 100%
rename from docker/personal-agent-desktop/mimeapps.list
rename to docker/personal-agent/desktop/mimeapps.list
diff --git a/docker/personal-agent-desktop/pa-browser b/docker/personal-agent/desktop/pa-browser
similarity index 100%
rename from docker/personal-agent-desktop/pa-browser
rename to docker/personal-agent/desktop/pa-browser
diff --git a/docker/personal-agent-desktop/pa-browser-helper.desktop b/docker/personal-agent/desktop/pa-browser-helper.desktop
similarity index 100%
rename from docker/personal-agent-desktop/pa-browser-helper.desktop
rename to docker/personal-agent/desktop/pa-browser-helper.desktop
diff --git a/docker/personal-agent-desktop/pa-browser.desktop b/docker/personal-agent/desktop/pa-browser.desktop
similarity index 100%
rename from docker/personal-agent-desktop/pa-browser.desktop
rename to docker/personal-agent/desktop/pa-browser.desktop
diff --git a/docker/personal-agent-desktop/pa-stream.py b/docker/personal-agent/desktop/pa-stream.py
similarity index 100%
rename from docker/personal-agent-desktop/pa-stream.py
rename to docker/personal-agent/desktop/pa-stream.py
diff --git a/docker/hugagent-cli-entrypoint.sh b/docker/personal-agent/hugagent-cli-entrypoint.sh
similarity index 100%
rename from docker/hugagent-cli-entrypoint.sh
rename to docker/personal-agent/hugagent-cli-entrypoint.sh
diff --git a/docker/personal-agent-entrypoint.sh b/docker/personal-agent/personal-agent-entrypoint.sh
similarity index 100%
rename from docker/personal-agent-entrypoint.sh
rename to docker/personal-agent/personal-agent-entrypoint.sh
diff --git a/docker/render-personal-agent-config.py b/docker/personal-agent/render-personal-agent-config.py
similarity index 100%
rename from docker/render-personal-agent-config.py
rename to docker/personal-agent/render-personal-agent-config.py
diff --git a/docker/Dockerfile.opensandbox b/docker/sandbox/Dockerfile.opensandbox
similarity index 95%
rename from docker/Dockerfile.opensandbox
rename to docker/sandbox/Dockerfile.opensandbox
index b84926ab..82960bfd 100644
--- a/docker/Dockerfile.opensandbox
+++ b/docker/sandbox/Dockerfile.opensandbox
@@ -5,7 +5,7 @@
#
# 构建:
# docker build -t hugagent-opensandbox-custom:latest \
-# -f Dockerfile.opensandbox .
+# -f docker/sandbox/Dockerfile.opensandbox .
#
# 启用:在 .env 中设置
# OPENSANDBOX_IMAGE=hugagent-opensandbox-custom:latest
@@ -69,7 +69,7 @@ RUN /opt/node/v22.2.0/bin/npm install -g @larksuite/cli@1.0.78 @larksuite/whiteb
RUN /opt/node/v22.2.0/bin/npm install -g openyida@2026.7.12-1
# ─── Python 数据/文档/网络全家桶 ───────────────────────────────────
-COPY docker/requirements-script-runner.txt /tmp/req-script-runner.txt
+COPY docker/sandbox/requirements-script-runner.txt /tmp/req-script-runner.txt
# Interactive package workers use FastAPI/uvicorn inside the sandbox.
RUN $PY_BIN -m pip install --no-cache-dir -r /tmp/req-script-runner.txt \
&& rm /tmp/req-script-runner.txt
@@ -77,7 +77,7 @@ RUN $PY_BIN -m pip install --no-cache-dir -r /tmp/req-script-runner.txt \
# Skill 依赖(由 backend admin UI 触发 rebuild 前聚合写入 requirements-skills.txt)。
# 依赖**只**在此 bake 进镜像;沙盒启动不再跑 pip。新增依赖须重新构建本镜像
# (管理台 → 沙盒依赖 → 重建)。/opt/skill-deps/ 副本仅留作排查参考。
-COPY docker/requirements-skills.txt /opt/skill-deps/requirements-skills.txt
+COPY docker/sandbox/requirements-skills.txt /opt/skill-deps/requirements-skills.txt
RUN $PY_BIN -m pip install --no-cache-dir -r /opt/skill-deps/requirements-skills.txt || \
(echo "[opensandbox] skill-deps install failed; continuing with empty set"; true)
@@ -141,17 +141,17 @@ RUN HIMALAYA_ARCH="$([ "${TARGETARCH:-amd64}" = "arm64" ] && echo aarch64 || ech
# 目录、node_modules 硬链接物化到 /workspace 临时区(秒级、离线、不进 myspace)。
# 构建期跑一次 smoke build:模板坏了在镜像构建阶段立刻暴露,而不是用户对话里。
# manifest 单独成层先 npm ci:模板源码微调不再击穿依赖层缓存(lock 不变即命中)
-COPY docker/site-template/react-vite/package.json \
- docker/site-template/react-vite/package-lock.json \
- docker/site-template/react-vite/.npmrc \
+COPY docker/sandbox/site-template/react-vite/package.json \
+ docker/sandbox/site-template/react-vite/package-lock.json \
+ docker/sandbox/site-template/react-vite/.npmrc \
/opt/site-template/react-vite/
RUN cd /opt/site-template/react-vite \
&& export PATH=/opt/node/v22.2.0/bin:$PATH \
&& npm ci --no-audit --no-fund \
&& npm cache clean --force \
&& rm -rf /root/.npm
-COPY docker/site-template/init-react-site.sh /opt/site-template/init-react-site.sh
-COPY docker/site-template/react-vite/ /opt/site-template/react-vite/
+COPY docker/sandbox/site-template/init-react-site.sh /opt/site-template/init-react-site.sh
+COPY docker/sandbox/site-template/react-vite/ /opt/site-template/react-vite/
RUN cd /opt/site-template/react-vite \
&& export PATH=/opt/node/v22.2.0/bin:$PATH \
&& SITE_DIST=/tmp/site-smoke SITE_CACHE=/tmp/site-cache npm run build \
@@ -221,5 +221,5 @@ RUN mkdir -p /home/ubuntu/.local /home/ubuntu/.jupyter /home/ubuntu/.npm \
done
# Real launch detects driver/browser revision mismatches before delivery.
-COPY docker/verify-browser-runtime.py /opt/verify-browser-runtime.py
+COPY docker/sandbox/verify-browser-runtime.py /opt/verify-browser-runtime.py
RUN runuser -u ubuntu -- $PY_BIN /opt/verify-browser-runtime.py --container-isolated
diff --git a/docker/Dockerfile.script-runner b/docker/sandbox/Dockerfile.script-runner
similarity index 96%
rename from docker/Dockerfile.script-runner
rename to docker/sandbox/Dockerfile.script-runner
index 40f0103a..f921e735 100644
--- a/docker/Dockerfile.script-runner
+++ b/docker/sandbox/Dockerfile.script-runner
@@ -4,7 +4,7 @@
#
# 构建:
# docker build -t hugagent-script-runner:latest \
-# -f Dockerfile.script-runner .
+# -f docker/sandbox/Dockerfile.script-runner .
# 由 docker-compose 在 SANDBOX_PROVIDER=script_runner(COMPOSE_PROFILES=script_runner)
# 时自动构建启动。
@@ -77,13 +77,13 @@ RUN HIMALAYA_ARCH="$([ "${TARGETARCH:-amd64}" = "arm64" ] && echo aarch64 || ech
fi && rm -f /tmp/himalaya.tgz
# Python deps
-COPY docker/requirements-script-runner.txt /tmp/
+COPY docker/sandbox/requirements-script-runner.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/requirements-script-runner.txt \
&& rm /tmp/requirements-script-runner.txt
# Skill 依赖(由 backend admin UI 触发 rebuild 前聚合写入 requirements-skills.txt)。
# 文件可能为空(默认占位),pip 接受空 requirements,不会失败。
-COPY docker/requirements-skills.txt /opt/skill-deps/requirements-skills.txt
+COPY docker/sandbox/requirements-skills.txt /opt/skill-deps/requirements-skills.txt
RUN pip install --no-cache-dir -r /opt/skill-deps/requirements-skills.txt || \
(echo "[script-runner] skill-deps install failed; continuing with empty set"; true)
@@ -141,5 +141,5 @@ EXPOSE 8900
CMD ["python", "-m", "uvicorn", "core.sandbox.runner.server:app", "--host", "0.0.0.0", "--port", "8900"]
# Real launch detects driver/browser revision mismatches before delivery.
-COPY docker/verify-browser-runtime.py /opt/verify-browser-runtime.py
+COPY docker/sandbox/verify-browser-runtime.py /opt/verify-browser-runtime.py
RUN python3 /opt/verify-browser-runtime.py --container-isolated
diff --git a/docker/apt-skills.txt b/docker/sandbox/apt-skills.txt
similarity index 100%
rename from docker/apt-skills.txt
rename to docker/sandbox/apt-skills.txt
diff --git a/docker/package-skills.json b/docker/sandbox/package-skills.json
similarity index 100%
rename from docker/package-skills.json
rename to docker/sandbox/package-skills.json
diff --git a/docker/requirements-script-runner.txt b/docker/sandbox/requirements-script-runner.txt
similarity index 100%
rename from docker/requirements-script-runner.txt
rename to docker/sandbox/requirements-script-runner.txt
diff --git a/docker/requirements-skills.txt b/docker/sandbox/requirements-skills.txt
similarity index 100%
rename from docker/requirements-skills.txt
rename to docker/sandbox/requirements-skills.txt
diff --git a/docker/site-template/init-react-site.sh b/docker/sandbox/site-template/init-react-site.sh
similarity index 100%
rename from docker/site-template/init-react-site.sh
rename to docker/sandbox/site-template/init-react-site.sh
diff --git a/docker/site-template/react-vite/.npmrc b/docker/sandbox/site-template/react-vite/.npmrc
similarity index 100%
rename from docker/site-template/react-vite/.npmrc
rename to docker/sandbox/site-template/react-vite/.npmrc
diff --git a/docker/site-template/react-vite/index.html b/docker/sandbox/site-template/react-vite/index.html
similarity index 100%
rename from docker/site-template/react-vite/index.html
rename to docker/sandbox/site-template/react-vite/index.html
diff --git a/docker/site-template/react-vite/package-lock.json b/docker/sandbox/site-template/react-vite/package-lock.json
similarity index 100%
rename from docker/site-template/react-vite/package-lock.json
rename to docker/sandbox/site-template/react-vite/package-lock.json
diff --git a/docker/site-template/react-vite/package.json b/docker/sandbox/site-template/react-vite/package.json
similarity index 100%
rename from docker/site-template/react-vite/package.json
rename to docker/sandbox/site-template/react-vite/package.json
diff --git a/docker/site-template/react-vite/src/App.jsx b/docker/sandbox/site-template/react-vite/src/App.jsx
similarity index 100%
rename from docker/site-template/react-vite/src/App.jsx
rename to docker/sandbox/site-template/react-vite/src/App.jsx
diff --git a/docker/site-template/react-vite/src/index.css b/docker/sandbox/site-template/react-vite/src/index.css
similarity index 100%
rename from docker/site-template/react-vite/src/index.css
rename to docker/sandbox/site-template/react-vite/src/index.css
diff --git a/docker/site-template/react-vite/src/lib/siteApi.js b/docker/sandbox/site-template/react-vite/src/lib/siteApi.js
similarity index 100%
rename from docker/site-template/react-vite/src/lib/siteApi.js
rename to docker/sandbox/site-template/react-vite/src/lib/siteApi.js
diff --git a/docker/site-template/react-vite/src/main.jsx b/docker/sandbox/site-template/react-vite/src/main.jsx
similarity index 100%
rename from docker/site-template/react-vite/src/main.jsx
rename to docker/sandbox/site-template/react-vite/src/main.jsx
diff --git a/docker/site-template/react-vite/src/pages/Home.jsx b/docker/sandbox/site-template/react-vite/src/pages/Home.jsx
similarity index 100%
rename from docker/site-template/react-vite/src/pages/Home.jsx
rename to docker/sandbox/site-template/react-vite/src/pages/Home.jsx
diff --git a/docker/site-template/react-vite/vite.config.mjs b/docker/sandbox/site-template/react-vite/vite.config.mjs
similarity index 100%
rename from docker/site-template/react-vite/vite.config.mjs
rename to docker/sandbox/site-template/react-vite/vite.config.mjs
diff --git a/docker/verify-browser-runtime.py b/docker/sandbox/verify-browser-runtime.py
similarity index 100%
rename from docker/verify-browser-runtime.py
rename to docker/sandbox/verify-browser-runtime.py
diff --git a/document/README.md b/document/README.md
index 90158198..f06d0e45 100644
--- a/document/README.md
+++ b/document/README.md
@@ -1,10 +1,10 @@
# HugAgentOS 文档 / Documentation
-> 最后更新:2026-07-21
+> 最后更新 / Last updated:2026-10-23
-HugAgentOS 是一个企业级 AI Agent 平台(FastAPI + React + AgentScope 2.0),以 open-core 模式提供 **开源社区版(CE)** 与 **商业版(EE)** 双形态。产品文档按语言分树维护:
+HugAgentOS 是一个开源、可自托管的 Agent Harness:你提出的问题在云端持续执行,关掉电脑也不会中断;在此之上提供 7×24 小时在线的 Personal Agent,并以 HugAgentOS Cloud(服务器)和 HugAgentOS Local(桌面本机)两种形态运行,以 open-core 模式提供 **开源社区版(CE)** 与 **商业版(EE)**。产品文档按语言分树维护:
-HugAgentOS is an enterprise-grade AI agent platform (FastAPI + React + AgentScope 2.0), shipped in an open-core model as a **Community Edition (CE)** and an **Enterprise Edition (EE)**. Product documentation is maintained per language:
+HugAgentOS is an open-source, self-hosted agent harness: your request keeps running in the cloud even after you close your computer. On top of it, HugAgentOS provides an always-on Personal Agent and runs as HugAgentOS Cloud (on your server) or HugAgentOS Local (on your desktop), shipped in an open-core model as a **Community Edition (CE)** and an **Enterprise Edition (EE)**. Product documentation is maintained per language:
| 语言 / Language | 入口 / Entry |
|---|---|
@@ -19,14 +19,13 @@ HugAgentOS is an enterprise-grade AI agent platform (FastAPI + React + AgentScop
document/
├── zh-CN/ · en/ # 双语产品文档(镜像)
│ ├── getting-started/ # 产品简介、快速开始、领域本体快速入门
-│ ├── deployment/ # 部署总览(选型) + 无 Docker 一键安装 / Docker Compose / 离线生产 / Windows(WSL2) / 环境变量参考
+│ ├── deployment/ # 部署总览、Docker Compose、桌面本机模式、一键安装、Windows / macOS / UOS、环境变量
│ ├── architecture/ # 总体架构、后端、前端、数据模型
-│ ├── modules/ # 17 个功能模块文档(对话/提示词/能力目录/模型/
-│ │ # MCP 工具/技能/沙箱/记忆/知识库/存储/项目空间/
-│ │ # 认证/管理台/自动化/画布与产物)
-│ ├── api/ # API 总览(信封/鉴权/SSE/路由清单)、错误码
+│ ├── modules/ # 功能模块:对话与 Run、Personal Agent、自主循环、后台作业、MCP、技能、沙箱、
+│ │ # 记忆、本体治理、知识库、站点与应用托管、存储、项目空间、认证、管理台等
+│ ├── api/ # API 总览(信封 / 鉴权 / SSE / 路由清单)、错误码
│ ├── editions/ # 社区版 vs 商业版、License 机制、CE 构建管线
-│ └── development/ # 后端 / 前端开发指南
+│ └── development/ # 后端 / 前端开发、界面定制、工作台(Workbench)协议与 SDK
└── internal/ # ⚠️ 内部文档(仅主仓,不随社区版分发)
├── agents/ # 开发工作流约定(development-flow / issue-tracker /
│ # triage-labels / domain),被 .agents/skills 与 CLAUDE.md 引用
@@ -44,151 +43,13 @@ document/
`zh-CN/`, `en/`, and this index are **public product docs**, shipped with the Community Edition derived tree; EE-only capabilities are explicitly labeled. `internal/` holds **internal documents** (business strategy, split blueprints, design drafts) and is excluded from the CE tree via `ce/manifest.yaml` — put any new internal document there.
-云文档在线编辑:Markdown、TXT 和代码文件的编辑、保存、权限与版本冲突处理见
-[我的空间](zh-CN/modules/projects-myspace.md#云文档在线编辑) /
-[Online cloud document editing](en/modules/projects-myspace.md#online-cloud-document-editing)。
+## 常用专题 / Frequently referenced topics
-工作台协议 / Workbench protocol:
-[中文接入说明](zh-CN/development/workbench-protocol.md) /
-[English integration guide](en/development/workbench-protocol.md)。
-
-普通外部应用:[中文](zh-CN/development/workbench-external-apps.md) · [English](en/development/workbench-external-apps.md)。
-
-工作台目录与布局策略:[中文](zh-CN/development/workbench-access-policies.md) · [English](en/development/workbench-access-policies.md)。
-
-工作台导航偏好:[中文](zh-CN/development/workbench-navigation-preferences.md) · [English](en/development/workbench-navigation-preferences.md)。
-
-独立 SDK 与插件页面:[中文](zh-CN/development/workbench-sdk.md) · [English](en/development/workbench-sdk.md)。
-
-工作台独立工程与权限预览:[中文](zh-CN/development/workbench-preview.md) · [English](en/development/workbench-preview.md)。
-工作台管理员指南:[中文](zh-CN/development/workbench-administration.md) · [English](en/development/workbench-administration.md)。
-工作台错误码:[中文](zh-CN/development/workbench-errors.md) · [English](en/development/workbench-errors.md)。
-工作台兼容与迁移:[中文](zh-CN/development/workbench-migration.md) · [English](en/development/workbench-migration.md)。
-
-工作台目录通知与撤销:[中文](zh-CN/development/workbench-invalidation.md) · [English](en/development/workbench-invalidation.md)。
-
-- [工作台构建与交付](zh-CN/development/workbench-build.md)
-
-- [workbench-sdk](en/development/workbench-sdk.md)
-
-- [workbench-access-policies](en/development/workbench-access-policies.md)
-
-- [workbench-external-apps](en/development/workbench-external-apps.md)
-
-- [workbench-plugin-upgrades](en/development/workbench-plugin-upgrades.md)
-
-- [workbench-protocol](en/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](en/development/workbench-navigation-preferences.md)
-
-- [workbench-sdk](zh-CN/development/workbench-sdk.md)
-
-- [workbench-access-policies](zh-CN/development/workbench-access-policies.md)
-
-- [workbench-external-apps](zh-CN/development/workbench-external-apps.md)
-
-- [workbench-plugin-upgrades](zh-CN/development/workbench-plugin-upgrades.md)
-
-- [workbench-protocol](zh-CN/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](zh-CN/development/workbench-navigation-preferences.md)
-
-- [workbench-origins](en/development/workbench-origins.md)
-
-- [workbench-sdk](en/development/workbench-sdk.md)
-
-- [workbench-access-policies](en/development/workbench-access-policies.md)
-
-- [workbench-external-apps](en/development/workbench-external-apps.md)
-
-- [workbench-protocol](en/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](en/development/workbench-navigation-preferences.md)
-
-- [workbench-origins](zh-CN/development/workbench-origins.md)
-
-- [workbench-sdk](zh-CN/development/workbench-sdk.md)
-
-- [workbench-access-policies](zh-CN/development/workbench-access-policies.md)
-
-- [workbench-external-apps](zh-CN/development/workbench-external-apps.md)
-
-- [workbench-protocol](zh-CN/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](zh-CN/development/workbench-navigation-preferences.md)
-
-- [workbench-sdk](en/development/workbench-sdk.md)
-
-- [workbench-platform-adapters](en/development/workbench-platform-adapters.md)
-
-- [workbench-access-policies](en/development/workbench-access-policies.md)
-
-- [workbench-external-apps](en/development/workbench-external-apps.md)
-
-- [workbench-protocol](en/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](en/development/workbench-navigation-preferences.md)
-
-- [workbench-sdk](zh-CN/development/workbench-sdk.md)
-
-- [workbench-platform-adapters](zh-CN/development/workbench-platform-adapters.md)
-
-- [workbench-access-policies](zh-CN/development/workbench-access-policies.md)
-
-- [workbench-external-apps](zh-CN/development/workbench-external-apps.md)
-
-- [workbench-protocol](zh-CN/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](zh-CN/development/workbench-navigation-preferences.md)
-
-- [workbench-sdk](en/development/workbench-sdk.md)
-
-- [workbench-access-policies](en/development/workbench-access-policies.md)
-
-- [workbench-external-apps](en/development/workbench-external-apps.md)
-
-- [workbench-activity](en/development/workbench-activity.md)
-
-- [workbench-protocol](en/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](en/development/workbench-navigation-preferences.md)
-
-- [workbench-sdk](zh-CN/development/workbench-sdk.md)
-
-- [workbench-access-policies](zh-CN/development/workbench-access-policies.md)
-
-- [workbench-external-apps](zh-CN/development/workbench-external-apps.md)
-
-- [workbench-activity](zh-CN/development/workbench-activity.md)
-
-- [workbench-protocol](zh-CN/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](zh-CN/development/workbench-navigation-preferences.md)
-
-- [workbench-sdk](en/development/workbench-sdk.md)
-
-- [workbench-access-policies](en/development/workbench-access-policies.md)
-
-- [workbench-external-apps](en/development/workbench-external-apps.md)
-
-- [workbench-diagnostics](en/development/workbench-diagnostics.md)
-
-- [workbench-protocol](en/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](en/development/workbench-navigation-preferences.md)
-
-- [workbench-sdk](zh-CN/development/workbench-sdk.md)
-
-- [workbench-access-policies](zh-CN/development/workbench-access-policies.md)
-
-- [workbench-external-apps](zh-CN/development/workbench-external-apps.md)
-
-- [workbench-diagnostics](zh-CN/development/workbench-diagnostics.md)
-
-- [workbench-protocol](zh-CN/development/workbench-protocol.md)
-
-- [workbench-navigation-preferences](zh-CN/development/workbench-navigation-preferences.md)
-
-项目与智能体独立对话链接 / Standalone project and agent chat links:[中文](zh-CN/modules/chat.md#项目和智能体的独立对话链接) · [English](en/modules/chat.md#standalone-chat-links-for-projects-and-agents)。
-
-模块页面滚动与应用中心:[中文](zh-CN/architecture/frontend.md#模块页面滚动与应用中心) · [English](en/architecture/frontend.md#module-page-scrolling-and-app-center)。
+| 专题 / Topic | 中文 | English |
+|---|---|---|
+| Personal Agent | [Personal Agent](zh-CN/modules/personal-agent.md) | [Personal Agent](en/modules/personal-agent.md) |
+| 桌面本机与混合模式 / Desktop local and hybrid modes | [桌面离线本机模式](zh-CN/deployment/desktop-local-mode.md) | [Desktop local mode](en/deployment/desktop-local-mode.md) |
+| 云文档在线编辑 / Online cloud document editing | [我的空间](zh-CN/modules/projects-myspace.md#云文档在线编辑) | [Projects & My Space](en/modules/projects-myspace.md#online-cloud-document-editing) |
+| 项目与智能体独立对话链接 / Standalone chat links | [对话](zh-CN/modules/chat.md#项目和智能体的独立对话链接) | [Chat](en/modules/chat.md#standalone-chat-links-for-projects-and-agents) |
+| 模块页面滚动与应用中心 / Module scrolling and app center | [前端架构](zh-CN/architecture/frontend.md#模块页面滚动与应用中心) | [Frontend](en/architecture/frontend.md#module-page-scrolling-and-app-center) |
+| 工作台协议与 SDK / Workbench protocol and SDK | [工作台文档](zh-CN/README.md#开发指南) | [Workbench docs](en/README.md#development) |
diff --git a/document/en/README.md b/document/en/README.md
index b2d3aa13..6c80cf3b 100644
--- a/document/en/README.md
+++ b/document/en/README.md
@@ -1,15 +1,19 @@
# HugAgentOS Documentation
-> Last updated: 2026-07-21 | [简体中文](../zh-CN/README.md)
+> Last updated: 2026-10-23 | [简体中文](../zh-CN/README.md)
-HugAgentOS is an enterprise-grade AI agent platform: a FastAPI backend, a React frontend, and an AgentScope 2.0 agent runtime, fully containerized with Docker and shipped in an open-core model as a **Community Edition (CE)** and an **Enterprise Edition (EE)**. This tree is the complete product documentation for operators, users, and integrators.
+HugAgentOS is an open-source, self-hosted agent harness: your request keeps running in the cloud
+even after you close your computer. On top of it, HugAgentOS provides an always-on Personal Agent
+and runs as HugAgentOS Cloud (on your server) or HugAgentOS Local (on your desktop). It ships as an
+open-source Community Edition (CE) and an Enterprise Edition (EE). This tree is the complete
+product documentation for operators, users, and integrators.
## Getting Started
| Document | Description |
|------|------|
-| [Introduction](getting-started/introduction.md) | What it is, feature overview, CE/EE at a glance, tech stack and architecture |
-| [Quick Start](getting-started/quick-start.md) | Run HugAgentOS with the one-command installer or Docker Compose |
+| [Introduction](getting-started/introduction.md) | The durable agent, runtime forms, feature overview, tech stack and architecture |
+| [Quick Start](getting-started/quick-start.md) | Choose HugAgentOS Cloud, the desktop client or the one-command installer, and enable Personal Agent |
| [Build a Domain Ontology Quickly](getting-started/domain-ontology-quickstart.md) | Validate, publish, test, and bind tools with a minimal Domain Pack |
## Deployment
@@ -19,42 +23,74 @@ Start with the [Deployment Guide (choosing a method)](deployment/README.md), the
| Document | Description |
|------|------|
| [Deployment Guide · Overview](deployment/README.md) | Comparison and selection of deployment methods, post-deployment verification |
-| [No-Docker Quick Install](deployment/quick-install.md) | Zero-dependency single machine: one command, SQLite + in-process event log/ephemeral state + subprocess MCP/sandbox |
-| [Docker Compose Deployment](deployment/docker-compose.md) | Team/production standard: full service topology, profiles, rebuild workflows, database migrations |
-| [Offline Production Deployment](deployment/offline-production.md) | Isolated environments: image tarball packaging / production-side loading / prompt snapshot migration (Enterprise Edition) |
-| [Windows Deployment](deployment/windows-deployment.md) | Docker Desktop / WSL2 deltas, line endings, paths and sandbox limitations |
+| [Docker Compose Deployment](deployment/docker-compose.md) | The standard HugAgentOS Cloud setup: service topology, profiles, rebuild workflows, database migrations |
+| [Desktop Local Mode](deployment/desktop-local-mode.md) | HugAgentOS Local: private runtime, local-only / cloud-only / local + cloud, upgrades and rollback |
+| [Local Project Workspace](deployment/local-project-workspace.md) | Execution directory and permission rules for bound local projects |
+| [Local and Cloud Skills and Plugins](deployment/local-capability-management.md) | Where skills and plugins come from and how they are managed in hybrid mode |
+| [No-Docker Quick Install](deployment/quick-install.md) | Single machine: SQLite + in-process state + subprocess MCP / sandbox |
+| [Windows Deployment](deployment/windows-deployment.md) | Desktop-hosted single-machine service or Docker Desktop + WSL2 |
+| [macOS Signing and Upgrades](deployment/macos-signing.md) | Desktop signing, notarization and upgrades |
+| [My Space File Mounts](deployment/space-files.md) | The file directories shared by the web app and sandboxes |
| [Environment Variables](deployment/environment-variables.md) | Complete variable reference (defaults / purpose / CE·EE relevance) |
## Architecture
| Document | Description |
|------|------|
-| [Overview](architecture/overview.md) | Layered architecture, full request lifecycle, container topology, key design decisions |
-| [Backend](architecture/backend.md) | Full `src/backend/` walkthrough: api / orchestration / 15 core submodules / router registry |
-| [Frontend](architecture/frontend.md) | Five app entries, component groups, Zustand stores, hooks, build chain |
-| [Data Model](architecture/data-model.md) | 76 tables by domain, dual alembic chains, CE/EE table boundary |
+| [Overview](architecture/overview.md) | Layered architecture, ontology control plane, full request lifecycle, container topology |
+| [Backend](architecture/backend.md) | `src/backend/` walkthrough and router registry |
+| [Frontend](architecture/frontend.md) | App entries, component groups, Zustand stores, module pages and the app center |
+| [Data Model](architecture/data-model.md) | Tables by domain, migration chains, CE/EE table boundary |
## Modules
+**Harness and long-running work**
+
+| Document | Description |
+|------|------|
+| [Chat & Orchestration](modules/chat.md) | Runs decoupled from connections, stream resume, mid-run steering, per-step context restore and compaction, plan mode, sub-agents |
+| [Personal Agent](modules/personal-agent.md) | The 24/7 personal agent: background tasks, permanent cloud computer, schedules and heartbeats, IM channels, approvals, file memory and Dreaming |
+| [Autonomous Loop](modules/autonomous-loop.md) | Goals that survive across turns, maker ≠ checker, stall guards, resumable runs |
+| [Workflow Background Jobs](modules/workflow-jobs.md) | Managed sandbox processes, lease takeover, derived main agents |
+| [Automation & Plan Mode](modules/automation.md) | Scheduled tasks and plan mode |
+
+**Capabilities and extensions**
+
| Document | Description |
|------|------|
-| [Chat & Orchestration](modules/chat.md) | End-to-end chat flow, SSE events, citations, plan mode, sub-agents, stream resume |
-| [Prompt System](modules/prompts.md) | Assembly precedence, version pool, prompt hub, cross-environment migration |
+| [MCP Tool System](modules/mcp-tools.md) | Built-in and plugin servers, connection pooling, custom MCP |
+| [Agent Skills](modules/agent-skills.md) | SKILL.md mechanics, bundle layering, skill marketplace |
+| [Browser Automation Plugin](modules/browser-automation.md) | Browse pages, operate forms and test web pages |
| [Capability Catalog](modules/catalog.md) | Catalog as single source of truth, capability gating, self-service capabilities |
-| [Model Providers](modules/model-providers.md) | Provider & role system, dynamic switching, personal API keys, billing |
-| [MCP Tool System](modules/mcp-tools.md) | Built-in and plugin servers, connection pooling, admin/user custom MCP |
-| [Agent Skills](modules/agent-skills.md) | SKILL.md mechanics, bundle layering, skill marketplace, distillation |
-| [Sandbox Execution](modules/sandbox.md) | Three providers, bash tools, snapshot persistence, MySpace bind-mount |
-| [Memory System](modules/memory.md) | L1/L2/L3 layered memory, sanitization, audit, mem0 infrastructure |
-| [Domain Ontology Governance and Validation](modules/ontology-harness.md) | CE Settings governance, Domain Packs, tool gates, pre-delivery review, and governed evolution |
-| [Knowledge Base](modules/knowledge-base.md) | Self-hosted KB (hybrid retrieval) and Dify integration, public KB admin |
-| [Knowledge Base Wiki](modules/knowledge-base-wiki.md) | LLM-extracted entities and concepts as interlinked pages and a concept graph |
-| [Object Storage](modules/storage.md) | local / s3 / oss backends, artifact store, file pipelines |
-| [Projects & My Space](modules/projects-myspace.md) | Project workspaces, personal/team folders, files into context |
-| [Authentication & Permissions](modules/auth.md) | Three AUTH_MODEs, SSO, permission bits, admin credentials |
-| [Admin Consoles](modules/admin-console.md) | The /admin and /config consoles, 19 admin route groups |
-| [Automation & Batch](modules/automation.md) | Scheduled tasks, plan mode, batch orchestration |
+| [Sandbox Execution](modules/sandbox.md) | Providers, Bash tools, snapshot persistence, My Space mounts |
+| [Model Providers](modules/model-providers.md) | Provider and role system, dynamic switching, personal API keys |
+| [Prompt System](modules/prompts.md) | Assembly precedence, version pool, cross-environment migration |
+
+**Knowledge, memory and governance**
+
+| Document | Description |
+|------|------|
+| [Memory System](modules/memory.md) | L1/L2/L3 layered memory, durable outbox, procedural learning, sanitization and audit |
+| [Domain Ontology Governance and Validation](modules/ontology-harness.md) | Domain Packs, tool gates, pre-delivery review, human review of evolution drafts |
+| [Knowledge Base](modules/knowledge-base.md) | Self-hosted knowledge bases (hybrid retrieval) and external sources |
+| [Knowledge Base Wiki](modules/knowledge-base-wiki.md) | Extracted entities and concepts as interlinked pages and a concept graph |
+
+**Workspaces and artifacts**
+
+| Document | Description |
+|------|------|
+| [Projects & My Space](modules/projects-myspace.md) | Project workspaces, personal folders, online cloud document editing, files into context |
| [Canvas & Artifacts](modules/canvas-artifacts.md) | Univer canvas, code artifacts, artifact center, chat sharing |
+| [Sites](modules/sites.md) | Build sites in chat and host them on the platform |
+| [Application Databases and MCP Hosting](modules/application-hosting.md) | Separate application databases, form data and MCP publishing |
+| [Object Storage](modules/storage.md) | Local and S3-compatible storage, artifact store, file pipelines |
+
+**Platform administration**
+
+| Document | Description |
+|------|------|
+| [Authentication & Permissions](modules/auth.md) | AUTH_MODE, SSO, permission bits, admin credentials |
+| [Admin Consoles](modules/admin-console.md) | The /admin and /config consoles |
## API Reference
@@ -67,21 +103,40 @@ Start with the [Deployment Guide (choosing a method)](deployment/README.md), the
| Document | Description |
|------|------|
-| [Community vs Enterprise](editions/overview.md) | Open-core model, feature matrix, three runtime modes, upgrade paths |
-| [License Mechanism](editions/license.md) | Offline signature verification, state machine, feature enforcement, issuing tool (EE) |
+| [Community vs Enterprise](editions/overview.md) | Open-core model, feature matrix, runtime forms, upgrade paths |
+| [License Mechanism](editions/license.md) | Offline signature verification, state machine, feature enforcement (EE) |
| [CE Build Pipeline](editions/build-ce.md) | Manifest, build_ce.py pipeline, overlay, acceptance gates |
## Development
| Document | Description |
|------|------|
-| [Backend Development](development/backend.md) | In-Docker dev model, tests, migrations, layering conventions, adding routes/MCP/skills |
-| [Frontend Development](development/frontend.md) | Build hot-swap, directory conventions, API call conventions, edition gating |
-
----
-
-Internal design and planning documents (CE/EE split blueprints, sandbox snapshot design, etc.) live in the [docs root](../README.md).
+| [Backend Development](development/backend.md) | Dev model, tests, migrations, layering conventions, adding routes / MCP / skills |
+| [Frontend Development](development/frontend.md) | Builds, directory conventions, API call conventions, edition gating |
+| [Chat UI Customization](development/frontend-customization.md) | Page configuration, customer component packages and standalone frontends |
-- [Browser automation plugin](modules/browser-automation.md)
+**Workbench applications**
-- [Personal Agent](modules/personal-agent.md)
+| Document | Description |
+|------|------|
+| [Workbench Protocol](development/workbench-protocol.md) | JSON Schema, protocol versions and integration |
+| [Workbench SDK and Plugin Pages](development/workbench-sdk.md) | SDK 1.0.0 and UI v3 plugin pages |
+| [Development Preview](development/workbench-preview.md) | Standalone project and permission preview |
+| [Build and Offline Artifacts](development/workbench-build.md) | Building and delivering the protocol, SDK and host frontend |
+| [Routing and Plugin Navigation](development/workbench-routing.md) | `/apps/` URLs and applicationKey |
+| [Application Resources](development/workbench-resources.md) | In-package resource runtime |
+| [UI State and Bounded Keep-alive](development/workbench-state.md) | State schema versions, save and restore |
+| [Badges, Notifications and Background Work](development/workbench-activity.md) | badge, notification and background capabilities |
+| [Platform Adapters](development/workbench-platform-adapters.md) | Web, Tauri and Electron adapter interfaces |
+| [Origins and Offline Installation](development/workbench-origins.md) | Entries for same-name local and cloud plugins |
+| [Plugin Upgrades and Permission Approval](development/workbench-plugin-upgrades.md) | Upgrades, reinstalls and inherited grants |
+| [Catalog and Layout Policies](development/workbench-access-policies.md) | Catalog API, availability and default entries |
+| [Module Navigation and App Catalog](development/workbench-navigation-preferences.md) | Sidebar modules and the app center |
+| [Catalog Notifications and Revocation](development/workbench-invalidation.md) | ETags and catalog invalidation |
+| [External Applications](development/workbench-external-apps.md) | Managing and using external link applications |
+| [External Application Authentication](development/workbench-external-auth.md) | Authentication setup for external applications (EE console) |
+| [External Page Embedding](development/workbench-external-embedding.md) | Origins and readiness for embedded pages |
+| [Application Diagnostics](development/workbench-diagnostics.md) | Diagnostic IDs and retries |
+| [Error Codes](development/workbench-errors.md) | Workbench error codes |
+| [Administrator Guide](development/workbench-administration.md) | Workbench administration |
+| [Compatibility and Migration](development/workbench-migration.md) | Version compatibility and migration |
diff --git a/document/en/api/overview.md b/document/en/api/overview.md
index c29d2b93..dff5828d 100644
--- a/document/en/api/overview.md
+++ b/document/en/api/overview.md
@@ -65,7 +65,7 @@ The backend runs several parallel authentication mechanisms, each covering a dif
| **CONFIG_TOKEN** | `Authorization: Bearer ` | `/config` console backend (`/v1/config/*`, `/v1/models`, `/v1/service-configs`, some `/v1/admin/*` observability endpoints) | `require_config` (env var `CONFIG_TOKEN`) |
| **ADMIN or CONFIG (either)** | Same as above | Prompt snapshot export/import (`/v1/content/prompts/export|import`) | `require_admin_or_config` |
| **super_admin session** | Session cookie (user with `extra_data.role == "super_admin"`); a valid ADMIN_TOKEN works as fallback | A few cross-user management operations | `require_super_admin` |
-| **BACKEND_INTERNAL_TOKEN** | `Authorization: Bearer ` | Internal site, browser and security-tool service calls; requests are rejected when unset | `api/routes/v1/internal_site_auth.py`, `internal_security.py` |
+| **BACKEND_INTERNAL_TOKEN** | `Authorization: Bearer ` | Internal site, browser and security-tool service calls; requests are rejected when unset | `features/sites/api/internal_site_auth.py`, `internal_security.py` |
Resolution order in `get_current_user`: **cookie session → API-key Bearer (`sk-jx-` prefix) → mock/remote Bearer**. A Bearer that looks like an API key but fails validation gets an immediate 401 — it is never silently downgraded to anonymous.
@@ -120,7 +120,7 @@ curl -X POST 'https:///api/v1/agents/responses' \
## SSE Streaming Protocol
-The streaming chat endpoint `POST /v1/agents/responses` (`src/backend/api/routes/v1/agent_responses.py`, `stream: true`) starts a background run after validation, then follows that run over SSE; after a disconnect, the client can resume from any offset via `GET /v1/chats/stream/{run_id}`. Events are produced by `src/backend/orchestration/workflow.py` and serialized to the wire by `src/backend/orchestration/chat_run_executor.py`.
+The streaming chat endpoint `POST /v1/agents/responses` (`src/backend/api/routes/v1/agent_responses.py`, `stream: true`) starts a background run after validation, then follows that run over SSE; after a disconnect, the client can resume from any offset via `GET /v1/chats/stream/{run_id}`. Events are produced by `src/backend/core/agent_runtime/runner.py` and serialized to the wire by `src/backend/core/chat/runtime/`.
**Wire format**: `Content-Type: text/event-stream`; each event is one `data: {JSON}` line with the event type in the JSON `type` field (the SSE `event:` line is not used). The stream terminates with `data: [DONE]`. After 15 seconds of silence, an SSE comment line `: heartbeat` keeps reverse-proxy connections alive.
@@ -197,7 +197,7 @@ All endpoints use the current user identity and verify chat ownership. Pending r
The answer count, question IDs, and order must exactly match the request. Private option IDs must come from the pending response, a single-select answer cannot contain multiple options, and every question must be answered or explicitly use `skipped=true`. The backend projects those private IDs back to the original option labels before returning the DSH-compatible model result `{"answers":[{"id":"scope","selected":["Minimal"]}]}`. The first valid answer/cancel atomically claims the request; concurrent submissions return `stale=true` and cannot wake the tool twice. A successful POST represents that atomic server claim, so the submitting tab may remove the exact `request_id` immediately; `user_question_resolved` and periodic authoritative pending snapshots synchronize other tabs and disconnected clients.
-`[ref:tool_name-N]` markers in the answer text are parsed into `citations` items by `orchestration/citations.py`; the frontend renders them as citation badges (see [Chat module](../modules/chat.md)).
+`[ref:tool_name-N]` markers in the answer text are parsed into `citations` items by `core/llm/citations/`; the frontend renders them as citation badges (see [Chat module](../modules/chat.md)).
### Resume, Steer, and cancel
diff --git a/document/en/architecture/backend.md b/document/en/architecture/backend.md
index 2f0f382b..18029b3a 100644
--- a/document/en/architecture/backend.md
+++ b/document/en/architecture/backend.md
@@ -145,9 +145,9 @@ The runner depends only on process and path primitives that do not load backend
|---|---|
| `core/chat` | Workflow context assembly (`context.py`), SSE tool-log event construction (`tool_log.py`) |
| `core/files/content` | Attachment parsing (`file_parser.py`), KB document chunking/keywords/vectorization (`kb_processing.py`), upload validation (`file_validation.py`), artifact reading and summaries (`artifact_reader/refs/summary.py`), content-block import/export (`content_blocks.py`), `svg_fit.py` |
-| `core/kb` / `edition_ee/kb` | Shared private-KB parsing and Milvus vector storage stay in `core/kb`; the Dify client and external retrieval adapter live only in `edition_ee/kb` (EE) |
+| `features/knowledge` / `edition_ee/kb` | Shared private-KB parsing and Milvus vector storage stay in `features/knowledge`; the Dify client and external retrieval adapter live only in `edition_ee/kb` (EE) |
| `core/files/artifacts` | Artifact store `store.py`: local / OSS dual mode |
-| `core/infra` | Unified responses (`responses.py`), exceptions (`exceptions.py`), structured logging (`logging.py`), rate limiting (`rate_limit.py`), Redis singleton (`redis.py`), metrics (`metrics.py`), background-task registry (`runtime_state.py`), data masking (`data_masking.py`), distillation budget gates (`distillation_budget.py`, Enterprise Edition, EE) |
+| `core/infra` | Unified responses (`responses.py`), exceptions (`exceptions.py`), structured logging (`logging.py`), Redis singleton (`redis.py`), metrics (`metrics.py`), background-task registry (`runtime_state.py`), data masking (`data_masking.py`), distillation budget gates (`distillation_budget.py`, Enterprise Edition, EE) |
| `edition_ee/licensing` | Feature enums, signature verification, clock guard, seat policy, middleware, and the manager all exist only in EE; the CE tree has no License package and gets a fixed edition probe from its `api/middleware/edition.py` overlay |
| `core/files/storage` | Storage protocol `protocol.py` + factory `factory.py`; `local.py` (CE), `s3.py` / `oss.py` (Enterprise Edition, EE) |
@@ -219,8 +219,8 @@ CE routes live under `api/routes/v1/`; physically split commercial routes live u
| App entry and startup hooks | `src/backend/api/app.py` |
| Router registry | `src/backend/api/routes/v1/__init__.py` |
| Agent factory | `src/backend/core/llm/factory/build.py` |
-| Run executor / workflow | `src/backend/orchestration/chat_run_executor.py`, `workflow.py` |
-| Capability catalog | `src/backend/core/config/catalog.py` |
+| Run executor / workflow | `src/backend/core/chat/runtime/`, `workflow.py` |
+| Capability catalog | `src/backend/core/capabilities/catalog/` |
| Sandbox protocol | `src/backend/core/sandbox/protocol.py` |
| Memory pipeline | `src/backend/core/memory/pipeline.py` |
| EE License implementation / CE edition middleware | `src/backend/edition_ee/licensing/`, `src/backend/api/middleware/edition.py` |
diff --git a/document/en/architecture/data-model.md b/document/en/architecture/data-model.md
index 9d29d477..95f89d71 100644
--- a/document/en/architecture/data-model.md
+++ b/document/en/architecture/data-model.md
@@ -179,10 +179,10 @@ A few tables that *look* EE but are required by CE are deliberately excluded fro
|---|---|
| Shared ORM / EE ORM | `src/backend/core/db/models/`, `src/backend/edition_ee/db/models/` |
| Ontology repository | `src/backend/core/db/repository/ontology.py` |
-| Feedback model / repository | `src/backend/edition_ee/db/models/feedback.py`, `db/repository/feedback.py` |
+| Feedback model / repository | `src/backend/edition_ee/db/models/feedback.py`, `edition_ee/db/repository/feedback.py` |
| Engine and startup table creation | `src/backend/core/db/engine.py` |
| Repository layer | `src/backend/core/db/repository/` |
-| CE/EE table boundary | `src/backend/core/db/edition_tables.py` |
+| CE/EE table boundary | `src/backend/edition_ee/db/edition_tables.py` |
| EE migration chain | `src/backend/alembic/versions/` |
| CE migration baseline | `ce/overlay/src/backend/alembic/versions/ce_0001_initial.py` |
| Prompt version pool service | `src/backend/core/llm/prompt_versions.py` |
diff --git a/document/en/architecture/overview.md b/document/en/architecture/overview.md
index 6c3cd86c..3fc350d6 100644
--- a/document/en/architecture/overview.md
+++ b/document/en/architecture/overview.md
@@ -1,8 +1,8 @@
# Architecture Overview
-> Last updated: 2026-07-19
+> Last updated: 2026-10-23
-HugAgentOS is an enterprise-grade AgentOS with both a lightweight local profile and a fully containerized production profile. A React single-page application provides the user experience, an async FastAPI service runs the backend, AgentScope 2.0's ReActAgent supplies the agent runtime, independent MCP (Model Context Protocol) processes expose tools, and pluggable sandbox providers isolate code execution. The platform ships in two editions — an open-source Community Edition (CE) and a commercial Enterprise Edition (EE) — built from a single codebase and separated by three runtime seams: the router registry, the table-creation boundary, and license feature gates (see [Editions](../editions/overview.md)).
+HugAgentOS is an open-source, self-hosted agent harness that runs as HugAgentOS Cloud (a fully containerized server) or HugAgentOS Local (on the desktop). A React single-page application provides the user experience, an async FastAPI service runs the backend, AgentScope 2.0's ReActAgent supplies the agent runtime, independent MCP (Model Context Protocol) processes expose tools, and pluggable sandbox providers isolate code execution. The platform ships in two editions — an open-source Community Edition (CE) and a commercial Enterprise Edition (EE) — built from a single codebase and separated by three runtime seams: the router registry, the table-creation boundary, and license feature gates (see [Editions](../editions/overview.md)).
This page describes the system from five angles: the layered architecture, the ontology-grounded enterprise trust plane, the full lifecycle of one chat request, the container topology, and the key design decisions. For deeper dives, see [Backend Architecture](./backend.md), [Frontend Architecture](./frontend.md), and the [Data Model](./data-model.md).
@@ -23,51 +23,43 @@ This page describes the system from five angles: the layered architecture, the o
```
┌────────────────────────────────────────────────────────────────────┐
-│ Browser (React SPA) │
-│ App.tsx main app · AdminApp content console (EE) │
-│ ConfigApp system console (EE) │
-│ api.ts typed client + useStreaming SSE parser │
+│ Entry: web (React SPA) · desktop (Tauri) · IM channels · API · │
+│ schedules · heartbeats │
└──────────────────────────────┬─────────────────────────────────────┘
- │ HTTP / SSE (under /api)
+ │ HTTP / SSE (/api prefix)
┌──────────────────────────────▼─────────────────────────────────────┐
-│ Nginx (frontend container, :80) │
-│ Static assets · /api → backend reverse proxy │
+│ FastAPI (src/backend/api/) │
+│ Middleware: CORS · structured logging · error handling · │
+│ edition router registry │
└──────────────────────────────┬─────────────────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────────────────┐
-│ FastAPI (backend container, src/backend/api/) │
-│ Middleware: CORS · structured logging · unified error handling │
-│ Router registry: CE_ROUTERS + EE_ROUTERS (license feature gates) │
+│ Run layer │
+│ core/runs: persisted runs, database leases, startup reconciliation,│
+│ tool effect intents and receipts │
+│ core/chat/runtime: run admission, chat worker, resume, steering, │
+│ compaction │
└──────────────────────────────┬─────────────────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────────────────┐
-│ Orchestration layer (src/backend/orchestration/) │
-│ chat_run_executor: decouples Runs from HTTP, resumable streams │
-│ workflow: streaming orchestration · strategy: routing │
-│ citations: reference extraction · memory_integration │
-│ subagents/plan_mode · schedulers: automation + distillation (EE) │
-└──────────────────────────────┬─────────────────────────────────────┘
- │
-┌──────────────────────────────▼─────────────────────────────────────┐
-│ Agent layer (src/backend/core/llm/) │
-│ factory/build → AgentScope 2.0 ReActAgent │
-│ ToolCollector → Toolkit (built-in tools + skills + MCP tools) │
-│ middlewares: dynamic model / file context / system reminders │
-│ offloader: oversized tool results spilled to sandbox │
-│ prompts/prompt_runtime: system prompt assembly (DB-first) │
+│ Agent execution │
+│ core/agent_runtime: runner.astream_chat_workflow, routing, │
+│ memory / ontology / evolution integration │
+│ core/llm: factory/build → AgentScope ReActAgent, tools, context │
+│ orchestration/: plans · loops (autonomous loop) · automations │
+│ personal_agent/: identity, task queue, scheduling, channels, │
+│ device links, workspace │
└───────┬──────────────────────┬──────────────────────┬──────────────┘
- │ streamable-http │ sandbox protocol │ SQL / vectors
+ │ streamable-http │ sandbox provider │ SQL / vector
┌───────▼───────────┐ ┌────────▼──────────┐ ┌─────────▼─────────────┐
-│ MCP tool pool │ │ Sandboxes │ │ Data layer │
-│ mcp container, │ │ script-runner(CE) │ │ PostgreSQL · Redis │
-│ tool processes │ │ OpenSandbox CE/EE │ │ Milvus · Neo4j (opt.) │
-│ see _ports.py │ │ CubeSandbox (EE) │ │ Local / S3 / OSS │
-│ search/fetch/tools │ │ │ │ storage │
-│ plugins/dwh/chain(EE)│ │ │ │ │
+│ MCP tool pool │ │ Sandbox and jobs │ │ Data layer │
+│ mcp processes │ │ OpenSandbox │ │ PostgreSQL / SQLite │
+│ ports: _ports.py │ │ script-runner │ │ Redis · Milvus · Neo4j│
+│ plugin tools │ │ core/jobs │ │ S3-compatible / local │
└───────────────────┘ └───────────────────┘ └───────────────────────┘
```
-Dependencies point strictly downward: `api → orchestration → core`, and within `core`, `services → db`. MCP servers are independent processes that talk to the backend exclusively over HTTP, so a crash on either side never takes down the other.
+Dependencies point strictly downward: `api → features / orchestration / personal_agent → core`; `core` never depends on upper layers, and `integrations/` holds connectors to external systems such as DingTalk, Feishu and email. MCP servers are independent processes that talk to the backend exclusively over HTTP, so a crash on either side never takes down the other.
## Ontology-Grounded Enterprise Trust Plane
@@ -150,21 +142,22 @@ The entry point is `POST /v1/agents/responses` (`src/backend/api/routes/v1/chats
### 2. Starting a Run (decoupled from HTTP)
-`orchestration/chat_run_executor.py::start_run` registers the AI workflow as a background Run (persisted to the `chat_runs` table); the HTTP response merely *follows* that Run:
+`core/chat/runtime/admission.py::start_run` registers the turn as a background Run (persisted to the `chat_runs` table), executed by a chat worker that holds a database lease; the HTTP response merely *follows* that Run:
-- After a dropped connection or page refresh, the client calls `GET /v1/chats/stream/{run_id}` to resume from a given offset;
-- On backend restart, the lifespan hook `_startup_recover_chat_runs` recovers or reaps unfinished Runs.
+- After a dropped connection or page refresh, the client calls `GET /v1/chats/stream/{run_id}?from=N` to resume from a given offset;
+- While running, `POST /v1/chat-runs/{run_id}/steer` adds an instruction that applies at the next safe ReAct boundary;
+- On startup, `core/chat/runtime/recovery.py::recover_orphan_runs` reconciles Runs whose lease has expired from their last committed safe point: recoverable ones continue, unrecoverable ones are closed explicitly.
### 3. Orchestration and agent execution
-`orchestration/workflow.py::astream_chat_workflow` drives a single turn:
+`core/agent_runtime/runner.py::astream_chat_workflow` drives a single turn:
-1. **Memory retrieval** — `orchestration/memory_integration.py` reads the L1 profile / L2 vector memories (when enabled) and injects them as a system message;
-2. **Routing decision** — `orchestration/strategy.py`; `ROUTER_STRATEGY=main_only` (default) always routes to the main agent;
+1. **Memory retrieval** — `core/agent_runtime/integrations/memory.py` reads the L1 profile / L2 vector memories (when enabled) and injects them as a system message;
+2. **Routing decision** — `core/agent_runtime/strategy.py`; `ROUTER_STRATEGY=main_only` (default) always routes to the main agent;
3. **Agent construction** — `core/llm/factory/build.py::create_agent_executor`: assembles the prompt (`prompts/prompt_runtime.py`, DB version pool first), filters MCP servers by catalog and per-user capabilities, registers built-in tools (Read/Write/Edit/Glob/Grep/bash/skill loading, see `core/llm/tools/`), then constructs the AgentScope Toolkit in one shot;
-4. **Streaming execution** — `orchestration/streaming.py::StreamingAgent` wraps the ReActAgent and yields reasoning deltas, tool calls, and tool results as they happen;
-5. **Citation extraction** — `orchestration/citations.py` parses `[ref:tool_name-N]` markers out of tool results into citation items;
-6. **Wrap-up** — the assistant message and tool-call logs are persisted, `core/memory/pipeline.py` extracts memories asynchronously in the background (off the SSE hot path), and follow-up questions are generated by a separate endpoint (`orchestration/followups.py`).
+4. **Streaming execution** — `core/llm/runtime/streaming.py::StreamingAgent` wraps the ReActAgent and yields reasoning deltas, tool calls, and tool results as they happen;
+5. **Citation extraction** — `core/llm/citations/` parses `[ref:tool_name-N]` markers out of tool results into citation items;
+6. **Wrap-up** — the assistant message and tool-call logs are persisted, `core/memory/pipeline.py` extracts memories asynchronously in the background (off the SSE hot path), and follow-up questions are generated by a separate endpoint (`core/chat/followups.py`).
### 4. The SSE event stream
@@ -177,9 +170,9 @@ Each event is `data: {json}\n\n` with a `type` discriminator; the stream termina
| `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_result` | Tool execution result (with citations, artifact card payloads) | `core/chat/tool_log.py` + `core/chat/tool_payloads.py` |
| `tool_progress` | Progress reports from long-running tools | `chats.py` |
-| `meta` | Trailing metadata: route, citation sources, artifact list, etc. | `orchestration/workflow.py` |
+| `meta` | Trailing metadata: route, citation sources, artifact list, etc. | `core/agent_runtime/runner.py` |
| `error` | Error event (immediately followed by `[DONE]`) | `chats.py` |
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`.
@@ -192,13 +185,14 @@ The frontend's `src/frontend/src/hooks/useStreaming.ts` dispatches on `type` and
|---|---|---|---|
| `frontend` | hugagent-frontend | `3002:80` | Nginx: static assets + `/api` reverse proxy |
| `backend` | hugagent-backend | `3001` | FastAPI main service |
-| `mcp` | hugagent-mcp | dedicated internal ports | 9 MCP servers as long-running streamable-http processes (7 general CE tools plus 2 EE industry tools) |
+| `mcp` | hugagent-mcp | dedicated internal ports | Built-in MCP servers as long-running streamable-http processes (data warehouse and industry tools are EE) |
| `postgres` | hugagent-postgres | `5432` | Primary database |
| `redis` | hugagent-redis | `6379` | Sessions / caching |
-| `script-runner` (profile `script_runner`) | hugagent-script-runner | internal | Lightweight script-execution sidecar (no DB/secret access) |
-| `opensandbox` + `opensandbox-config-init` (profile `opensandbox`) | hugagent-opensandbox | internal | Persistent sandbox (Enterprise Edition, EE) |
+| `script-runner` (profile `script_runner`) | hugagent-script-runner | internal | Optional lightweight script-execution sidecar (no DB/secret access) |
+| `opensandbox` + `opensandbox-config-init` (profile `opensandbox`) | hugagent-opensandbox | internal | Default sandbox: persistent sessions and snapshot recovery (mutually exclusive with script-runner) |
| `etcd` / `minio` / `milvus` (profile `mem0`) | hugagent-etcd etc. | internal | L2 vector-memory infrastructure |
| `neo4j` (profile `mem0`) | hugagent-neo4j | `7474/7687` | L3 graph memory (optional) |
+| `personal-agent-opensandbox` (profile `personal-agent`, `docker/personal-agent/compose.personal-agent.yml`) | — | internal | Dedicated controller for Personal Agent permanent computers |
All services share the `hugagent-network` network; named volumes such as `postgres_data` persist across rebuilds. See [Docker Compose deployment](../deployment/docker-compose.md) for details.
@@ -206,7 +200,7 @@ All services share the `hugagent-network` network; named volumes such as `postgr
### Catalog as the single source of truth
-`src/backend/core/config/catalog.json` is the sole authority for capability switches, with four kinds of entries: `skills` / `agents` / `mcp` / `kb`. `core/config/catalog.py` exposes `is_enabled(kind, id)` / `get_enabled_ids(kind)`, gating everything from MCP tool loading and skill registration to routing decisions; admin toggles are stored as overrides in the `catalog_overrides` table layered on top of the file (`core/config/catalog_loader.py`). Adding a capability to the catalog makes it visible to both the frontend capability center and the agent factory — no orchestration changes required. See [Capability Catalog](../modules/catalog.md).
+`src/backend/core/config/catalog.json` is the sole authority for capability switches, with four kinds of entries: `skills` / `agents` / `mcp` / `kb`. `core/capabilities/catalog/` exposes `is_enabled(kind, id)` / `get_enabled_ids(kind)`, gating everything from MCP tool loading and skill registration to routing decisions; admin toggles are stored as overrides in the `catalog_overrides` table layered on top of the file (`core/capabilities/catalog/loader.py`). Adding a capability to the catalog makes it visible to both the frontend capability center and the agent factory — no orchestration changes required. See [Capability Catalog](../modules/catalog.md).
### Unified response envelope
@@ -218,15 +212,15 @@ System prompts are no longer hardcoded: the version pool is stored in the `conte
### MCP tools as independent processes
-Each tool is a standalone MCP server under `src/backend/extensions/mcp_servers//`, all running inside the dedicated `mcp` container as long-lived streamable-http listeners (the port table in `extensions/mcp_servers/_ports.py` is the single source of truth). The backend connects through the HTTP client pool in `core/llm/mcp/manager.py`. Benefits: a tool crash never affects the main process, document editing and export have moved into sandbox skills, and the tool tier can scale independently. CE keeps 7 general-purpose servers; the two industry tools, `query_database` and `ai_chain_information_mcp`, depend on intranet data sources and belong to the Enterprise Edition (EE). See [MCP Tools](../modules/mcp-tools.md).
+Each tool is a standalone MCP server under `src/backend/extensions/mcp_servers//`, all running inside the dedicated `mcp` container as long-lived streamable-http listeners (the port table in `extensions/mcp_servers/_ports.py` is the single source of truth). The backend connects through the run-owned native client in `core/llm/mcp/lifetime.py`. Benefits: a tool crash never affects the main process, document editing and export have moved into sandbox skills, and the tool tier can scale independently. The two industry tools, `query_database` and `ai_chain_information_mcp`, depend on intranet data sources and belong to the Enterprise Edition (EE). See [MCP Tools](../modules/mcp-tools.md).
-### Runs decoupled from connections
+### Runs decoupled from connections and processes
-Streaming chats are not tied to the HTTP connection lifecycle: `chat_run_executor` persists execution state to the `chat_runs` table and supports offset-based resumption — eliminating "refresh loses the reply" and flaky-network drops — and the same execution channel is reused by the automation scheduler.
+Streaming chats are tied neither to the HTTP connection nor to a particular process: run state and per-step context are persisted, workers claim execution through database leases, and a stale executor can no longer commit results or call tools; when a process dies, another worker takes over. Tool calls record an intent and complete only with a receipt (`core/runs/tool_effects/`); recovery replays only calls proven not to have taken effect, and external operations with an unknown outcome go to the user for confirmation. Automation, Plan Mode, the autonomous loop and Personal Agent reuse the same run machinery.
### One codebase, two editions
-The commercial main repository carries the full code; the Community Edition is derived by `scripts/build_ce.py` according to `ce/manifest.yaml` (exclude `edition_ee` implementations + text transforms + CE overlays). Runtime boundaries include the router registry `api/routes/v1/__init__.py`, the table-creation boundary `core/db/edition_tables.py` (CE never registers the 20 EE-only tables), EE's `edition_ee/licensing/` package versus CE's fixed edition middleware, and policy seams such as site visibility. See [Editions](../editions/overview.md).
+The commercial main repository carries the full code; the Community Edition is derived by `scripts/build_ce.py` according to `ce/manifest.yaml` (exclude `edition_ee` implementations + text transforms + CE overlays). Runtime boundaries include the router registry `api/routes/v1/__init__.py`, the table-creation boundary `edition_ee/db/edition_tables.py` (CE never registers the 20 EE-only tables), EE's `edition_ee/licensing/` package versus CE's fixed edition middleware, and policy seams such as site visibility. See [Editions](../editions/overview.md).
## Related Source
@@ -234,12 +228,13 @@ The commercial main repository carries the full code; the Community Edition is d
|---|---|
| FastAPI app and startup hooks | `src/backend/api/app.py` |
| Streaming chat entry point | `src/backend/api/routes/v1/chats/__init__.py` |
-| Run executor | `src/backend/orchestration/chat_run_executor.py` |
-| Streaming orchestration | `src/backend/orchestration/workflow.py` |
+| Run executor | `src/backend/core/chat/runtime/` |
+| Streaming orchestration | `src/backend/core/agent_runtime/runner.py` |
| Agent factory | `src/backend/core/llm/factory/build.py` |
| MCP config and ports | `src/backend/core/config/mcp_config.py`, `src/backend/extensions/mcp_servers/_ports.py` |
-| Capability catalog | `src/backend/core/config/catalog.json`, `catalog.py` |
+| Capability catalog | `src/backend/core/config/catalog.json`, `src/backend/core/capabilities/catalog/` |
+| Runs, leases and recovery | `src/backend/core/runs/`, `src/backend/core/chat/runtime/recovery.py` |
| Response envelope | `src/backend/core/infra/responses.py` |
| Prompt assembly | `src/backend/prompts/prompt_runtime.py` |
-| CE/EE seams | `src/backend/api/routes/v1/__init__.py`, `src/backend/core/db/edition_tables.py`, `src/backend/api/middleware/edition.py`, `src/backend/edition_ee/licensing/` |
+| CE/EE seams | `src/backend/api/routes/v1/__init__.py`, `src/backend/edition_ee/db/edition_tables.py`, `src/backend/api/middleware/edition.py`, `src/backend/edition_ee/licensing/` |
| Container orchestration | `docker-compose.yml` |
diff --git a/document/en/deployment/README.md b/document/en/deployment/README.md
index 85827d04..f8fdfab2 100644
--- a/document/en/deployment/README.md
+++ b/document/en/deployment/README.md
@@ -9,17 +9,14 @@ HugAgentOS supports several deployment methods, from "zero-dependency single-mac
| Method | Best for | Docker | Database | Multi-user | Doc |
|---|---|---|---|---|---|
| **Windows desktop one-click install** | Personal Windows use; install the local service with the desktop client | Not required | SQLite | No (single user) | [windows-deployment.md](windows-deployment.md) |
-| **UOS 1070 ARM64 desktop** | UOS 1070 aarch64; Electron 43 `.deb` | Not required | SQLite / remote | Local single user / cloud multi-user | [uos1070-desktop.md](uos1070-desktop.md) |
| **No-Docker quick install** | Personal single-machine trial, development experience; one command and you're running | Not required | SQLite | No (single user) | [quick-install.md](quick-install.md) |
| **Docker Compose** | The standard form for teams / production — multi-user, full features | Required | PostgreSQL | Yes | [docker-compose.md](docker-compose.md) |
-| **Offline production (Enterprise Edition)** | Air-gapped environments (government intranets); image tarball offline delivery | Required | PostgreSQL | Yes | [offline-production.md](offline-production.md) |
Cross-platform and reference:
| Doc | Description |
|---|---|
| [Windows Deployment](windows-deployment.md) | Installing the desktop-managed local service, or running Compose through Docker Desktop and WSL2 |
-| [UOS 1070 ARM64 Desktop](uos1070-desktop.md) | Electron 43, glibc 2.28, ARM64 `.deb` build and acceptance |
| [Environment Variables](environment-variables.md) | Complete variable reference (defaults / purpose / CE·EE relevance) |
## One-line comparison
@@ -31,7 +28,6 @@ Cross-platform and reference:
single-user deployment.
- **No-Docker quick install**: the fastest command-line path on Linux and macOS. Run `curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh | bash`; the installer creates the admin account, configures the model, starts the server, and opens the browser. Data lives under `~/.hugagent/`. **Single process, single user** — ideal for personal trials and development, not for multi-user or production.
- **Docker Compose**: the recommended standard deployment. All services are orchestrated by one `docker-compose.yml` (PostgreSQL + Redis + backend + MCP + frontend + sandbox), supporting multi-user, persistent sandboxes, layered memory, and every other capability.
-- **Offline production (EE)**: for isolated environments that cannot pull images online — build image tarballs on a connected machine, copy them to production, `docker load` + `compose up`. Part of the Enterprise Edition delivery scope.
> For capability differences between the Community and Enterprise editions, see [Edition Comparison](../editions/overview.md).
diff --git a/document/en/deployment/artifact-storage-key-repair.md b/document/en/deployment/artifact-storage-key-repair.md
deleted file mode 100644
index 7370f91f..00000000
--- a/document/en/deployment/artifact-storage-key-repair.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Repair historical artifact storage keys
-
-Older releases could register `artifacts/.docx` objects using the placeholder
-`artifacts/`. Generated-file persistence now requires a real storage key.
-Personal-space metadata reads drain local writes without requiring every remote
-file to download. Import failures show an error and retry control instead of an empty list.
-
-## Safety
-
-Repair matches only `storage_key == artifacts/`. It requires confirmed
-absence of that original object, an existing object with the recorded filename suffix,
-and downloaded bytes matching the recorded size. Timeouts, authorization errors,
-missing replacements and size mismatches leave the record unchanged. Existing
-extensionless objects are preserved.
-
-Updates compare the original key, filename, size and content timestamp atomically.
-They preserve the content version. The old/new keys, SHA-256 and original content
-version are committed together in `metadata.storage_key_repair` for audit and
-conditional rollback. Failed personal-space projection uses the same verified repair;
-existing conflict protection preserves unsaved local edits.
-
-## Operation
-
-Follow repository release approval rules. Validate in staging first and obtain
-separate approval before production code or data changes. Run from the project root
-using the authorized environment runtime and configuration. Never copy credentials
-or `.env` files between environments.
-
-```bash
-# Default: verify and print candidates without changing database records.
-PYTHONPATH=src/backend python scripts/repair_artifact_storage_keys.py --user-id
-
-# Apply after reviewing candidates; already repaired records are skipped.
-PYTHONPATH=src/backend python scripts/repair_artifact_storage_keys.py --user-id --apply
-
-# Undo audited repairs only while the current key and content version are unchanged.
-# This restores the old key. An upgraded background projector may repair it again.
-PYTHONPATH=src/backend python scripts/repair_artifact_storage_keys.py --user-id --rollback
-```
-
-The tool processes one explicitly selected account in ID pages and exits nonzero on
-errors. It includes historical deleted records without restoring files or changing
-folder ownership.
-
-## Verification
-
-1. Confirm candidate keys, object sizes and audit records; repeat runs make no duplicate changes.
-2. Verify successful root/folder listing APIs and downloads of valid files.
-3. In Windows desktop local mode, select a file using both `@` and `+ → Import from My Space`, then send it and verify the document content is readable.
-4. With one unavailable object, other metadata remains accessible and fetching the affected file reports failure.
-5. Failed requests expose retry; old responses cannot overwrite a newer filter or folder.
-
-Frontend changes require a desktop client release to affect installed clients.
-A successful source build does not imply publication or production data changes.
-Record staging, production and desktop verification separately.
diff --git a/document/en/deployment/desktop-local-mode.md b/document/en/deployment/desktop-local-mode.md
index 12dfe662..b56d5d1d 100644
--- a/document/en/deployment/desktop-local-mode.md
+++ b/document/en/deployment/desktop-local-mode.md
@@ -3,7 +3,7 @@
The complete desktop package includes the platform-specific CE backend and a private Python runtime.
Starting local mode verifies and extracts the bundled resources without requiring Docker, system Python,
pip or a first-run dependency download. Thin packages support remote connections only.
-See [Windows deployment](windows-deployment.md), [UOS deployment](uos1070-desktop.md) and
+See [Windows deployment](windows-deployment.md) and
the [deployment overview](README.md) for installation options.
## Persistent data
diff --git a/document/en/deployment/docker-compose.md b/document/en/deployment/docker-compose.md
index 1dac3fd7..dc75240d 100644
--- a/document/en/deployment/docker-compose.md
+++ b/document/en/deployment/docker-compose.md
@@ -14,7 +14,7 @@ For a new deployment:
```bash
python3 scripts/configure-ce-docker.py
-docker build -f docker/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
+docker build -f docker/sandbox/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
docker compose build backend
docker compose up -d --build
```
@@ -68,8 +68,8 @@ Enterprise Edition (EE).
| Service | Profile | Container | Image / build | Role |
|---|---|---|---|---|
-| `script-runner` | `script_runner` | hugagent-script-runner | `docker/Dockerfile.script-runner` | Lightweight sandbox: 1 GB RAM / 1 CPU / read-only rootfs + tmpfs; skill directory mounted read-only at `/workspace/skills` |
-| `opensandbox-config-init` | `opensandbox` | hugagent-opensandbox-config-init | `python:3.12-alpine` | One-shot init: renders `@@HOST_REPO_PATH@@` / `@@HOST_STORAGE_PATH@@` in `docker/opensandbox-config.toml.tpl` into the named volume `opensandbox_config` |
+| `script-runner` | `script_runner` | hugagent-script-runner | `docker/sandbox/Dockerfile.script-runner` | Lightweight sandbox: 1 GB RAM / 1 CPU / read-only rootfs + tmpfs; skill directory mounted read-only at `/workspace/skills` |
+| `opensandbox-config-init` | `opensandbox` | hugagent-opensandbox-config-init | `python:3.12-alpine` | One-shot init: renders `@@HOST_REPO_PATH@@` / `@@HOST_STORAGE_PATH@@` in `docker/opensandbox-server/opensandbox-config.toml.tpl` into the named volume `opensandbox_config` |
| `opensandbox` | `opensandbox` | hugagent-opensandbox | `hugagent-opensandbox-server:v0.1.13-3` | Shared CE / EE controller: manages containers through Docker socket; execd runs commands and session files persist. CE publishes no host port; the full repository has a configurable debug port |
### mem0 memory infrastructure (profile `mem0`, optional)
@@ -219,19 +219,19 @@ make migrate-new msg="describe change"
| Feature | Files |
|---|---|
-| Service orchestration | `docker-compose.yml` (CubeSandbox overlay: `docker-compose.cube.yml`) |
+| Service orchestration | `docker-compose.yml` (CubeSandbox overlay: `docker/sandbox/compose.cube.yml`) |
| Backend image | `docker/Dockerfile` (multi-stage, target `production`) |
| MCP image | `docker/Dockerfile.mcp` |
-| Sandbox images | `docker/Dockerfile.script-runner`, `docker/Dockerfile.opensandbox` |
+| Sandbox images | `docker/sandbox/Dockerfile.script-runner`, `docker/sandbox/Dockerfile.opensandbox` |
| Frontend image + nginx proxy | `src/frontend/Dockerfile`, `src/frontend/nginx.conf`, `src/frontend/default.conf.template` |
| Backend entrypoint (auto migration) | `src/backend/scripts/backend_entrypoint.sh` |
-| OpenSandbox config template | `docker/opensandbox-config.toml.tpl` |
+| OpenSandbox config template | `docker/opensandbox-server/opensandbox-config.toml.tpl` |
| Migrations | `alembic.ini`, `src/backend/alembic/` |
| MCP port single source of truth | `src/backend/extensions/mcp_servers/_ports.py` |
### OpenSandbox command latency and controller image
-The controller is built from `docker/Dockerfile.opensandbox-server` as
+The controller is built from `docker/opensandbox-server/Dockerfile.opensandbox-server` as
`hugagent-opensandbox-server:v0.1.13-3`. Based on a pinned upstream release,
it inspects known sandboxes by name and verifies ownership labels. Synchronous
Docker lookups in lifecycle and proxy routes run in a thread pool so other requests
@@ -270,7 +270,7 @@ Create a dedicated BuildKit builder from the WSL project directory:
BUILDX_BUILDER=hugagent-limited COMPOSE_PARALLEL_LIMIT=1 docker compose build mcp
BUILDX_BUILDER=hugagent-limited COMPOSE_PARALLEL_LIMIT=1 docker compose build frontend
docker buildx build --builder hugagent-limited --load \
- -f docker/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
+ -f docker/sandbox/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
docker buildx stop hugagent-limited
Reuse the builder if it already exists. Stop it after builds; its cache remains available.
diff --git a/document/en/deployment/environment-variables.md b/document/en/deployment/environment-variables.md
index 27a293aa..8a10e2b6 100644
--- a/document/en/deployment/environment-variables.md
+++ b/document/en/deployment/environment-variables.md
@@ -23,7 +23,7 @@ This reference is sourced from `.env.example` and `src/backend/core/config/setti
| `HOST_REPO_PATH` | repo absolute path | Repository root on the host; used when the backend resolves compose-relative paths through docker.sock to rebuild sandbox images | CE |
| `DOCKER_GID` | `999` | Host docker group GID granting the backend container access to `/var/run/docker.sock` (find with `stat -c '%g' /var/run/docker.sock`) | CE |
| `COMPOSE_PROFILES` | CE Docker: `opensandbox` | Compose profile selection (`script_runner` / `opensandbox`, append `,mem0` as needed); must match `SANDBOX_PROVIDER` | CE |
-| `COMPOSE_FILE` | (unset) | Compose overlay files; set `docker-compose.yml:docker-compose.cube.yml` for CubeSandbox | CE |
+| `COMPOSE_FILE` | (unset) | Compose overlay files; set `docker-compose.yml:docker/sandbox/compose.cube.yml` for CubeSandbox | CE |
| `MAX_REQUEST_SIZE` | `52428800` (code default, 50 MB) | Maximum request body size (bytes) | CE |
| `DB_MIGRATION_RETRIES` / `DB_MIGRATION_RETRY_INTERVAL` | `20` / `2` (entrypoint defaults) | Startup migration retry count / interval (seconds) | CE |
@@ -57,7 +57,7 @@ This reference is sourced from `.env.example` and `src/backend/core/config/setti
| `ENABLE_SUMMARY` | `true` | When off, titles fall back to message truncation | CE |
| `SUMMARY_MAX_ROUNDS` | `3` | Titles stop updating after this many rounds | CE |
| `OPENAI_API_KEY` / `OPENAI_API_BASE` | (empty) / `https://api.openai.com/v1` | Alternate direct OpenAI configuration (passed through by compose) | CE |
-| `ROUTER_STRATEGY` | `main_only` (code default) | Routing strategy (`orchestration/strategy.py`) | CE |
+| `ROUTER_STRATEGY` | `main_only` (code default) | Routing strategy (`core/agent_runtime/strategy.py`) | CE |
| `FOLLOWUP_ENABLED` | `true` (code default) | Follow-up suggestion generation | CE |
## Authentication and login
@@ -219,13 +219,13 @@ the database has no value.
|---|---|---|
| `OPENSANDBOX_DOMAIN` | `http://opensandbox:8080` | OpenSandbox server address |
| `OPENSANDBOX_API_KEY` | Generated by CE initialization | Shared by backend and controller; never logged |
-| `OPENSANDBOX_IMAGE` | `hugagent-opensandbox-custom:latest` (compose default; code fallback `opensandbox/code-interpreter:v1.0.2`) | Sandbox runtime image; the custom image pre-installs all project dependencies (`docker/Dockerfile.opensandbox`) |
+| `OPENSANDBOX_IMAGE` | `hugagent-opensandbox-custom:latest` (compose default; code fallback `opensandbox/code-interpreter:v1.0.2`) | Sandbox runtime image; the custom image pre-installs all project dependencies (`docker/sandbox/Dockerfile.opensandbox`) |
| `OPENSANDBOX_READY_TIMEOUT_S` | `90` | Maximum wait for sandbox readiness (s) |
| `OPENSANDBOX_REQUEST_TIMEOUT_S` | `120` | Per-HTTP-call timeout (s) |
| `OPENSANDBOX_PORT` | Full repository: `8910`; CE: no mapping | Controller uses 8080 inside the network |
| `OPENSANDBOX_POOL_JUPYTER_MIN_IDLE` / `MAX_IDLE` | `.env.example` 1/3; compose defaults 2/3 | User session pool (historical setting name); CE minimum is 0 |
| `OPENSANDBOX_POOL_LIGHT_MIN_IDLE` / `MAX_IDLE` | `2` / `5` | Light bucket (one-shot executions) |
-| `OPENSANDBOX_POOL_MAX_TOTAL` | CE: `4`; full: `20` | Pool ceiling (idle + in use) |
+| `OPENSANDBOX_POOL_MAX_TOTAL` | CE: `4`; full: `20` | Controller-wide ceiling (all workers, warm and active instances); backend and controller must share this value |
| `OPENSANDBOX_SNAPSHOT_ENABLED` | `true` | Snapshot persistence master switch (snapshot+kill when idle, restore on reconnect) |
| `OPENSANDBOX_SNAPSHOT_RETENTION_DAYS` | `7` | Snapshot retention (GC sweeps expired rows) |
| `OPENSANDBOX_SNAPSHOT_WAIT_TIMEOUT_S` | `120` | Maximum poll for snapshot readiness (s) |
@@ -323,16 +323,10 @@ estimate only when it is not.
See the [License mechanism](../editions/license.md) for details.
-## Miscellaneous (rate limiting, circuit breakers, security, alerting)
+## Miscellaneous (security, alerting)
| Variable | Default | Description | Edition |
|---|---|---|---|
-| `RATE_LIMIT_ENABLED` | `true` | API rate limiting | CE |
-| `RATE_LIMIT_STORAGE` | `memory://` | Rate-limit counter store (`redis://...` supported) | CE |
-| `RATE_LIMIT_GLOBAL` / `RATE_LIMIT_PER_USER` | `500/minute` / `50/minute` | Global / per-user limits | CE |
-| `CB_USER_CENTER_THRESHOLD` / `CB_USER_CENTER_TIMEOUT` | `5` / `60` | User-center circuit breaker | EE |
-| `CB_MODEL_API_THRESHOLD` / `CB_MODEL_API_TIMEOUT` | `10` / `30` | Model-API circuit breaker | CE |
-| `CB_STORAGE_THRESHOLD` / `CB_STORAGE_TIMEOUT` | `5` / `60` | Storage circuit breaker | CE |
| `CORS_ORIGINS` | compose default `http://localhost:3000,http://localhost:5173` | Allowed cross-origin sources | CE |
| `ENABLE_SECURITY_HEADERS` | (commented; recommended `true` in production) | Security response headers | CE |
| `AUDIT_LOG_RETENTION_DAYS` / `AUDIT_LOG_EXPORT_ENABLED` | `90` / `true` (commented samples) | Audit-log retention / export | EE |
@@ -346,7 +340,7 @@ See the [License mechanism](../editions/license.md) for details.
|---|---|
| Variable samples (deployment starting point) | `.env.example` |
| Centralized settings (single read point) | `src/backend/core/config/settings.py` |
-| Compose-level injection and defaults | `docker-compose.yml`, `docker-compose.cube.yml` |
+| Compose-level injection and defaults | `docker-compose.yml`, `docker/sandbox/compose.cube.yml` |
| Sandbox provider selection | `src/backend/core/sandbox/`, `settings.py::SandboxSettings` |
| Memory settings | `settings.py::MemorySettings`, `src/backend/core/memory/` (service.py / pipeline.py) |
| Feedback GitHub configuration | `src/backend/edition_ee/services/system_config.py`, `feedback_service.py` |
@@ -395,7 +389,7 @@ Hosting remains opt-in. See [application deployment](../modules/application-host
With the module enabled, existing authenticated CE users can use Personal Agent. EE additionally checks the **Personal Agent** permission granted per user, role or team in the Config console (default off). Revoking that permission hides the entry and stops queued, scheduled and channel work. A persistent_sandbox license is not required. Both editions enforce ownership and action approvals.
-See [Personal Agent](../modules/personal-agent.md). Use docker/compose.personal-agent.yml with the root Compose file and the personal-agent profile.
+See [Personal Agent](../modules/personal-agent.md). Use docker/personal-agent/compose.personal-agent.yml with the root Compose file and the personal-agent profile.
diff --git a/document/en/deployment/offline-production.md b/document/en/deployment/offline-production.md
deleted file mode 100644
index efe4f663..00000000
--- a/document/en/deployment/offline-production.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# Offline Production Deployment (Enterprise Edition)
-
-> Last updated: 2026-10-16 | [简体中文](../../zh-CN/deployment/offline-production.md) | Back to [Deployment Guide](README.md)
-
-Offline delivery uses compiled images and the matching deployment kit from the `deploy` branch.
-Build and package in the supplier's separate `deploy` checkout. Load the images and start the matching kit on the target.
-Branch synchronization, builds, uploads and production publication each follow the project authorization rules.
-
-## Connected side: prepare the complete delivery
-
-1. Verify the validated `deploy` commit, clean checkout, product identity and target CPU architecture.
-2. Read that commit's `deploy_kit/save_pack_ee.sh` and matching Compose files in full.
-3. Build and package:
-
- ```bash
- # Run at the root of the deploy checkout
- bash deploy_kit/save_pack_ee.sh --build
- # Package existing compiled images only after checking their source revision
- bash deploy_kit/save_pack_ee.sh
- ```
-
-The script checks compiled backend, MCP, frontend and required infrastructure images, then includes available optional images.
-The supplier must also verify every dependency of the target profiles. An absent optional image does not mean the target does not need it.
-Use image names, versions, output paths and filenames from the selected script and manifest without renaming them.
-Deliver the image archive, manifest and matching kit together. Deliver the License separately through a secure channel.
-Ordinary source images or an incremental backend/frontend pack cannot replace the complete EE delivery.
-
-## Target side: load and start
-
-Follow the delivered kit to configure a separate project name, data directories, License and protected environment files.
-Before upgrading, back up the database and files, and verify the manifest, CPU architecture and selected profiles.
-After authorization, load the specified archive and use the matching kit's startup entry and complete Compose overlays.
-For targets without registry access, verify that all required images are loaded and startup does not build images.
-Do not use the former source-deployment scripts or automatically select an unverified archive by modification time.
-
-Verify at least `/health`, `/ready`, login, attachments, model calls and the enabled sandbox capabilities.
-Database migrations modify the schema; persistent volumes do not mean upgrades cannot change business data.
-On failure, restore the recorded images, configuration and data backups without deleting data volumes.
-
-## Shipping prompt snapshots with the pack
-
-System prompts live in the database `content_blocks` table (the `prompt_versions` pool plus the `prompt_hub` template gallery). They are **not in the repository and not in the images**. To push new prompts to production, ship them as a separate data file alongside the image pack and import on the production side:
-
-### 1. Export on the connected side
-
-Export a snapshot through `GET /v1/content/prompts/export` using the source environment's
-administrator or system configuration token. Save the response's `data` object as
-`prompts_snapshot_.json`; do not save the complete response envelope.
-
-### 2. Copy
-
-Copy `prompts_snapshot_.json` to the production machine together with the image tarball.
-
-### 3. Import on the production side
-
-Prerequisite: the production backend image already includes the `POST /v1/content/prompts/import` endpoint (`src/backend/api/routes/v1/content.py`). Once the new backend is up:
-
-```bash
-BACKEND_CONTAINER=your-backend-container
-docker cp prompts_snapshot_.json "${BACKEND_CONTAINER}:/tmp/"
-docker exec "$BACKEND_CONTAINER" curl -sS -X POST \
- 'http://localhost:3001/v1/content/prompts/import?overwrite=true' \
- -H "Authorization: Bearer " \
- -H 'Content-Type: application/json' \
- -d @/tmp/prompts_snapshot_.json
-```
-
-The API import invalidates the prompt cache automatically — **no backend restart needed**.
-
-> This is an on-demand, one-time data operation: the DB volume persists, so prompts do not need re-importing on every release — only when pushing new prompts to production.
-
-## Related source
-
-| Feature | Files |
-|---|---|
-| Complete EE image pack (deploy branch) | `deploy_kit/save_pack_ee.sh` |
-| Compiled EE images (deploy branch) | `docker/Dockerfile.backend-compiled`, `docker/Dockerfile.mcp-compiled` |
-| Compose overlays (deploy branch) | `docker-compose.prod.yml`, `docker-compose.deploy.yml`; runtime follows the delivered kit |
-| Prompt export / import | `src/backend/api/routes/v1/content.py` |
-| Prompt version pool | `src/backend/core/llm/prompt_versions.py` |
diff --git a/document/en/deployment/quick-install.md b/document/en/deployment/quick-install.md
index 41256c45..c41b178b 100644
--- a/document/en/deployment/quick-install.md
+++ b/document/en/deployment/quick-install.md
@@ -185,7 +185,7 @@ The no-Docker single-machine mode is built to be lightweight. Here is how it dif
| CLI (onboard / serve / doctor) | `src/backend/cli.py` |
| Local-mode switch | `src/backend/core/config/settings.py` (`DeploySettings`, `DEPLOY_PROFILE=local`) |
| Frontend static hosting + `/api` bridge | `src/backend/api/local_hosting.py` |
-| MCP / sandbox subprocess supervision | `src/backend/orchestration/local_subprocess.py` |
-| In-process event log / ephemeral state | `src/backend/orchestration/run_event_stream.py`, `src/backend/core/infra/ephemeral.py` (enabled by leaving `REDIS_URL` empty) |
+| MCP / sandbox subprocess supervision | `src/backend/core/infra/local_runtime/` |
+| In-process event log / ephemeral state | `src/backend/core/runs/events/`, `src/backend/core/infra/ephemeral.py` (enabled by leaving `REDIS_URL` empty) |
| Built-in MCP catalog seed | `src/backend/core/capabilities/mcp/service.py` (`seed_builtin_mcp_servers_if_empty`) |
| Environment variables | [environment-variables.md](environment-variables.md) (the "No-Docker local mode" section) |
diff --git a/document/en/deployment/space-files.md b/document/en/deployment/space-files.md
index ded1d526..5c696ccb 100644
--- a/document/en/deployment/space-files.md
+++ b/document/en/deployment/space-files.md
@@ -25,7 +25,7 @@ The controller needs no host FUSE mount or shared-propagation directory.
## Local S3 service (CE / EE)
-Add docker/compose.object-storage.yml to COMPOSE_FILE.
+Add docker/object-storage/compose.object-storage.yml to COMPOSE_FILE.
Set S3_ENDPOINT=http://object-storage:9000 and S3_BUCKET=hugagent-files.
Generate separate random S3_ACCESS_KEY and S3_SECRET_KEY values.
Store credentials in a protected environment file with mode 600.
@@ -48,8 +48,6 @@ Test upload, download and listing with an editor account.
Confirm that a read-only account cannot write or delete.
Revoke team access and confirm that the directory is unavailable.
-Local S3 has passed isolated MinIO, controller, sandbox and browser tests.
-Live OSS and OBS mounts need verification in dedicated test buckets.
Object-store directory moves use multiple object operations. They are not atomic filesystem renames.
## File page
@@ -101,4 +99,4 @@ Missing, expired, or changed-argument tickets cannot send a gateway request.
Desktop rollout requires the bundled backend, cloud backend, and a refreshed
capability manifest.
-The object-storage image uses checksum-pinned binaries from the official MinIO GitHub release. Docker builds this image from docker/Dockerfile.object-storage. Existing Milvus images and volumes are separate.
+The object-storage image uses checksum-pinned binaries from the official MinIO GitHub release. Docker builds this image from docker/object-storage/Dockerfile.object-storage. Existing Milvus images and volumes are separate.
diff --git a/document/en/deployment/uos1070-desktop.md b/document/en/deployment/uos1070-desktop.md
deleted file mode 100644
index 89017148..00000000
--- a/document/en/deployment/uos1070-desktop.md
+++ /dev/null
@@ -1,101 +0,0 @@
-# UOS 1070 aarch64 Desktop Client
-
-> [简体中文](../../zh-CN/deployment/uos1070-desktop.md)
-
-UOS 1070 aarch64 uses the Electron 43 release line in `desktop-uos/`. The supported
-delivery format is a Debian ARM64 `.deb`. It does not depend on system WebKitGTK 4.1,
-so it avoids the Tauri 2 WebView dependency blocker on the Debian 10-era UOS base.
-
-## Support baseline
-
-| Item | Production baseline |
-|---|---|
-| Operating system | UOS 1070 Desktop, aarch64 |
-| libc | glibc 2.28 |
-| Desktop shell | Electron 43.4.1 |
-| Installer | Debian `arm64` `.deb` |
-| Local runtime | Private CPython 3.11 with an `aarch64-manylinux_2_28` lock |
-| Data | `~/.hugagent` |
-| Configuration | `~/.config/com.hugagent.desktop` |
-
-The full package supports local, cloud, and dual modes with the existing desktop
-capabilities: deep-link login, loopback streaming proxy, tray/quick ask, native
-notifications, local-folder projects, dual-upstream routing, identity/model bridges,
-offline local-service install/upgrade/rollback, and SHA-256 verified updates.
-
-UOS hides the in-window menu bar by default and retains native title-bar controls.
-The tray exposes File, Edit, View, and Help commands; Ctrl+Shift+N opens a new window
-and Ctrl+N starts a new chat. The workspace shares macOS navigation gradients,
-rounded sidebar/content surfaces, light/dark themes, and loading layout. Quick ask
-keeps its compact appearance. The native UOS titlebar sits outside the web area,
-so no additional top inset is reserved inside the page.
-
-A distribution brand may preset the first-launch mode for a full package. A fixed
-dual-mode build goes directly to one-action initialization. The initialization and
-installation pages use the same halo, orbit, and progress-highlight motion as the
-existing desktop client, while showing only the current stage, percentage, and
-actionable errors. Raw logs remain in the local-service log directory for diagnosis
-instead of appearing at the bottom of the page.
-
-## Build and verification
-
-Build the full package on native Linux aarch64, preferably UOS 1070 itself. An x86_64
-builder may only cross-package the remote-only shell without the Python runtime.
-
-```bash
-cd desktop-uos
-npm install
-npm test
-npm run build
-npm run verify:deb -- "dist/HugAgentOS UOS_0.5.15_uos1070_arm64.deb"
-```
-
-Release acceptance must cover:
-
-1. Debian architecture is `arm64`;
-2. the Electron ELF imports no GLIBC symbol newer than 2.28;
-3. a disconnected full-package cold start performs no pip, uv, or Python download;
-4. local, cloud, and dual modes pass login, SSE chat, file transfer, folder selection, and notifications;
-5. dual-mode cloud projects remain in the cloud while local-folder projects remain local;
-6. a staged update passes SHA-256 verification, system authorization, installation, and restart.
-
-UOS shares one release index with Windows and macOS: each client fetches the update for its
-own platform and reports "no update available for this platform" when none was published,
-while older clients that send no platform read the common release, which advances only once
-all three platforms have published that version. The desktop version is identical across the
-three platforms and the build enforces it.
-
-See [`desktop-uos/README_EN.md`](../../../desktop-uos/README_EN.md) for commands,
-artifacts, and publishing.
-
-> Electron 43 upstream support is scheduled to end in January 2027. Treat it as the
-> current UOS compatibility baseline, keep the latest 43.x patch deployed, and qualify
-> Electron 44+ continuously.
-
-## Parity with the current Tauri client
-
-UOS shares the current React frontend and CE backend sources. Rebuilding the full package includes
-the latest capability origins, local enablement, skill sync and working-directory behavior.
-Updating the cloud alone does not replace resources in an installed client.
-
-- File supports opening a folder into a local project conversation; ordinary packages can change runtime mode.
-- View offers Zoom In, Zoom Out and Actual Size (Ctrl +/-/0 or Ctrl+wheel). Tauri's zoom steps are
- saved in `prefs.json` and reused by new windows and after restart; system DPI remains native.
-- Help, tray and sidebar share update handling. Background release notices only offer an update;
- confirmation opens download progress with cancellation during download. Installation still requires
- Ed25519 signature and SHA-256 verification and UOS system authorization.
- Older servers use low-frequency checks; reconnects back off and concurrent checks cannot install twice.
-- The loopback proxy checks Origin/Fetch Metadata before injecting session and bridge credentials;
- direct requests without source metadata are refused.
-- Service logs rotate while running, keeping the current and previous files at up to 32 MiB each.
- Capability readiness checks back off from 500 ms to 15 seconds. Existing UOS data and capability
- directories are preserved without automatically migrating configuration.
-
-Full packages also support Tauri's hybrid-only build option:
-
-```bash
-JX_DEFAULT_SERVER_BASE=https://agent.example.com npm run build:hybrid-only
-```
-
-This fixes the cloud address and dual mode and requires a cloud address. Thin packages cannot be
-hybrid-only. Full offline ARM64 packages still require native aarch64 build and installation validation.
diff --git a/document/en/development/backend.md b/document/en/development/backend.md
index d596ec56..153eac46 100644
--- a/document/en/development/backend.md
+++ b/document/en/development/backend.md
@@ -68,10 +68,10 @@ The version list pins a compatible mem0, pymilvus and Milvus Lite combination. D
## Alembic migration flow
-1. Change the ORM models in `src/backend/core/db/models.py`;
+1. Change the ORM models in `src/backend/core/db/models/`;
2. `make migrate-new msg="add xxx table"` to autogenerate the migration (**review the output by hand** — autogenerate cannot be fully trusted);
3. Apply with `make migrate` (or `alembic upgrade head` inside the container);
-4. If the table is **EE-only**: add its name to `core/db/edition_tables.py::EE_ONLY_TABLES` (CE does not create empty EE tables; names in the set are existence-asserted).
+4. If the table is **EE-only**: add its name to `edition_ee/db/edition_tables.py::EE_ONLY_TABLES` (CE does not create empty EE tables; names in the set are existence-asserted).
Note: the main-repo alembic chain is the EE chain; the CE derived tree uses its own `ce_0001` baseline chain (see [CE Build Pipeline](../editions/build-ce.md#ce-database-differences)) — new main-repo migrations never enter CE.
@@ -188,7 +188,7 @@ A **marketplace skill** goes under `extensions/skills/marketplace//` w
## New-feature checklist
-- [ ] ORM model + indexes + timestamps (`core/db/models.py`); EE tables added to `EE_ONLY_TABLES`
+- [ ] ORM model + indexes + timestamps (`core/db/models/`); EE tables added to `EE_ONLY_TABLES`
- [ ] Repository filters soft deletes; Service carries business logic and permission checks
- [ ] Routes use envelope responses; errors go through `core/infra/exceptions`
- [ ] Route registered in `CE_ROUTERS` / `EE_ROUTERS` (EE with a feature bit + manifest exclusion)
@@ -205,10 +205,10 @@ A **marketplace skill** goes under `extensions/skills/marketplace//` w
| Response envelope | `src/backend/core/infra/responses.py` |
| Exception hierarchy | `src/backend/core/infra/exceptions.py` |
| Dependency injection | `src/backend/api/deps.py` |
-| ORM / table boundary | `src/backend/core/db/models.py`, `src/backend/core/db/edition_tables.py` |
+| ORM / table boundary | `src/backend/core/db/models/`, `src/backend/edition_ee/db/edition_tables.py` |
| MCP port table / connection config | `src/backend/extensions/mcp_servers/_ports.py`, `src/backend/core/config/mcp_config.py` |
| Capability catalog | `src/backend/core/config/catalog.json` |
| Skill loading | `src/backend/core/capabilities/skills/` (`config.py` / `loader.py` / `registry.py`) |
-| Workflow orchestration | `src/backend/orchestration/workflow.py` |
+| Workflow orchestration | `src/backend/core/agent_runtime/runner.py` |
See also: [Frontend Development Guide](frontend.md) · [API Overview](../api/overview.md) · [MCP Tools](../modules/mcp-tools.md) · [Agent Skills](../modules/agent-skills.md)
diff --git a/document/en/development/container-layout.md b/document/en/development/container-layout.md
deleted file mode 100644
index 9911e7f1..00000000
--- a/document/en/development/container-layout.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Container-responsive layout
-
-The chat toolbar responds to its pane's available width. Home, conversation, project,
-and split views share the same layout.
-
-- Tools and model/send controls form separate groups and wrap when necessary.
-- Labels collapse progressively while icons, accessible names, and mode exits remain.
- Model names stay visible with ellipsis for long text.
-- Touch devices use real 44px targets in normal flow, without overlapping invisible hit layers.
-- Project, capability, and knowledge cards use intrinsic grids; long names cannot widen tracks.
-- Safe areas, page height, and portaled menus retain their viewport-based constraints.
-
-Implementation: frontend components/chat/ComposerToolbar.tsx,
-styles/chat/toolbar-layout.css, styles/projects.css, and styles/catalog/.
-A container supplies width and its descendants respond. Do not query the container
-from itself or duplicate CSS breakpoints in JavaScript.
-
-From the WSL project directory, enter src/frontend with a supported Node.js version:
-
- npm run test:container-layout-browser
- npm run test:canvas-layout-browser
- npm run test:chat-width-browser
- npm run lint
- npm run build
-
-The new suite covers narrow containers inside a wide window, phone viewports,
-fractional boundaries, light/dark themes, long names, all modes, touch targets,
-keyboard exits, and send/stop callbacks.
diff --git a/document/en/development/frontend.md b/document/en/development/frontend.md
index 9df68e6b..715a69c5 100644
--- a/document/en/development/frontend.md
+++ b/document/en/development/frontend.md
@@ -132,6 +132,15 @@ Caveats:
- When adding new EE UI: ① gate it with the selector on the matching feature bit; ② if the entire page is EE (like /admin, /config), no runtime gating is needed — the CE derived tree physically excludes the component directories and removes the routes via the overlay `main.tsx` (see [CE Build Pipeline](../editions/build-ce.md));
- When handling API errors, branch on `LicenseError` (402) and show "contact your administrator to update the license" rather than a generic failure message.
+## Container-responsive layout
+
+The chat toolbar and project / capability / knowledge cards lay out by the available width of their pane; home,
+conversation, project and split views share the same rules. Containers provide width and descendants respond to the
+container; elements must not query themselves, and CSS breakpoints must not be duplicated in JavaScript. Touch targets
+are real 44px areas. Entry points: `components/chat/ComposerToolbar.tsx`, `styles/chat/toolbar-layout.css`,
+`styles/projects.css`, `styles/catalog/`. After changes, run `npm run test:container-layout-browser` and
+`npm run test:chat-width-browser`.
+
## New-feature checklist
- [ ] Components are PascalCase + named export + Props interface
diff --git a/document/en/development/workbench-routing.md b/document/en/development/workbench-routing.md
index 33aa5420..a7c484f6 100644
--- a/document/en/development/workbench-routing.md
+++ b/document/en/development/workbench-routing.md
@@ -10,6 +10,6 @@ The page distinguishes directory loading, directory failure, an undiscoverable a
A secondary view may reference its application's workspace module. Launch accepts only the main module and modules declared by that application's views. Other installation modules are rejected.
-No database schema migration is required. Legacy UI v1/v2, managed application APIs, and existing business URLs retain their contracts. Integrated acceptance records full platform validation. Fixture checks do not establish native client compatibility.
+No database schema migration is required. Legacy UI v1/v2, managed application APIs, and existing business URLs retain their contracts.
An empty directory retains the deep link and shows an empty state. Network and server failures offer retry. They do not appear as missing applications or denied discovery.
diff --git a/document/en/editions/build-ce.md b/document/en/editions/build-ce.md
index 8dc42d88..15150f24 100644
--- a/document/en/editions/build-ce.md
+++ b/document/en/editions/build-ce.md
@@ -83,10 +83,10 @@ Industry-chain, news, and company-profile presentation remains an EE feature.
| `.hugagent-edition` | Machine-readable `ce` marker used only after derivation; it lets release tooling distinguish a derived CE checkout from a source checkout whose generator is unexpectedly missing |
| `.github/workflows/desktop-release.yml` | Public CE desktop release workflow, including the release-tag/version preflight gate |
| `src/backend/api/routes/v1/__init__.py` | CE route registry; `EE_ROUTERS` is always empty |
-| `src/backend/core/auth/permissions_iface.py` | Owner-only single-tenant authorization interface with no team permission exports |
-| `src/backend/core/files/artifacts/edition.py` | Personal artifact-scope interface with no team fields, permissions, or repository methods |
+| `src/backend/core/auth/personal_permissions_iface.py` | Owner-only single-tenant authorization interface with no team permission exports |
+| `src/backend/core/files/edition.py` | Personal artifact-scope interface with no team fields, permissions, or repository methods |
| `src/backend/core/llm/tools/edition_{myspace,myspace_vfs,artifact_recovery}.py` | Personal MySpace tool, VFS, and recovery interfaces; organization implementations do not enter CE |
-| `src/backend/core/config/edition_display_names.py` | CE tool display names with no team-tool names |
+| `src/backend/core/config/display_names.py` | CE tool display names with no team-tool names |
| `src/backend/core/memory/audit.py` | Memory-audit no-op stub (same interface, writes nothing) |
| `src/backend/alembic/versions/ce_000{1,2}_*.py` | CE independent migration-chain baseline and idempotent schema upgrade (next section) |
| `src/backend/orchestration/workers/edition_startup.py` | CE startup seam with no-op implementations for EE data-source recovery and distillation scheduling, so pruned modules are never imported |
@@ -194,7 +194,7 @@ A qualifying release build must pass all of:
| Overlay directory | `ce/overlay/` |
| Derived-tree edition marker | `ce/overlay/.hugagent-edition` |
| Public desktop release workflow | `ce/overlay/.github/workflows/desktop-release.yml` |
-| CE/EE table boundary | `src/backend/core/db/edition_tables.py` |
+| CE/EE table boundary | `src/backend/edition_ee/db/edition_tables.py` |
| Idempotent schema reconciler | `src/backend/core/db/schema_reconcile.py` |
| Startup table-creation CE branch | `src/backend/core/db/engine.py::init_db` |
| CE independent migration chain | `ce/overlay/src/backend/alembic/versions/ce_000{1,2}_*.py` |
diff --git a/document/en/getting-started/introduction.md b/document/en/getting-started/introduction.md
index 10859626..74117c6e 100644
--- a/document/en/getting-started/introduction.md
+++ b/document/en/getting-started/introduction.md
@@ -1,93 +1,123 @@
# Introduction
-> Last updated: 2026-07-02
+> Last updated: 2026-10-23 | [简体中文](../../zh-CN/getting-started/introduction.md)
-HugAgentOS is an enterprise-grade AI Agent platform. Built around a ReAct agent core, it brings conversation, tool calling (MCP), skills, sandboxed code execution, long-term memory, knowledge-base RAG, automation, and batch processing together in a fully containerized (Docker Compose) system. The backend runs on FastAPI + AgentScope 2.0, the frontend on React 19 + Vite + Zustand, and every capability is registered in a unified catalog that can be toggled at will. Out of the box it is a complete, self-hostable product — not a half-finished framework.
+HugAgentOS is an open-source, self-hosted **agent harness**. The model does the thinking; the
+harness handles everything around it: where a task runs, how it picks up after an interruption,
+how tools and sandboxes are used, which actions need human approval, and how experience is kept.
-The platform ships in two editions: the **Community Edition (CE, open source)** and the **Enterprise Edition (EE, commercial)**. CE targets individuals and small teams and includes the full conversation, tool, skill, sandbox, three-layer memory, automation, batch, and data-canvas stack. EE layers organization-scale capabilities on top: team collaboration, SSO, audit & compliance, industry data tools, persistent sandboxes, cloud storage, and the full admin console. See [Edition Comparison](../editions/overview.md) for the exact boundary.
+At its core is the **durable agent**: your request runs in the cloud, not in your browser or on
+your computer. Close the tab or shut the laptop and the task keeps moving; if a server restarts or
+fails, another instance continues from the last saved progress; external operations that may or
+may not have happened are never redone automatically. On top of that, HugAgentOS provides an
+always-on **Personal Agent** and runs in two forms: **HugAgentOS Cloud** and **HugAgentOS Local**.
+
+The platform ships as a **Community Edition (CE, open source)** and an **Enterprise Edition (EE)**.
+CE lets an individual run the complete harness and Personal Agent; EE adds organization-scale
+capabilities such as team collaboration, SSO, compliance auditing and industry data tools. See the
+[edition comparison](../editions/overview.md) for the exact boundary.
+
+## Runtime forms
+
+| Form | Runs on | Data and state | Best for | Guide |
+|---|---|---|---|---|
+| **HugAgentOS Cloud** | Your server or private cloud | PostgreSQL, Redis, S3-compatible storage, OpenSandbox | 24/7 Personal Agent, long tasks, IM channels, access from any device | [Docker Compose](../deployment/docker-compose.md) |
+| **HugAgentOS Local** | Your computer (Windows / macOS / Linux) | Local SQLite, Milvus Lite, local files | Offline work, sensitive data, working directly on local projects | [Desktop local mode](../deployment/desktop-local-mode.md) |
+| **Local + cloud** | Cloud as control plane, local machine as execution plane | Cloud projects in the cloud, local projects on the machine | An always-on cloud agent that also operates your computer and projects | [Desktop local mode](../deployment/desktop-local-mode.md) |
+| One-command install | A development machine | SQLite, in-process state, subprocess sandbox | Trials and development | [No-Docker quick install](../deployment/quick-install.md) |
+
+Durable 24/7 operation needs HugAgentOS Cloud. The one-command install has no cross-process lease
+takeover and is meant for trials only.
## Feature overview
| Capability | Description | Edition |
|---|---|---|
-| Conversational agent | SSE streaming chat, AgentScope 2.0 ReActAgent, Plan Mode sub-agent, deep thinking, `[ref:tool-N]` citation tracing, automatic conversation-title summarization | CE |
-| Sub-agents | User-created sub-agents, @mention collaboration, routing strategy (`ROUTER_STRATEGY`) | CE (agent versioning / org-level agent library: EE) |
-| MCP tool ecosystem | 9 built-in MCP servers (7 CE general tools: internet search, web fetch, chart generation, batch planning, automation task management, skill management, knowledge retrieval; 2 EE industry tools: data-warehouse query and industry-chain info), served from a dedicated `mcp` container over streamable-http; users can self-register remote HTTP/SSE MCP servers | CE (industry tools such as industry-chain analysis, company profiling, data-warehouse query: EE) |
-| Skill system | Agent Skills (SKILL.md + scripts): built-in skill bundles, admin upload, skill marketplace browse & install, skill distillation | CE (skill review / org governance: EE) |
-| Sandbox execution | `bash` / `sandbox_put_artifact` / `sandbox_get_artifact` tools with three switchable providers: script_runner (lightweight, built-in), OpenSandbox (persistent sessions + Jupyter context + snapshots), CubeSandbox (E2B-compatible MicroVM) | CE for the lightweight sandbox; persistent sandboxes (session keep-alive / snapshots): EE |
-| Memory system | mem0 three-layer memory: L1 user profile, L2 vector memory (Milvus), L3 knowledge graph (Neo4j), with cross-session injection and background extraction | CE (memory audit: EE) |
-| Knowledge-base RAG | Document upload, chunking, hybrid vector + keyword retrieval, and private knowledge bases; EE can add public and external Dify knowledge bases | CE (public KB and Dify integration: EE) |
-| Automation | Scheduled tasks / cron scheduling / prompt & plan automation / failure retry (`orchestration/schedulers/`) | CE |
-| Data canvas | Online spreadsheet editing built on Univer | CE for personal editing; real-time multi-user collaboration: EE |
-| Projects / My Space | Personal projects (file capacity quota), personal drive (myspace), favorites, conversation sharing | CE (team folders / permission matrix: EE) |
-| Admin consoles | `/admin` content management (skills, capability center, release notes) + `/config` system console (prompt version pool, models, users/teams, billing, audit, security) | CE basics; full console (users/teams/billing/audit/security): EE |
-
-## CE vs. EE
-
-In one sentence: **the Community Edition lets one person push the platform to its limits; the Enterprise Edition lets an organization run it at scale** — team collaboration, SSO, RBAC, audit & compliance, industry data tools, persistent sandboxes, cloud storage, and white-labeling belong to EE. See [Edition Comparison](../editions/overview.md) and the [License mechanism](../editions/license.md) for the full matrix.
+| Durable runs | Every request is a persisted run executed in the background; resumable streams and mid-run steering; lease takeover and startup reconciliation; tool side effects record intent and receipt, and unknown outcomes are never replayed automatically | CE |
+| Personal Agent | One conversation, parallel background tasks, a permanent cloud computer, schedules and heartbeats, IM channels, action approvals, file-based memory with Dreaming, and connecting your own computer | CE (authorization governance and cross-device session management are EE) |
+| Autonomous loop | Goals survive across turns until done; a worker does the work, a read-only reviewer verifies it, and command-checkable requirements are verified by the driver; resumable after interruption | CE |
+| Chat and Plan Mode | SSE streaming, ReAct tool orchestration, deep thinking, Plan Mode, citations, context compaction checkpoints, chat forks | CE |
+| Sub-agents | Built-in explorer, worker and reviewer, user-created sub-agents, automatic routing or `@` mentions | CE (organization agent library is EE) |
+| MCP tools and plugins | Built-in web search, page fetching, knowledge retrieval, charts, automation, and skill and plugin management tools; an MCP marketplace, private MCP servers, a plugin marketplace and a browser automation plugin | CE (data warehouse and industry tools are EE) |
+| Agent Skills | SKILL.md plus scripts; built-in skills, a skill marketplace and personal skills | CE (skill review and organization governance are EE) |
+| Sandbox execution | OpenSandbox sessions with environment reuse and snapshot recovery, or a local subprocess sandbox; workflow jobs run as managed processes that can be taken over by lease | CE (CubeSandbox and team mounts are EE) |
+| Memory and personal evolution | L1 profile, L2 procedural memory (Milvus, optional), L3 relationship graph (Neo4j, optional); durable outbox writes; evolution proposals approved one by one | CE (memory auditing is EE) |
+| Knowledge base | Document chunking, hybrid vector and keyword retrieval, private knowledge bases, knowledge-base wiki | CE (public KB administration and Dify integration are EE) |
+| Domain ontology governance | Domain Pack import and versioned publishing, rule checks before tool execution, independent review of high-risk answers, human review of evolution drafts | CE |
+| Automation | Scheduled tasks in natural language, cron scheduling, retries on failure | CE |
+| Sites and application hosting | Build a site in chat and publish it in one click; separate application databases and MCP hosting | CE (site collaboration after project conversion is EE) |
+| Data canvas and artifacts | Univer spreadsheets, charts, reports, Office files, web pages and an artifact center | CE personal editing; real-time collaboration is EE |
+| Projects and My Space | Personal projects, folders, favourites and chat sharing; the desktop client can bind local project directories | CE (team folders and permission matrices are EE) |
+| Desktop client | Tauri 2 client with its own private runtime, supporting local-only, cloud-only and local + cloud modes | CE |
## Technology stack
-| Layer | Technology | Notes |
-|---|---|---|
-| Backend | FastAPI + Uvicorn (Python 3.11) | `src/backend/api/app.py`, unified response envelope |
-| Agent framework | AgentScope 2.0 (`agentscope==2.0.0`) | ReActAgent + tool registration, `core/llm/factory/build.py` |
-| Frontend | React 19 + Vite 7 + Zustand 5 + Ant Design 6 | `src/frontend/`, served by nginx with an `/api` reverse proxy |
-| Database | PostgreSQL 15 (production) / SQLite (local-debug fallback) | SQLAlchemy 2 + Alembic migrations |
-| Cache / sessions | Redis 7 | Session store, streaming follower (Redis Streams) |
-| Vector DB | Milvus 2.4 (`mem0` profile) | L2 vector memory and self-hosted KB retrieval |
-| Graph DB | Neo4j 5 Community (`mem0` profile, optional) | L3 knowledge-graph memory |
-| Sandbox | script_runner sidecar / Alibaba OpenSandbox / Tencent CubeSandbox | `core/sandbox/` provider protocol, switched via env |
-| Deployment | Docker Compose (profiles: `script_runner` / `opensandbox` / `mem0`) | Everything runs in containers; there is no local dev server |
-
-## Architecture at a glance
+| Layer | Technology |
+|---|---|
+| Agent runtime | AgentScope 2.0 (ReActAgent), Model Context Protocol |
+| Backend | Python 3.11, FastAPI, SQLAlchemy 2, Alembic |
+| Frontend | React 19, TypeScript, Vite, Zustand, Ant Design |
+| Desktop | Tauri 2, private CPython 3.11 runtime |
+| Data and state | PostgreSQL 15 or SQLite; Redis 7 or in-process state |
+| Storage | S3-compatible storage (cloud), local files (desktop and one-command install) |
+| Sandbox | OpenSandbox, local subprocess (CubeSandbox is EE) |
+| Optional memory | Milvus 2.4, Neo4j 5 Community, mem0 |
+| Deployment | Docker Compose, desktop installer, one-command installer, Nginx |
+
+## Architecture
```
- ┌──────────────────────────────────────────────┐
- Browser ──► Nginx ────►│ FastAPI backend (src/backend/api/app.py) │
- (frontend ctr, │ api/routes/v1/* · 50+ routers · envelope │
- /api proxy) └───────────────────┬──────────────────────────┘
- │
- ┌──────────────────────┼─────────────────────────┐
- ▼ ▼ ▼
- orchestration/workflow.py core/services/* core/auth/*
- (SSE streaming orchestration: (business service (local / mock /
- text / tool_call / layer) remote + SSO)
- tool_result / meta / done)
- │
- ┌─────────────┼───────────────────┬─────────────────────┐
- ▼ ▼ ▼ ▼
- core/llm/ orchestration/ orchestration/ core/memory/ (svc)
- factory/build strategy.py citations.py + memory_integration
- (AgentScope (routing (citation parsing) (mem0 retrieve/save)
- 2.0 ReAct) strategy) │
- │ ▼
- ├──► core/llm/mcp_manager ──► mcp container Milvus / Neo4j
- │ (9 MCP servers, ports 9100–9104, 9106–9108, and 9112) (mem0 profile)
- ├──► core/sandbox/* ──► script-runner / OpenSandbox / CubeSandbox
- └──► PostgreSQL · Redis · storage (local / S3 / OSS)
+ Entry points: web · desktop client · IM channels · API · schedules · heartbeats
+ │
+ ▼
+ FastAPI (api/) auth · edition routing · messages persisted before the response
+ │
+ ▼
+ Run layer (core/runs · core/chat/runtime)
+ persisted runs · database leases · startup reconciliation · resumable streams · steering · tool effect receipts
+ │
+ ▼
+ Agent execution (core/agent_runtime · core/llm)
+ ReActAgent · per-step context assembly and compaction · memory / ontology / evolution integration
+ Outer orchestration (orchestration/): Plan Mode · autonomous loop · automation
+ │
+ ┌────────────────┼──────────────────┐
+ ▼ ▼ ▼
+ MCP · skills · plugins sandbox · jobs knowledge · memory
+ │ │ │
+ └────────────────┴──────────────────┘
+ ▼
+ PostgreSQL / SQLite · Redis · S3 / local storage · Milvus · Neo4j
```
-Main request path: browser → nginx inside the frontend container (`/api` proxy) → FastAPI → `orchestration/workflow.py` orchestrates streaming → `core/llm/factory/build.py` builds the ReActAgent → MCP tools / sandbox / memory → SSE events (`text` / `tool_call` / `tool_result` / `meta` / `done`) stream back to the frontend.
+Personal Agent (`personal_agent/`) reuses the same runs, leases and sandboxes, and adds identity,
+a task queue, scheduling, channels, device links and workspace files. See the
+[architecture overview](../architecture/overview.md) for the full request lifecycle.
## Next steps
-- [Quick Start in 10 minutes](quick-start.md)
-- [Full Docker Compose deployment](../deployment/docker-compose.md)
-- [Environment variable reference](../deployment/environment-variables.md)
-- [Architecture overview](../architecture/overview.md)
+- [Quick start](quick-start.md)
+- [Personal Agent](../modules/personal-agent.md)
+- [Chat and orchestration](../modules/chat.md)
+- [Autonomous loop](../modules/autonomous-loop.md)
+- [Deployment guide](../deployment/README.md)
-## Related source
+## Source map
-| Feature | Files |
+| Area | Path |
|---|---|
-| FastAPI app & router registration | `src/backend/api/app.py`, `src/backend/api/routes/v1/` |
-| Streaming orchestration (SSE) | `src/backend/orchestration/workflow.py`, `orchestration/streaming.py` |
+| FastAPI app and routes | `src/backend/api/app.py`, `src/backend/api/routes/v1/` |
+| Run lifecycle, leases and recovery | `src/backend/core/runs/` |
+| Chat worker, stream resume and steering | `src/backend/core/chat/runtime/` |
+| Agent execution entry | `src/backend/core/agent_runtime/` (`runner.astream_chat_workflow`) |
| Agent construction | `src/backend/core/llm/factory/build.py` |
-| MCP servers & port mapping | `src/backend/extensions/mcp_servers/`, `src/backend/extensions/mcp_servers/_ports.py`, `src/backend/core/config/mcp_config.py` |
-| Capability catalog | `src/backend/core/config/catalog.json`, `core/config/catalog.py` |
-| Sandbox providers | `src/backend/core/sandbox/`, `src/backend/core/config/settings.py::SandboxSettings` |
-| Memory system | `src/backend/core/memory/` (service.py / pipeline.py), `src/backend/orchestration/memory_integration.py` |
-| Automation scheduler | `src/backend/orchestration/schedulers/automation_scheduler.py` |
-| Frontend entries (App / Admin / Config) | `src/frontend/src/main.tsx`, `App.tsx`, `AdminApp.tsx`, `ConfigApp.tsx` |
-| Edition & license settings | `src/backend/core/config/settings.py::EditionSettings / LicenseSettings` |
+| Harness contracts and execution ledger | `src/backend/core/harness/` |
+| Workflow background jobs | `src/backend/core/jobs/` |
+| Plan Mode, autonomous loop, automation | `src/backend/orchestration/plans/`, `orchestration/loops/`, `orchestration/automations/` |
+| Personal Agent | `src/backend/personal_agent/` |
+| Memory and evolution | `src/backend/core/memory/`, `src/backend/core/evolution/` |
+| Domain ontology | `src/backend/core/ontology/` |
+| Sandbox | `src/backend/core/sandbox/` |
+| Built-in MCP, skills, plugins and agents | `src/backend/extensions/` |
+| Frontend entry | `src/frontend/src/main.tsx`, `App.tsx` |
+| Desktop client | `desktop/src-tauri/` |
diff --git a/document/en/getting-started/quick-start.md b/document/en/getting-started/quick-start.md
index c3fea68f..acdd4e41 100644
--- a/document/en/getting-started/quick-start.md
+++ b/document/en/getting-started/quick-start.md
@@ -1,22 +1,91 @@
# Quick start
-> Last updated: July 21, 2026 |
+> Last updated: October 23, 2026 |
> [简体中文](../../zh-CN/getting-started/quick-start.md)
-Choose the one-command installer for a personal trial or Docker Compose for a
-server-oriented, service-isolated deployment. Both methods require access to
-an OpenAI-compatible API or local model.
+There are three ways to get started. Choose HugAgentOS Cloud when agents should keep running in the
+cloud around the clock, the HugAgentOS Local desktop client to work offline on your own computer, or
+the one-command installer for development and quick trials. Every option needs access to an
+OpenAI-compatible API or a local model.
## Choose a deployment method
-The two deployment methods use different runtime and persistence models.
-
| Method | Best for | Runtime |
|---|---|---|
-| One-command installer | Personal trials and development | SQLite, in-process state, local subprocess sandbox |
-| Docker Compose | Long-running servers and service isolation | PostgreSQL, Redis, container sandbox, persistent volumes |
+| HugAgentOS Cloud (Docker Compose) | 24/7 operation, Personal Agent, IM channels, access from any device | PostgreSQL, Redis, OpenSandbox, persistent volumes |
+| HugAgentOS Local (desktop client) | Offline use, sensitive data, local projects | Bundled private runtime, local SQLite and files |
+| One-command installer | Trials and development | SQLite, in-process state, local subprocess sandbox |
+
+**The initial account and password are both `admin` and must be changed on first sign-in; the
+Community Edition has no self-registration.**
+
+## Option 1: HugAgentOS Cloud (Docker Compose)
+
+Install Git, Docker Engine or Docker Desktop, and Docker Compose v2, then run:
+
+```bash
+git clone https://github.com/ZJU-REAL/HugAgentOS.git
+cd HugAgentOS
+cp .env.example .env
+mkdir -p data/storage
+docker compose build backend
+docker compose up -d --build
+```
+
+Open [http://localhost:3002](http://localhost:3002). Sign in with `admin` /
+`admin`, change the password, then open **Settings → System Administration →
+Model Services** to connect a model. Use `docker compose ps` to inspect the
+services and `docker compose down` to stop them without deleting data.
+
+Read the [Docker Compose deployment guide](../deployment/docker-compose.md) for
+profiles, persistence, production configuration, and rebuild workflows.
+
+### Enable Personal Agent (optional)
+
+Personal Agent is off by default. It uses a dedicated OpenSandbox controller to keep a permanent
+cloud computer for each user.
+
+1. Build the computer image:
+
+ ```bash
+ docker build -f docker/personal-agent/Dockerfile.personal-agent -t hugagent-personal-agent:v5 .
+ ```
+
+2. Set these values in `.env`:
+
+ | Variable | Value |
+ |---|---|
+ | `PERSONAL_AGENT_ENABLED` | `true` |
+ | `HOST_STORAGE_PATH` | An absolute path on the host |
+ | `PERSONAL_AGENT_OPENSANDBOX_API_KEY` | A separate random secret of at least 32 characters, for example the output of `openssl rand -hex 32` |
+
+3. Start with the additional Compose file:
+
+ ```bash
+ docker compose -f docker-compose.yml -f docker/personal-agent/compose.personal-agent.yml --profile personal-agent up -d
+ ```
+
+Then click **Personal Agent** under **New chat**. See [Personal Agent](../modules/personal-agent.md)
+for database migrations, image upgrades and channel binding. Never commit the secret to Git.
-## Option 1: one-command installer
+## Option 2: HugAgentOS Local (desktop client)
+
+1. Download the desktop installer for your platform (Windows x86_64, macOS Apple Silicon / Intel,
+ Linux x86_64) from [Releases](https://github.com/ZJU-REAL/HugAgentOS/releases).
+2. Choose a mode on first launch:
+ - **Local only**: the service, data and execution stay on this computer and can run fully
+ offline;
+ - **Cloud only**: connect to a deployed HugAgentOS Cloud;
+ - **Local + cloud**: use your cloud account; cloud projects stay in the cloud and local
+ projects run on this machine, which also lets Personal Agent connect to this computer.
+3. In local mode, the client verifies and unpacks its bundled runtime, usually within tens of
+ seconds, with no Python, Docker or database to install.
+
+See [Desktop local mode](../deployment/desktop-local-mode.md) for installation, upgrade rollback
+and data directories, and [Local project workspace](../deployment/local-project-workspace.md) for
+how execution works in a bound local project.
+
+## Option 3: one-command installer
Use this method on Linux, macOS, or Windows through WSL2. Install the following
tools before you start.
@@ -65,36 +134,20 @@ You can add the command to your shell path:
export PATH="$HOME/.hugagent/venv/bin:$PATH"
```
-## Option 2: Docker Compose
-
-Use this method when you need durable service volumes and container isolation.
-Install Git, Docker Engine or Docker Desktop, and Docker Compose v2, then run:
-
-```bash
-git clone https://github.com/ZJU-REAL/HugAgentOS.git
-cd HugAgentOS
-cp .env.example .env
-mkdir -p data/storage
-docker compose up -d --build
-```
-
-Open [http://localhost:3002](http://localhost:3002). Sign in with `admin` /
-`admin`, change the password, then open **Settings → System Administration →
-Model Services** to connect a model. Use `docker compose ps` to inspect the
-services and `docker compose down` to stop them without deleting data.
-
-Read the [Docker Compose deployment guide](../deployment/docker-compose.md) for
-profiles, persistence, production configuration, and rebuild workflows.
+The one-command installer has no cross-process lease takeover and is not meant for 24/7 operation.
## Next steps
Continue with these guides after your first successful login:
-- Read the [complete no-Docker installation guide](../deployment/quick-install.md)
- for installer options, capability boundaries, and troubleshooting.
+- Read the [introduction](introduction.md) for the durable agent, runtime forms and feature overview.
- Configure [model providers](../modules/model-providers.md).
-- Build a governed workflow with the
- [domain ontology quickstart](domain-ontology-quickstart.md).
+- Try [Personal Agent](../modules/personal-agent.md) and the
+ [autonomous loop](../modules/autonomous-loop.md).
- Explore [MCP tools](../modules/mcp-tools.md),
[Agent Skills](../modules/agent-skills.md), and
[private knowledge bases](../modules/knowledge-base.md).
+- Build a governed workflow with the
+ [domain ontology quickstart](domain-ontology-quickstart.md).
+- Read the [complete no-Docker installation guide](../deployment/quick-install.md)
+ for installer options, capability boundaries, and troubleshooting.
diff --git a/document/en/modules/admin-console.md b/document/en/modules/admin-console.md
index ae7a6d27..141faa56 100644
--- a/document/en/modules/admin-console.md
+++ b/document/en/modules/admin-console.md
@@ -53,11 +53,11 @@ Page Configuration also owns the homepage's first-screen content. Administrators
`/v1/admin/skills` (`api/routes/v1/admin_skills.py`, `ADMIN_TOKEN`): full skill lifecycle — CRUD, zip upload, enable/disable, ordering, icons, dependency scan and editing, per-file read/write/delete inside a skill, forking built-in skills, import/export. Skills are stored in the `admin_skills` table; see [Agent Skills](agent-skills.md).
-`/v1/admin/skill-drafts` (`api/routes/v1/admin_skill_drafts.py`, `ADMIN_TOKEN`): review of auto-distilled skill drafts — list / count / detail, edit-then-approve, reject, delete, plus a manual trigger for the daily distillation scan.
+`/v1/admin/skill-drafts` (`features/distillation/api/admin_skill_drafts.py`, `ADMIN_TOKEN`): review of auto-distilled skill drafts — list / count / detail, edit-then-approve, reject, delete, plus a manual trigger for the daily distillation scan.
### Marketplace review (admin_marketplace) (Enterprise Edition: content_admin)
-`/v1/admin/marketplace` (`api/routes/v1/admin_marketplace.py`, `ADMIN_TOKEN`) does two things:
+`/v1/admin/marketplace` (`features/marketplace/api/admin_marketplace.py`, `ADMIN_TOKEN`) does two things:
1. **Install marketplace skills globally**: browse the marketplace listing (flagging "already installed globally"); installed skills have an empty owner, are available to everyone, and remain editable under Skill Management.
2. **Review user listing submissions**: submission list (filterable by status), detail (with SKILL.md preview), approve (publish, installable by all), reject / unpublish.
@@ -91,14 +91,14 @@ The sub-agent status switch uses `PUT /v1/admin/agents/{agent_id}/toggle`. While
### Knowledge base management (admin_kb) (Enterprise Edition: content_admin)
-`/v1/admin/kb` (`api/routes/v1/admin_kb.py`, `ADMIN_TOKEN`): management of self-hosted **public knowledge bases** (`KBSpace.visibility == "public"`, owned by the system owner), mirroring the user-side `kb.py` — KB CRUD, AI-generated descriptions, document upload / listing / original-file preview (Office→PDF) / deletion / reindexing, chunk preview and per-chunk editing (content / tags / questions). Public KBs are visible to all users in the capability catalog and searchable. In Dify mode, Dify datasets are shown read-only and writes return 409. See [Knowledge Base](knowledge-base.md).
+`/v1/admin/kb` (`edition_ee/routes/admin_kb.py`, `ADMIN_TOKEN`): management of self-hosted **public knowledge bases** (`KBSpace.visibility == "public"`, owned by the system owner), mirroring the user-side `kb.py` — KB CRUD, AI-generated descriptions, document upload / listing / original-file preview (Office→PDF) / deletion / reindexing, chunk preview and per-chunk editing (content / tags / questions). Public KBs are visible to all users in the capability catalog and searchable. In Dify mode, Dify datasets are shown read-only and writes return 409. See [Knowledge Base](knowledge-base.md).
### Sandbox management (admin_sandbox + config_security/sandbox)
Two complementary panels:
-- `/v1/admin/sandbox` (`api/routes/v1/admin_sandbox.py`, `ADMIN_TOKEN`, **Enterprise Edition: content_admin**): aggregated skill-dependency manifest preview, sandbox image rebuild trigger + container hot-swap, rebuild history and log queries.
-- `/v1/config/security/sandbox/*` (`api/routes/v1/config_security.py`, `CONFIG_TOKEN`, **Enterprise Edition: system_config**): read-only security view — sandbox overview, running instances list / detail, snapshots, rebuild history, redacted configuration. Capabilities a provider cannot support (e.g. ScriptRunner cannot enumerate instances) return `code=42210`, which the frontend renders as disabled.
+- `/v1/admin/sandbox` (`features/sandbox_ops/api/admin_sandbox.py`, `ADMIN_TOKEN`, **Enterprise Edition: content_admin**): aggregated skill-dependency manifest preview, sandbox image rebuild trigger + container hot-swap, rebuild history and log queries.
+- `/v1/config/security/sandbox/*` (`features/security_console/api/config_security.py`, `CONFIG_TOKEN`, **Enterprise Edition: system_config**): read-only security view — sandbox overview, running instances list / detail, snapshots, rebuild history, redacted configuration. Capabilities a provider cannot support (e.g. ScriptRunner cannot enumerate instances) return `code=42210`, which the frontend renders as disabled.
See [Sandbox](sandbox.md).
@@ -166,13 +166,13 @@ Aligned with chapter 4 of the productization plan and the route registry:
| Administrative credential dependencies | `src/backend/api/deps.py` |
| License features | `src/backend/edition_ee/licensing/features.py`, `src/backend/edition_ee/licensing/deps.py` |
| Skills / drafts / marketplace | `src/backend/api/routes/v1/admin_skills.py`, `admin_skill_drafts.py`, `admin_marketplace.py` |
-| Feedback and GitHub publishing | `src/backend/edition_ee/routes/feedbacks.py`, `admin_feedbacks.py`, `services/feedback_service.py` |
+| Feedback and GitHub publishing | `src/backend/edition_ee/routes/feedbacks.py`, `admin_feedbacks.py`, `edition_ee/services/feedback_service.py` |
| Prompts / MCP / sub-agents | `src/backend/api/routes/v1/admin_prompts.py`, `admin_mcp_servers.py`, `admin_agents.py` |
-| Knowledge base / sandbox | `src/backend/edition_ee/routes/admin_kb.py`, `src/backend/api/routes/v1/admin_sandbox.py` |
+| Knowledge base / sandbox | `src/backend/edition_ee/routes/admin_kb.py`, `src/backend/features/sandbox_ops/api/admin_sandbox.py` |
| Billing / usage / logs / chat review | `src/backend/api/routes/v1/admin_billing.py`, `admin_usage_logs.py`, `admin_logs.py`, `admin_chat_history.py` |
| Content management | `src/backend/api/routes/v1/content.py` |
| Users / teams / invites / license (EE) | `src/backend/edition_ee/routes/config_users.py`, `config_teams.py`, `config_invites.py`, `config_license.py` |
-| Security | `src/backend/api/routes/v1/config_security.py` |
+| Security | `src/backend/features/security_console/api/config_security.py` |
| Service configs | `src/backend/api/routes/v1/service_configs.py` |
Further reading: [Authentication & Permissions](auth.md) · [Prompt System](prompts.md) · [Editions & Licensing](../editions/overview.md)
diff --git a/document/en/modules/agent-skills.md b/document/en/modules/agent-skills.md
index e44f02df..8af22ed4 100644
--- a/document/en/modules/agent-skills.md
+++ b/document/en/modules/agent-skills.md
@@ -153,7 +153,7 @@ Implementation: `core/capabilities/plugins/runtime/__init__.py` (deferral resolu
## The Skill Marketplace
-The marketplace is an installable skill library with two sources — curated presets and community submissions — implemented in `core/services/marketplace_service.py`.
+The marketplace is an installable skill library with two sources — curated presets and community submissions — implemented in `features/marketplace/`.
### Browsing and installing
@@ -205,7 +205,7 @@ admin GET/PUT /v1/admin/marketplace/skills/{slug}/visibility (skill ma
GET /v1/admin/visibility/principals ← brief user/team/role lists (picker data source)
```
-Storage: `marketplace_listing_states.visibility` (missing row = public) plus the `marketplace_visibility_grants` whitelist table; user-side resolution is centralized in `core/auth/marketplace_visibility.py` (roles include both direct assignments and team-default roles inherited via team membership).
+Storage: `marketplace_listing_states.visibility` (missing row = public) plus the `marketplace_visibility_grants` whitelist table; user-side resolution is centralized in `edition_ee/auth/marketplace_visibility.py` (roles include both direct assignments and team-default roles inherited via team membership).
## Admin skill management
@@ -233,7 +233,7 @@ daily cron (default 02:30, DISTILL_CRON_EXPRESSION)
approve → promoted to AdminSkill; reject / delete; manual trigger-daily-scan available
```
-Thresholds, keywords and budgets are all overridable via `DISTILL_*` environment variables (`core/config/distillation.py`). The distillation pipeline (`skill_distiller.py`, the scheduler) is **Enterprise EE**; the CE tree strips it, and the draft-review console only appears in the Enterprise Edition.
+Thresholds, keywords and budgets are all overridable via `DISTILL_*` environment variables (`features/distillation/config.py`). The distillation pipeline (`skill_distiller.py`, the scheduler) is **Enterprise EE**; the CE tree strips it, and the draft-review console only appears in the Enterprise Edition.
## User self-service skills (capability center)
@@ -290,13 +290,13 @@ only submission, never conversation or continued local editing.
| `src/backend/core/llm/tools/skill_tool.py` | Restricted view_text_file + {baseDir} substitution + Runtime Hint |
| `src/backend/extensions/skills/default/` | The 12 built-in skills (incl. the 10 OfficeCLI ones) |
| `src/backend/extensions/skills/marketplace/` | The 45 installable marketplace packages |
-| `src/backend/core/services/marketplace_service.py` | Marketplace listing / install / secret injection / submission review |
-| `src/backend/api/routes/v1/marketplace.py` | User-side marketplace API (browse/install/submit/withdraw) |
-| `src/backend/api/routes/v1/admin_marketplace.py` | Admin marketplace API (global install / submission review) |
+| `src/backend/features/marketplace/` | Marketplace listing / install / secret injection / submission review |
+| `src/backend/features/marketplace/api/marketplace.py` | User-side marketplace API (browse/install/submit/withdraw) |
+| `src/backend/features/marketplace/api/admin_marketplace.py` | Admin marketplace API (global install / submission review) |
| `src/backend/api/routes/v1/admin_skills.py` | Admin skill CRUD / zip / dependency management |
-| `src/backend/api/routes/v1/admin_skill_drafts.py` | Distillation draft review (EE) |
-| `src/backend/core/llm/skill_distiller.py` | Skill distillation LLM pipeline (EE) |
-| `src/backend/orchestration/schedulers/distillation_cron_scheduler.py` | Daily distillation scheduler (EE) |
+| `src/backend/features/distillation/api/admin_skill_drafts.py` | Distillation draft review (EE) |
+| `src/backend/features/distillation/` | Skill distillation LLM pipeline (EE) |
+| `src/backend/orchestration/workers/distillation_cron_scheduler.py` | Daily distillation scheduler (EE) |
| `src/backend/api/routes/v1/me_capabilities.py` | User self-service skill / private MCP API |
Related docs: [Sandbox execution](sandbox.md) · [MCP tool system](mcp-tools.md) · [Capability catalog](catalog.md) · [Admin console](admin-console.md) · [Editions & licensing](../editions/overview.md)
diff --git a/document/en/modules/application-hosting.md b/document/en/modules/application-hosting.md
index 2173c621..be4f44a4 100644
--- a/document/en/modules/application-hosting.md
+++ b/document/en/modules/application-hosting.md
@@ -6,11 +6,11 @@ Static sites do not create databases. Persistent forms create typed application
Set `APPLICATION_DATABASE_URL` to a separate database and run `python -m core.services.application_hosting_setup` once. Cloud installations without this setting show hosting as unavailable; local mode may use SQLite. Production uses PostgreSQL.
-The optional `docker-compose.application-hosting.yml` adds PostgreSQL, a persistent volume and an initialization job. Set different URL-safe `APPLICATION_DB_PASSWORD` (bootstrap) and `APPLICATION_OWNER_DB_PASSWORD` (runtime), or encode connection strings correctly. The initialization job uses the same Dockerfile build target as the backend and mounts current backend sources read-only. Build the backend from these sources and rebuild the frontend for the nginx `/applications-mcp/` route. Follow repository approval rules before deployment. End-to-end tests use separate databases and platform mock authentication for local testing only.
+The optional `docker/application-hosting/compose.application-hosting.yml` adds PostgreSQL, a persistent volume and an initialization job. Set different URL-safe `APPLICATION_DB_PASSWORD` (bootstrap) and `APPLICATION_OWNER_DB_PASSWORD` (runtime), or encode connection strings correctly. The initialization job uses the same Dockerfile build target as the backend and mounts current backend sources read-only. Build the backend from these sources and rebuild the frontend for the nginx `/applications-mcp/` route. Follow repository approval rules before deployment. End-to-end tests use separate databases and platform mock authentication for local testing only.
```bash
-docker compose -f docker-compose.yml -f docker-compose.application-hosting.yml config --quiet
-docker compose -f docker-compose.yml -f docker-compose.application-hosting.yml up -d
+docker compose -f docker-compose.yml -f docker/application-hosting/compose.application-hosting.yml config --quiet
+docker compose -f docker-compose.yml -f docker/application-hosting/compose.application-hosting.yml up -d
```
## Access and data
@@ -43,27 +43,7 @@ Schema validation, relational table construction, registry transactions, data op
## Limits
-Implemented: typed SQL, bounded reads/writes, atomic batches, idempotency, optimistic updates, application isolation, anonymous insert-only forms and declarative MCP hosting. Not implemented: arbitrary-code MCP containers, OAuth authorization server, foreign-key relationship designer, backup/restore console and comprehensive usage billing. The real chat driver is `src/backend/tests/application_hosting_e2e.py`; `application_hosting_e2e_setup.py` prepares its isolated environment. It exercises the production chat API, model, tools, sandbox and publishing. Acceptance status comes from the actual run report.
-
-## Reproduce local end-to-end acceptance
-
-Use a local Docker environment with a real model, industry source and OpenSandbox configured. Build the current frontend first. These drivers use production APIs and actual tools, with no file or publication adapters. Test services bind loopback ports and use separate platform_e2e_verified / applications_e2e_verified databases and storage.
-
-```bash
-npm --prefix src/frontend run build
-PYTHONPATH=src/backend .venv/bin/python src/backend/tests/application_hosting_e2e_setup.py
-PYTHONPATH=src/backend .venv/bin/python src/backend/tests/application_hosting_e2e.py
-```
-
-Submit test data at `http://127.0.0.1:18533/site/e2e-form-1007/` using email `browser-e2e@example.test`. Then run:
-
-```bash
-PYTHONPATH=src/backend .venv/bin/python src/backend/tests/application_hosting_lifecycle_e2e.py
-```
-
-The lifecycle driver restarts only the isolated test services and rotates, rolls back, revokes and republishes the test MCP credential. Credentials stay in `/tmp/application-hosting-full-e2e-credentials.json` (0600), outside pages and acceptance reports. Existing batches are not automatically reset; use a clean isolated database batch for a full chat rerun. This workflow does not upgrade the primary installation or deploy remotely.
-
-For a fresh chat rerun, stop and remove only `hugagent-application-e2e-backend` and `hugagent-application-e2e-mcp`, then run setup with `APPLICATION_E2E_BATCH=run2`. Previous batch databases remain intact and drivers keep the same loopback URLs. Do not remove primary containers or databases.
+Implemented: typed SQL, bounded reads/writes, atomic batches, idempotency, optimistic updates, application isolation, anonymous insert-only forms and declarative MCP hosting. Not implemented: arbitrary-code MCP containers, OAuth authorization server, foreign-key relationship designer, backup/restore console and comprehensive usage billing.
## Desktop integration
@@ -135,9 +115,9 @@ and project files so subsequent upgrades reuse bindings; do not delete applicati
Management and rollback publication reject an existing unpublished project draft instead of overwriting it. Project publication waits for file-tool registration and refreshes the mounted source to its new version before reporting synchronization complete.
-## External review fixes and upgrades
+## Deployment requirements and data safety
-Application hosting remains optional. Set different APPLICATION_DB_PASSWORD and APPLICATION_OWNER_DB_PASSWORD values. Use both docker-compose.yml and docker-compose.application-hosting.yml.
+Application hosting remains optional. Set different APPLICATION_DB_PASSWORD and APPLICATION_OWNER_DB_PASSWORD values. Use both docker-compose.yml and docker/application-hosting/compose.application-hosting.yml.
Run application-database-init for new deployments and existing volumes. It creates recovery history and transfers application schema and table ownership to application_owner. It also grants access to existing application roles. Initialization failures must block backend startup.
diff --git a/document/en/modules/auth.md b/document/en/modules/auth.md
index de0f0b6c..13a8ca15 100644
--- a/document/en/modules/auth.md
+++ b/document/en/modules/auth.md
@@ -120,7 +120,7 @@ User-facing endpoints:
### Interface layer (CE/EE split seam)
-`core/auth/permissions_iface.py` is the **single import point** for permission symbols: mainline code (deps / files / chats / projects / kb, etc.) imports only from here, never from the concrete implementations. In the EE main repo it purely re-exports three real implementations; in the CE-derived tree the whole file is replaced by a single-tenant stub and the three implementation files do not physically exist:
+`core/auth/personal_permissions_iface.py` is the **single import point** for permission symbols: mainline code (deps / files / chats / projects / kb, etc.) imports only from here, never from the concrete implementations. In the EE main repo it purely re-exports three real implementations; in the CE-derived tree the whole file is replaced by a single-tenant stub and the three implementation files do not physically exist:
| Implementation file | Responsibility |
|---|---|
@@ -210,7 +210,7 @@ Key authentication events all land in the audit table (`audit_logs`): login succ
| SSO ticket exchange | `src/backend/edition_ee/auth/sso.py`, `src/backend/edition_ee/routes/auth.py` |
| Mock SSO / local login & registration page | `src/backend/api/routes/v1/mock_sso.py`, `src/backend/core/auth/mock_ticket_store.py` |
| Password hashing | `src/backend/core/auth/password.py` |
-| Permission interface layer (CE/EE seam) | `src/backend/core/auth/permissions_iface.py` |
+| Permission interface layer (CE/EE seam) | `src/backend/core/auth/personal_permissions_iface.py` |
| Team roles / file permissions (EE) | `src/backend/edition_ee/auth/roles.py`, `src/backend/edition_ee/auth/team_permissions.py` |
| Project / chat-share permissions (EE) | `src/backend/edition_ee/auth/project_permissions.py`, `src/backend/edition_ee/auth/chat_share_permissions.py` |
| Administrative credential dependencies | `src/backend/api/deps.py` |
diff --git a/document/en/modules/automation.md b/document/en/modules/automation.md
index 83417b2b..d2c375f6 100644
--- a/document/en/modules/automation.md
+++ b/document/en/modules/automation.md
@@ -6,8 +6,8 @@ HugAgentOS ships two built-in mechanisms for turning a single instruction into r
| Capability | One-liner | Backend entry |
|---|---|---|
-| Scheduled automation | Run a prompt or a plan automatically on a cron schedule (or once / manually) | `api/routes/v1/automations.py` + `orchestration/schedulers/automation_scheduler.py` |
-| Plan mode | AI first decomposes a complex task into structured steps, then executes them after confirmation | `api/routes/v1/plans.py` + `orchestration/subagents/plan_mode.py` |
+| Scheduled automation | Run a prompt or a plan automatically on a cron schedule (or once / manually) | `features/automation/api/automations.py` + `orchestration/automations/automation_scheduler.py` |
+| Plan mode | AI first decomposes a complex task into structured steps, then executes them after confirmation | `api/routes/v1/plans.py` + `orchestration/plans/` |
## Scheduled automation tasks
@@ -17,7 +17,7 @@ Local scheduled tasks belong to the signed-in cloud account. The account mapping
### Task model
-Tasks live in the `scheduled_tasks` table (`ScheduledTask` in `core/db/models/automation.py`). Key fields:
+Tasks live in the `scheduled_tasks` table (`ScheduledTask` in `edition_ee/db/models/automation.py`). Key fields:
| Field | Description |
|---|---|
@@ -40,7 +40,7 @@ Tasks live in the `scheduled_tasks` table (`ScheduledTask` in `core/db/models/au
### Scheduling mechanics (automation_scheduler.py)
-`orchestration/schedulers/automation_scheduler.py` is an asyncio polling scheduler started with the backend:
+`orchestration/automations/automation_scheduler.py` is an asyncio polling scheduler started with the backend:
- **Polling**: every 15 seconds (plus 0–5 s random jitter) it queries the DB for tasks whose `next_run_at` is due.
- **Distributed lock**: before firing, it acquires the Redis lock `jx:auto:lock:{task_id}` with continuous renewal throughout execution and token-checked release on completion so multiple instances never double-fire.
@@ -64,7 +64,7 @@ Opening the right-side Canvas in an execution conversation temporarily hides the
## Plan mode
-Plan mode decomposes a complex task into structured steps before executing them, in two phases (`orchestration/subagents/plan_mode.py`):
+Plan mode decomposes a complex task into structured steps before executing them, in two phases (`orchestration/plans/`):
1. **Generate (Phase 1)**: `POST /v1/plans/generate` (SSE streaming) — the AI analyzes the task description and produces a plan draft with a title, description, and step list; each step can declare `expected_tools` / `expected_skills` / `expected_agents`.
2. **Execute (Phase 2)**: `POST /v1/plans/{plan_id}/execute` (SSE streaming) — steps run sequentially, each with its own agent; step status, tool-call logs, and AI output are persisted incrementally.
@@ -99,14 +99,41 @@ Every morning an Industry morning brief execution conversation is created. The s
| Topic | Path |
|---|---|
-| Automation REST API | `src/backend/api/routes/v1/automations.py` |
-| Scheduler (polling / locks / recovery) | `src/backend/orchestration/schedulers/automation_scheduler.py` |
-| Automation service (cron computation / state machine) | `src/backend/core/services/automation_service.py` |
-| Task / run-record models | `src/backend/core/db/models/automation.py` |
+| Automation REST API | `src/backend/features/automation/api/automations.py` |
+| Scheduler (polling / locks / recovery) | `src/backend/orchestration/automations/automation_scheduler.py` |
+| Automation service (cron computation / state machine) | `src/backend/features/automation/service.py` |
+| Task / run-record models | `src/backend/features/automation/models.py` |
| Plan REST API | `src/backend/api/routes/v1/plans.py` |
-| Plan generation / execution orchestration | `src/backend/orchestration/subagents/plan_mode.py`, `src/backend/core/chat/plans.py` |
+| Plan generation / execution orchestration | `src/backend/orchestration/plans/`, `src/backend/core/chat/plans.py` |
| Plan / step models | `src/backend/core/db/models/agent.py` |
-| Frontend automation UI | `src/frontend/src/components/lab/AutomationPanel.tsx` etc., `src/frontend/src/components/automation/RunTimelinePanel.tsx` |
+| Frontend automation UI | `src/frontend/src/components/automation/AutomationPanel.tsx` etc., `src/frontend/src/components/automation/RunTimelinePanel.tsx` |
| Frontend state | `src/frontend/src/stores/automationStore.ts`, `automationChatStore.ts` |
Further reading: [Chat System](chat.md) · [MCP Tools](mcp-tools.md) · [Canvas & Artifacts](canvas-artifacts.md)
+
+## Multi-process execution and recovery
+
+Scheduled and manual triggers share one loop, scanned every 3 seconds with at most
+8 observed executions. Trigger consumption, schedule advancement and insertion of
+a pending run commit in one transaction. Conditional database updates fence
+concurrent admission. A manual trigger for a busy task remains queued.
+
+Pending means accepted and awaiting execution. Scheduler shutdown or lease loss
+transfers observation without cancelling the accepted conversation. A successor
+observes the same durable execution and its handoffs. User cancellation still
+stops execution. Child linkage and recovery arguments commit with acceptance;
+recovery never restarts a command to obtain its result. Existing notification
+delivery semantics remain; this change does not promise exactly-once delivery
+to external channels.
+
+Apply FULL migration automationqueue01 or CE migration ce_0032 before rollout.
+Local SQLite uses the installed automation module's schema initialization.
+Downgrade refuses pending records rather than discarding accepted work. Stop old
+schedulers, migrate, then start the new version; do not mix scheduling protocols.
+
+Plan/Loop persist all initial arguments with child acceptance. After a crash before
+worker registration, the successor dispatches only pending children. It never
+replays an already-running child from the beginning. Drain old in-flight work
+before upgrading; missing legacy arguments are not reconstructed by a compatibility path.
+
+Task delivery uses delivery_targets exclusively. The upgrade migrates flat channel_id/conversation_id fields into destination lists, removes the old fields, and preserves explicit destinations and unrelated metadata. Request handling no longer reads the old format.
diff --git a/document/en/modules/autonomous-loop.md b/document/en/modules/autonomous-loop.md
index 0b844bb4..346c5fdc 100644
--- a/document/en/modules/autonomous-loop.md
+++ b/document/en/modules/autonomous-loop.md
@@ -71,7 +71,7 @@ Frontend entry: the **"Autonomous loop" toggle in the chat input** (`components/
## Related code
-- 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`
+- Driver `orchestration/loops/driver.py`, planner `orchestration/loops/planner.py`, reviewer `orchestration/loops/reviewer.py`
+- ChatRun integration `core/chat/runtime/` (`start_autonomous_loop_run` / startup reconciliation `resume_running_loops`)
+- Service `orchestration/loops/run.py` (ledger mirror / start params / steering queue), API `api/routes/v1/loops.py`
- Tables `agent_loops` / `loop_iterations`
diff --git a/document/en/modules/browser-automation.md b/document/en/modules/browser-automation.md
index 56149118..883b37c2 100644
--- a/document/en/modules/browser-automation.md
+++ b/document/en/modules/browser-automation.md
@@ -107,7 +107,7 @@ Startup probes the actual Chromium sandbox. Known inner-sandbox failures retry w
After OpenSandbox updates, explicitly build the runtime image from the repository root:
```bash
-docker build -f docker/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
+docker build -f docker/sandbox/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
```
Ordinary docker compose up --build does not rebuild this on-demand image. Check OPENSANDBOX_IMAGE and create a fresh browser_open session after updating. Resource readiness requires Chromium initialization and real browser verification. Rebuild the corresponding images for other providers too.
diff --git a/document/en/modules/canvas-artifacts.md b/document/en/modules/canvas-artifacts.md
index 0dd6328c..dca1517f 100644
--- a/document/en/modules/canvas-artifacts.md
+++ b/document/en/modules/canvas-artifacts.md
@@ -8,7 +8,7 @@ Personal canvas editing, the artifact center, and chat sharing are all **Communi
## Data canvas
-The data canvas is a slide-out panel on the right of the chat UI (`src/frontend/src/components/canvas/CanvasPanel.tsx`), opened by clicking an attachment card in chat (`components/chat/ArtifactCardList.tsx`) or a file in My Space (`components/myspace/MySpacePanel.tsx`). The panel picks a renderer per file type:
+The data canvas is a slide-out panel on the right of the chat UI (`src/frontend/src/components/canvas/CanvasPanel.tsx`), opened by clicking an attachment card in chat (`components/chat/MessageArtifacts.tsx`) or a file in My Space (`components/myspace/MySpacePanel.tsx`). The panel picks a renderer per file type:
| File type | Rendering | Editable |
|---|---|---|
@@ -87,14 +87,14 @@ limits show a local-download prompt while the main page remains responsive.
### How artifacts are produced and persisted
-During a streamed turn, files produced by tools first enter the per-conversation **workspace** (`core/llm/workspace`); only files that are explicitly **pinned** are written to the `artifacts` table at end of turn by `core/files/artifacts/service.py::persist_artifacts` — this strict workspace gate keeps temporary intermediates from polluting My Space.
+During a streamed turn, files produced by tools first enter the per-conversation **workspace** (`core/sandbox/workspace`); only files that are explicitly **pinned** are written to the `artifacts` table at end of turn by `core/files/artifacts/service.py::persist_artifacts` — this strict workspace gate keeps temporary intermediates from polluting My Space.
Physical storage is managed by `core/files/artifacts/store.py`, dual-mode by `STORAGE_TYPE`:
- `local` (default, Community Edition): files are written under `${STORAGE_PATH:-result}/artifacts/` with a local JSON index (`index.json`), served via FileResponse.
- `oss` (Enterprise Edition, `cloud_storage`): files are uploaded to Aliyun OSS; the local index is also backed up to OSS so it survives container restarts. The S3 backend lives in `core/files/storage/`.
-Downloads all go through `GET /files/{file_id}` (`api/routes/files.py`), with an owner ∪ team permission check via `core/auth/permissions_iface.py::resolve_artifact_access`; Office files support PDF-converted online preview.
+Downloads all go through `GET /files/{file_id}` (`api/routes/files.py`), with an owner ∪ team permission check via `core/auth/personal_permissions_iface.py::resolve_artifact_access`; Office files support PDF-converted online preview.
### REST API (`/v1/artifacts`, `api/routes/v1/artifacts.py`)
diff --git a/document/en/modules/catalog.md b/document/en/modules/catalog.md
index e15332cd..83e1ff2b 100644
--- a/document/en/modules/catalog.md
+++ b/document/en/modules/catalog.md
@@ -121,14 +121,14 @@ State is centralized in `src/frontend/src/stores/catalogStore.ts`; local default
| Topic | Path |
|---|---|
-| Public API (is_enabled, etc.) | `src/backend/core/config/catalog.py` |
+| Public API (is_enabled, etc.) | `src/backend/core/capabilities/catalog/` |
| Single-source file | `src/backend/core/config/catalog.json` (overridable via `CATALOG_PATH`) |
-| Loading / dynamic sync / caching | `src/backend/core/config/catalog_loader.py`, `catalog_common.py`, `catalog_migration.py` |
+| Loading / dynamic sync / caching | `src/backend/core/capabilities/catalog/loader.py`, `catalog_common.py`, `catalog_migration.py` |
| User-override merging | `src/backend/core/capabilities/catalog/resolver.py`, `core/capabilities/catalog/overrides.py` |
| Catalog route | `src/backend/api/routes/v1/catalog.py` |
| User self-service capabilities | `src/backend/api/routes/v1/me_capabilities.py` |
| MCP server config (DB) | `src/backend/core/capabilities/mcp/service.py`, `api/routes/v1/admin_mcp_servers.py` |
| Skill management | `src/backend/api/routes/v1/admin_skills.py`, `core/capabilities/skills/` |
-| Dify KB injection (EE) | `src/backend/edition_ee/kb/dify.py`, via the shared `core/kb/external_provider.py` seam |
+| Dify KB injection (EE) | `src/backend/edition_ee/kb/dify.py`, via the shared `features/knowledge/extension.py` seam |
| Frontend Capability Center | `src/frontend/src/components/catalog/`, `stores/catalogStore.ts` |
| Factory consumption | `src/backend/core/llm/factory/build.py::_effective_mcp_server_keys` |
diff --git a/document/en/modules/chat.md b/document/en/modules/chat.md
index ef27e794..2fc804de 100644
--- a/document/en/modules/chat.md
+++ b/document/en/modules/chat.md
@@ -85,7 +85,7 @@ SSE follower: chat_run_executor.follow_run_as_sse()
### Run decoupling and reconnect/resume
-Every sent message creates a `ChatRun` and a background task (`orchestration/chat_run_executor.py`); events are written to a Redis Stream (`maxlen=5000`; the default TTL is the 7,200-second maximum human wait plus a 1,800-second recovery grace period). The HTTP connection is merely a *follower*, which enables:
+Every sent message creates a `ChatRun` and a background task (`core/chat/runtime/`); events are written to a Redis Stream (`maxlen=5000`; the default TTL is the 7,200-second maximum human wait plus a 1,800-second recovery grace period). The HTTP connection is merely a *follower*, which enables:
| Capability | Endpoint |
|---|---|
@@ -150,7 +150,7 @@ A permission chip sits next to the project selector in the composer toolbar. It
| Approve for me | `auto` | Ordinary writes and edits pass silently; **destructive operations** (`DESTRUCTIVE_OPS`) and commands the local policy tagged `danger:` still stop and ask |
| Full access | `full` | No tool confirmation is ever raised |
-The vocabulary is declared once in `core/llm/tool_permissions.py` (`APPROVAL_ASK` / `APPROVAL_AUTO` / `APPROVAL_FULL`), travels with the chat context into `PermissionRuntime`, and is the single criterion every tool confirmation gate consults. No preset does more than skip the *asking* — a hard deny from the local security policy still denies. Whether a given call counts as dangerous is recovered by `core/sandbox/permissions/models.py::danger_categories()` from the `danger:` prefix in the verdict reasons, never by matching prose. Legacy stored values (`standard`, `readonly`) fall back to the most conservative preset, `ask`.
+The vocabulary is declared once in `core/llm/tool_permissions/` (`APPROVAL_ASK` / `APPROVAL_AUTO` / `APPROVAL_FULL`), travels with the chat context into `PermissionRuntime`, and is the single criterion every tool confirmation gate consults. No preset does more than skip the *asking* — a hard deny from the local security policy still denies. Whether a given call counts as dangerous is recovered by `core/sandbox/permissions/models.py::danger_categories()` from the `danger:` prefix in the verdict reasons, never by matching prose. Legacy stored values (`standard`, `readonly`) fall back to the most conservative preset, `ask`.
Trusted unattended entry points (channel bots, scheduled automation) take the `default_allow` branch and are unaffected by the preset.
@@ -160,17 +160,17 @@ Trusted unattended entry points (channel bots, scheduled automation) take the `d
`create_agent_executor()` is the shared factory for every mode (main chat, plan, batch, sub-agents, automation):
-- **MCP tools**: after the three-layer filter of catalog + per-user overrides + request context (see [Capability Center](catalog.md)), stable servers reuse the process-level connection pool (`core/llm/mcp/pool.py`); per-request servers (e.g. `retrieve_dataset_content`, which needs per-request HTTP headers) are spawned fresh; the user's self-added private MCP servers are merged in with owner isolation.
+- **MCP tools**: after the three-layer filter of catalog + per-user overrides + request context (see [Capability Center](catalog.md)), stable servers reuse the process-level connection pool (`core/llm/mcp/`); per-request servers (e.g. `retrieve_dataset_content`, which needs per-request HTTP headers) are spawned fresh; the user's self-added private MCP servers are merged in with owner isolation.
- **Skills**: registered as AgentScope Agent Skills via `core/capabilities/skills/loader.py`, with `view_text_file` allow-listed to read SKILL.md files (see [Agent Skills](agent-skills.md)).
- **File / sandbox tools**: `bash`, `sandbox_put_artifact`, `sandbox_get_artifact` are always registered; Read/Edit/Write/Glob/Grep/Delete/Move/mkdir plus the MySpace tools are gated by `CODE_CAPABILITY_ENABLED` and share one `ReadStateTracker` to keep the "must Read before Edit" invariant.
-- **Middlewares** (onion model, `core/llm/middlewares.py`): `DynamicModelMiddleware` (switches the model per chat_mode, see [Model Providers](model-providers.md)), `FileContextMiddleware` (injects uploaded/historical file context), `SteerMiddleware` (injects follow-ups after tool results and before the next reasoning round), `WorkspacePinHintMiddleware`, `FinishPinGuardMiddleware`.
+- **Middlewares** (onion model, `core/llm/runtime/`): `DynamicModelMiddleware` (switches the model per chat_mode, see [Model Providers](model-providers.md)), `FileContextMiddleware` (injects uploaded/historical file context), `SteerMiddleware` (injects follow-ups after tool results and before the next reasoning round), `WorkspacePinHintMiddleware`, `FinishPinGuardMiddleware`.
- **Context compression**: `CompactingAgent` routes the pre-turn, mid-turn, and post-turn trigger points through one persistent checkpoint engine and one Codex-style handoff prompt. `ContextConfig` remains only for tool-result limiting. Compaction uses the unified coordinator without a second framework summary fallback. Failure preserves the original context. If the final request still exceeds its budget, the run saves a checkpoint and pauses before calling the reply model.
- **Permissions**: every registered tool gets a native `PermissionRule(ALLOW)` seed, preserving AgentScope's built-in dangerous-operation checks (no blanket BYPASS).
- **Iteration caps**: main agent defaults to `max_iters=50`, isolated sub-agents to 10.
## SSE event types and payloads
-`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:
+`core/llm/runtime/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 |
|---|---|---|
@@ -219,7 +219,7 @@ data: [DONE]
After `meta`, `chat_run_executor.py` persists the assistant message, backfills artifacts,
and launches a background follow-up-question generator
-(`orchestration/followups.py`; results land in the message's
+(`core/chat/followups.py`; results land in the message's
`extra_data.follow_up_questions` and are fetched through
`GET /v1/chats/{chat_id}/messages/{message_id}/followups`). The frontend
collects ontology events in a standalone **Domain Ontology Governance** module
@@ -272,9 +272,9 @@ Each canonical tool-result step is persisted before its display event, independe
Citations make every fact in the answer traceable back to a specific tool result. Numbering authority belongs to a single backend source of truth — the model only **copies** ids, never computes them. The chain has four segments:
-1. **Anchor allocation & injection (backend middleware)**: `core/llm/middlewares.py::CitationAnchorMiddleware` hooks AgentScope 2.0's `on_acting` and, before a tool result reaches the model, calls `orchestration/citation_anchor.py` to extract → allocate → inject: each citable item gets a session-monotonic anchor id (`e1`, `e2`, … — unique across tools, calls, and turns; a new turn continues from the max anchor found in the chat's persisted messages), and `"cite_id": "e7"` is written into the result JSON in place (plain-text results get a trailing `[cite_id: e7]` line). **The allocator is bound to the agent instance** (`attach_allocator()` / `resolve_allocator()`), which is how the orchestrator and the middleware share one counter; the ContextVar is only a fallback for sub-agent chains, because `astream_chat_workflow` is an async generator whose context does not reach the task the agent actually runs in. Extraction degrades through four layers: tool-declared `__citations__` → the tool spec registry (`TOOL_SPECS` config: list paths + CN/EN field aliases) → a generic heuristic (unique dict-array field) → the whole result as one anchor. Operational tools (file writes, pin, etc. — `SKIP_TOOLS`) pass through untouched. Any exception passes the original result through — citations degrade, the conversation never breaks.
+1. **Anchor allocation & injection (backend middleware)**: `core/llm/runtime/` hooks AgentScope 2.0's `on_acting` and, before a tool result reaches the model, calls `core/llm/citations/hook.py` to extract → allocate → inject: each citable item gets a session-monotonic anchor id (`e1`, `e2`, … — unique across tools, calls, and turns; a new turn continues from the max anchor found in the chat's persisted messages), and `"cite_id": "e7"` is written into the result JSON in place (plain-text results get a trailing `[cite_id: e7]` line). **The allocator is bound to the agent instance** (`attach_allocator()` / `resolve_allocator()`), which is how the orchestrator and the middleware share one counter; the ContextVar is only a fallback for sub-agent chains, because `astream_chat_workflow` is an async generator whose context does not reach the task the agent actually runs in. Extraction degrades through four layers: tool-declared `__citations__` → the tool spec registry (`TOOL_SPECS` config: list paths + CN/EN field aliases) → a generic heuristic (unique dict-array field) → the whole result as one anchor. Operational tools (file writes, pin, etc. — `SKIP_TOOLS`) pass through untouched. Any exception passes the original result through — citations degrade, the conversation never breaks.
2. **Prompt contract**: the system prompt (fallback file `prompts/prompt_text/default/system/40_format.system.md`; the active DB version is authoritative at runtime) needs only one tool-count-independent rule: copy the `cite_id` annotated in the result verbatim into `[anchor text](cite:e7)` (or `[来源](cite:e7)` at sentence end); never self-number.
-3. **Orchestration consumption**: each `tool_result` event calls `collect_citation_dicts()`, which fetches `CitationItem`s (`id` / `tool_name` / `tool_id` / `title` / `url` / `snippet` / `source_type` / `item_index`) from the allocator registry keyed by `tool_id`; when no allocator is installed (legacy replay paths) it falls back to the old offset extraction in `orchestration/citations.py`. `source_type` values: CE ships `internet`, `knowledge_base`, and `database`; industry citation types such as `industry_news`, `ai_news`, `chain_info`, and `company_profile` are added by the Enterprise Edition (EE).
+3. **Orchestration consumption**: each `tool_result` event calls `collect_citation_dicts()`, which fetches `CitationItem`s (`id` / `tool_name` / `tool_id` / `title` / `url` / `snippet` / `source_type` / `item_index`) from the allocator registry keyed by `tool_id`; when no allocator is installed (legacy replay paths) it falls back to the old offset extraction in `core/llm/citations/`. `source_type` values: CE ships `internet`, `knowledge_base`, and `database`; industry citation types such as `industry_news`, `ai_news`, `chain_info`, and `company_profile` are added by the Enterprise Edition (EE).
4. **Frontend rendering**: citations ride on `tool_result` and `meta` events and are persisted with the message (what is persisted is the annotated result, so replay/share shows the same numbering as generation). `components/citation/CitationMarkdownBlock.tsx` recognizes three marker forms in parallel — `[anchor text](cite:eN)` (rendered as a text link with a hover source card), `[[eN]]` (obsidian-style tolerance), and the legacy `[ref:tool_name-N]` (historical messages, rendered as a superscript badge). Tool cards show a matching `cite_id` chip on each item (`jx-tr-citeTag`), and tools without a dedicated renderer fall back to a generic list-card renderer.
**Tool development convention**: a tool (in-house or MCP) that wants precise citation granularity should return a `__citations__` field in its JSON — `[{"title": "...", "url": "...", "snippet": "...", "source_type": "..."}, …]`, entries ordered to match the result body; the middleware adopts it verbatim and injects `cite_id` in place. Tools without the field fall back to registry config or heuristics — at worst the whole result becomes one anchor, so **every tool is citable by default**. See the citation-declaration section in [MCP tools](mcp-tools.md).
@@ -297,7 +297,7 @@ Cards are persisted with the message (`metadata.referenced_chats`) and history r
## Plan Mode
-Plan Mode splits complex tasks into "generate plan → user reviews/edits → execute step by step", implemented in `orchestration/subagents/plan_mode.py`:
+Plan Mode splits complex tasks into "generate plan → user reviews/edits → execute step by step", implemented in `orchestration/plans/`:
- **Generate** (`astream_generate_plan` / `POST /v1/plans/generate`): a "bare LLM" agent (`disable_tools=True`) produces a structured JSON plan. System-prompt resolution: active `plan_mode` version in the prompt pool → legacy `system/90_plan_mode` part → fallback file `prompts/prompt_text/plan_mode/plan_mode.system.md` → hardcoded minimal prompt.
- **Execute** (`astream_execute_plan` / `POST /v1/plans/{plan_id}/execute`): each step gets its own agent, executed sequentially, with step-level MCP/skill/sub-agent bindings and cancellation (`is_run_cancelled` polling); execution also goes through ChatRun + Redis Stream, so it survives disconnects.
@@ -351,7 +351,7 @@ the `subagents` prompt-pool kind. That kind stays in backend storage and
migration snapshots for compatibility;
`extensions/agents/default//system.md` is only the seed and failure fallback.
-In addition, user-created sub-agents (`api/routes/v1/agents.py`, DB table `UserAgent`)
+In addition, user-created sub-agents (`edition_ee/routes/agents.py`, DB table `UserAgent`)
can carry their own system prompt, MCP, skill, plugin, and knowledge base
bindings, plus model parameters such as provider, temperature, `max_tokens`,
and `max_iters`. When you create or edit a sub-agent, the resource picker
@@ -438,7 +438,7 @@ Three complementary layers:
| Layer | Implementation | Trigger |
|---|---|---|
-| Chat title summary | `core/llm/summarizer.py::ConversationSummarizer` (`summarizer` model role, `ENABLE_SUMMARY` flag), `POST /v1/summary` | Auto-titling new chats |
+| Chat title summary | `core/models/completions/summarizer.py::ConversationSummarizer` (`summarizer` model role, `ENABLE_SUMMARY` flag), `POST /v1/summary` | Auto-titling new chats |
| Unified context checkpoint | `core/chat/compaction/service.py::run_compaction()`; pre-turn, mid-turn, and post-turn share one trigger ratio, handoff prompt, replacement shape, and persistent checkpoint | Context reaches the configured fraction of the model window |
| Deterministic overflow protection | `ContextConfig.tool_result_limit` bounds individual tool results first; failed compaction preserves the original context, and an over-budget final request saves a checkpoint and pauses | A tool result is oversized or unified compaction is temporarily unavailable |
@@ -486,29 +486,29 @@ Storage is Redis (`chat_share:*` key groups + TTL) with an in-process memory fal
## Other entry points
-The same orchestration foundation also powers: response regeneration (`POST /v1/chats/{chat_id}/regenerate`), edit-and-resend (`POST /v1/chats/{chat_id}/edit`), non-streaming `POST /v1/agents/responses` with `stream: false` (see [Automation](automation.md)), and scheduled automation (`orchestration/schedulers/`).
+The same orchestration foundation also powers: response regeneration (`POST /v1/chats/{chat_id}/regenerate`), edit-and-resend (`POST /v1/chats/{chat_id}/edit`), non-streaming `POST /v1/agents/responses` with `stream: false` (see [Automation](automation.md)), and scheduled automation (`orchestration/automations/`).
## Source map
| Topic | Path |
|---|---|
| Chat routes / SSE egress | `src/backend/api/routes/v1/chats/__init__.py` |
-| Run decoupling / Redis Stream / resume | `src/backend/orchestration/chat_run_executor.py`, `api/routes/v1/chat_runs.py` |
-| Streaming orchestration | `src/backend/orchestration/workflow.py` |
-| Event mapping (reply_stream → SSE) | `src/backend/orchestration/streaming.py` |
+| Run decoupling / Redis Stream / resume | `src/backend/core/chat/runtime/`, `api/routes/v1/chat_runs.py` |
+| Streaming orchestration | `src/backend/core/agent_runtime/runner.py` |
+| Event mapping (reply_stream → SSE) | `src/backend/core/llm/runtime/streaming.py` |
| Runtime context assembly | `src/backend/core/chat/context.py` |
| Agent factory | `src/backend/core/llm/factory/build.py` |
-| Middlewares | `src/backend/core/llm/middlewares.py` (pure-function helpers in `core/llm/runtime/hooks.py`) |
-| Citation extraction | `src/backend/orchestration/citations.py` |
+| Middlewares | `src/backend/core/llm/runtime/` (pure-function helpers in `core/harness/hooks.py`) |
+| Citation extraction | `src/backend/core/llm/citations/` |
| Cross-chat references (cards / digests / paging) | `src/backend/core/chat/references.py` |
| Cross-chat history tools | `src/backend/core/llm/tools/chat_history_tool.py` |
| Citation rendering | `src/frontend/src/utils/citations.ts`, `src/frontend/src/components/citation/` |
-| Plan mode | `src/backend/orchestration/subagents/plan_mode.py`, `api/routes/v1/plans.py` |
-| Sub-agent tool | `src/backend/core/llm/subagents/tool.py`, `api/routes/v1/agents.py` |
-| Title summarization / context compaction / window protection | `src/backend/core/llm/summarizer.py`, `compaction.py`, `core/chat/compaction/service.py`, `context_manager.py` |
+| Plan mode | `src/backend/orchestration/plans/`, `api/routes/v1/plans.py` |
+| Sub-agent tool | `src/backend/core/llm/subagents/tool.py`, `edition_ee/routes/agents.py` |
+| Title summarization / context compaction / window protection | `src/backend/core/models/completions/summarizer.py`, `compaction.py`, `core/chat/compaction/service.py`, `context_manager.py` |
| Oversized-result offloading | `src/backend/core/llm/context/offloader.py` |
| Chat sharing | `src/backend/api/routes/v1/chat_shares.py` |
-| Follow-up generation | `src/backend/orchestration/followups.py` |
+| Follow-up generation | `src/backend/core/chat/followups.py` |
| Frontend stream parsing / follow-up queue | `src/frontend/src/hooks/chatStream.ts`, `useStreaming.ts`, `components/chat/QueuedMessageCard.tsx` |
### Desktop tool permissions and operation confirmation
diff --git a/document/en/modules/knowledge-base-wiki.md b/document/en/modules/knowledge-base-wiki.md
index db1ad00b..a25a7c8c 100644
--- a/document/en/modules/knowledge-base-wiki.md
+++ b/document/en/modules/knowledge-base-wiki.md
@@ -140,12 +140,12 @@ The tools work for both self-hosted and external knowledge bases; just pass the
| Layer | Location |
|---|---|
-| Data model | `src/backend/core/db/models/kb_wiki.py` |
-| Pipeline | `src/backend/core/kb/wiki/` (`extract` / `cite` / `dedup` / `taxonomy` / `reduce` / `finalize` / `pipeline`) |
-| Prompts | `src/backend/core/kb/wiki/prompts.py` |
-| Jobs and worker | `src/backend/core/kb/wiki/jobs.py`, `worker.py` |
-| Read surface | `src/backend/core/kb/wiki/local_provider.py` |
-| Per-base dispatch | `src/backend/core/kb/wiki_router.py` |
-| Routes | `src/backend/api/routes/v1/kb_wiki.py` |
+| Data model | `src/backend/features/knowledge/models.py` |
+| Pipeline | `src/backend/features/knowledge/wiki/` (`extract` / `cite` / `dedup` / `taxonomy` / `reduce` / `finalize` / `pipeline`) |
+| Prompts | `src/backend/features/knowledge/wiki/prompts.py` |
+| Jobs and worker | `src/backend/features/knowledge/wiki/jobs.py`, `worker.py` |
+| Read surface | `src/backend/features/knowledge/wiki/local_provider.py` |
+| Per-base dispatch | `src/backend/features/knowledge/wiki_router.py` |
+| Routes | `src/backend/features/knowledge/api/kb_wiki.py` |
| Agent tools | `src/backend/extensions/mcp_servers/retrieve_dataset_content_mcp/wiki_impl.py` |
| Frontend | `src/frontend/src/components/kb/` (`WikiPanel` / `WikiTree` / `ConceptGraph` / `IndexModePicker`) |
diff --git a/document/en/modules/knowledge-base.md b/document/en/modules/knowledge-base.md
index 1284c583..32edc8ed 100644
--- a/document/en/modules/knowledge-base.md
+++ b/document/en/modules/knowledge-base.md
@@ -68,9 +68,9 @@ ORM definitions live in `src/backend/core/db/models/knowledge.py`:
| `kb_chunks` | **Parent chunk** text (returned to the LLM on retrieval), with `tags` and related `questions` |
| `kb_assets` | **Media assets** carried by a document (figures cut out by layout parsing, standalone image uploads): bytes live in object storage; this table holds the owning chunk, the source position `locator`, the caption/OCR text `text_content`, and the model-generated description `caption` |
-The three Wiki tables (`kb_wiki_pages` / `kb_wiki_folders` / `kb_wiki_jobs`) are defined in `core/db/models/kb_wiki.py`; see [Knowledge Base Wiki](./knowledge-base-wiki.md).
+The three Wiki tables (`kb_wiki_pages` / `kb_wiki_folders` / `kb_wiki_jobs`) are defined in `features/knowledge/models.py`; see [Knowledge Base Wiki](./knowledge-base-wiki.md).
-Child chunks never touch the relational DB — they are vectorized into the Milvus collection `hugagent_kb_private` (`core/kb/kb_vector.py`); every row carries `user_id` / `kb_id` for ownership isolation, and `row_type` distinguishes chunk rows, question rows, and image rows.
+Child chunks never touch the relational DB — they are vectorized into the Milvus collection `hugagent_kb_private` (`features/knowledge/kb_vector.py`); every row carries `user_id` / `kb_id` for ownership isolation, and `row_type` distinguishes chunk rows, question rows, and image rows.
### Chunking and indexing
@@ -89,7 +89,7 @@ Parent-child parameters are tunable per upload via `indexing_config`: `parent_ch
### Multimodal: image retrieval
Figures inside documents are no longer discarded. Indexing runs through
-`core/kb/kb_assets.py`:
+`features/knowledge/kb_assets.py`:
1. **Extraction** — for PDFs, the external parse service's layout pass returns the figure
bytes along with its caption, page index and bbox; a standalone image upload is itself
@@ -98,7 +98,7 @@ Figures inside documents are no longer discarded. Indexing runs through
service's temp paths are rewritten to real asset URLs.
2. **Image understanding** — reuses the
[vision bridge](./model-providers.md#vision-bridge-giving-text-only-models-eyes)
- (`core/vision`): the same path and the same `vision` model role that lets a text-only
+ (`core/models/vision`): the same path and the same `vision` model role that lets a text-only
chat model read images. Three things come for free with it — an **evidence cache keyed
by image content** (re-indexing the same document costs nothing), three provider
protocols with structured-output degradation, and the hard constraint that only a
@@ -149,7 +149,7 @@ Returned content follows the `[ref:retrieve_local_kb-N]` citation-marker convent
### API routes
-User-facing routes are prefixed `/v1/catalog/kb` (`src/backend/api/routes/v1/kb.py`, request models in `kb_models.py`):
+User-facing routes are prefixed `/v1/catalog/kb` (`src/backend/features/knowledge/api/kb.py`, request models in `kb_models.py`):
| Method | Path | Description |
|---|---|---|
@@ -165,7 +165,7 @@ User-facing routes are prefixed `/v1/catalog/kb` (`src/backend/api/routes/v1/kb.
| GET / PATCH | `/v1/catalog/kb/{kb_id}/chunks[/{chunk_id}]` | Chunk list / edit tags and questions |
| GET | `/v1/catalog/kb/assets/{asset_id}` | Read a media asset's raw bytes (visibility follows the owning space) |
-Business logic is centralized in `core/services/kb_service.py::KBService`.
+Business logic is centralized in `features/knowledge/service.py::KBService`.
### System-managed space: MySpace sync
@@ -173,13 +173,13 @@ Business logic is centralized in `core/services/kb_service.py::KBService`.
### Admin public knowledge bases (Enterprise Edition, EE)
-The `/v1/admin/kb/*` admin routes live in `src/backend/api/routes/v1/admin_kb.py`, gated by the `content_admin` feature flag (EE router table in `api/routes/v1/__init__.py`). Public spaces are owned by the synthetic system account `system_public_kb` (`kb_service.py::SYSTEM_KB_OWNER_ID`), have `visibility=public`, and are visible and searchable by all users. The admin side additionally offers raw file download, Office-to-PDF online preview, and chunk content editing / deletion.
+The `/v1/admin/kb/*` admin routes live in `src/backend/edition_ee/routes/admin_kb.py`, gated by the `content_admin` feature flag (EE router table in `api/routes/v1/__init__.py`). Public spaces are owned by the synthetic system account `system_public_kb` (`kb_service.py::SYSTEM_KB_OWNER_ID`), have `visibility=public`, and are visible and searchable by all users. The admin side additionally offers raw file download, Office-to-PDF online preview, and chunk content editing / deletion.
Public retrieval through `retrieve_dataset_content` selects exactly one source according to platform configuration. Platform-local mode (`knowledge_base.provider=custom`) searches only local public/shared spaces accessible to the current user, excluding private spaces. External mode searches only the selected Dify, FastGPT or WeKnora backend using that backend's own connection settings. Results are never merged across sources, and empty results or errors never trigger a cross-source fallback. The public knowledge-base list uses the same source selection; private spaces remain accessible through the separate `retrieve_local_kb` tool.
## External Dify knowledge bases (Enterprise Edition, EE)
-The EE-only client is `src/backend/edition_ee/kb/dify.py`; shared routes call it through `core/kb/external_provider.py`. The derived CE tree replaces that seam with a disabled implementation and contains no Dify client. The `is_dify_enabled()` decision has three priority levels:
+The EE-only client is `src/backend/edition_ee/kb/dify.py`; shared routes call it through `features/knowledge/extension.py`. The derived CE tree replaces that seam with a disabled implementation and contains no Dify client. The `is_dify_enabled()` decision has three priority levels:
1. DB system config `knowledge_base.provider == "dify"` (editable in the Config console);
2. Environment variable `KNOWLEDGE_BASE=dify`;
@@ -213,17 +213,17 @@ The general-purpose parser `core/files/content/file_parser.py::parse_file()` (sh
| Path | Responsibility |
|---|---|
-| `src/backend/core/kb/kb_parser.py` | Document parsing + parent-child chunking (5 chunk methods) |
-| `src/backend/core/kb/kb_vector.py` | Milvus collection, embedding, hybrid search, reranking |
-| `src/backend/core/kb/kb_assets.py` | Media assets: storage, image understanding (via the vision bridge), retrieval rows, result attachment |
+| `src/backend/features/knowledge/kb_parser.py` | Document parsing + parent-child chunking (5 chunk methods) |
+| `src/backend/features/knowledge/kb_vector.py` | Milvus collection, embedding, hybrid search, reranking |
+| `src/backend/features/knowledge/kb_assets.py` | Media assets: storage, image understanding (via the vision bridge), retrieval rows, result attachment |
| `src/backend/edition_ee/kb/dify.py` | Dify datasets client and enablement logic (EE only) |
-| `src/backend/core/kb/external_provider.py` | Edition-neutral external-provider seam; disabled by the CE overlay |
-| `src/backend/core/files/content/kb_processing.py` | Background vectorization, LLM keyword / question enrichment |
+| `src/backend/features/knowledge/extension.py` | Edition-neutral external-provider seam; disabled by the CE overlay |
+| `src/backend/features/knowledge/processing.py` | Background vectorization, LLM keyword / question enrichment |
| `src/backend/core/files/content/file_validation.py` | Upload validation (extension + magic bytes) |
| `src/backend/core/files/content/file_parser.py` | General-purpose file parser |
-| `src/backend/core/services/kb_service.py` | KB business logic (incl. the system-managed sync space) |
-| `src/backend/api/routes/v1/kb.py` + `kb_models.py` | User-facing `/v1/catalog/kb` routes |
-| `src/backend/api/routes/v1/admin_kb.py` | Admin public KB routes (Enterprise Edition, EE) |
+| `src/backend/features/knowledge/service.py` | KB business logic (incl. the system-managed sync space) |
+| `src/backend/features/knowledge/api/kb.py` + `kb_models.py` | User-facing `/v1/catalog/kb` routes |
+| `src/backend/edition_ee/routes/admin_kb.py` | Admin public KB routes (Enterprise Edition, EE) |
| `src/backend/api/routes/v1/catalog.py` | Catalog aggregation (private only in CE; Dify / public spaces added in EE) |
| `src/backend/extensions/mcp_servers/retrieve_dataset_content_mcp/` | Retrieval MCP server (both tools) |
| `src/backend/core/db/models/knowledge.py` | `KBSpace` / `KBDocument` / `KBChunk` / `KBAsset` ORM |
diff --git a/document/en/modules/mcp-tools.md b/document/en/modules/mcp-tools.md
index ec2aed3e..404705c8 100644
--- a/document/en/modules/mcp-tools.md
+++ b/document/en/modules/mcp-tools.md
@@ -29,7 +29,7 @@ must execute in separate rounds.
│ agent) │ │ │ :9103 unified Industry Knowledge Center (EE)│
└─────────┘ │ │ :9104 reserved (retired chart MCP) │
│ │ │ :9105 reserved (former report export MCP) │
- MCPConnectionPool │ │ :9106 web_fetch │
+ Native MCP Client │ │ :9106 web_fetch │
mcp_pool.py) └───┬────┘ :9108 automation_task (automation plugin) │
│ 9109–9111 reserved (former office MCPs) │
│ :9112 skill_manager │
@@ -223,7 +223,7 @@ priority order:
injects `"cite_id": "eN"` into each entry in place; the model copies the id as-is.
2. **Standard list shape → one registry line**: list-style tools returning
`{"items": [{"title": …, "content": …}]}` just need an `items_paths` + field-alias
- entry in `orchestration/citation_anchor.py::TOOL_SPECS` — per-item numbering with no
+ entry in `core/llm/citations/hook.py::TOOL_SPECS` — per-item numbering with no
tool code change.
3. **Do nothing → automatic fallback**: a generic heuristic finds the unique dict-array
field at the top level (or one level under `result`) and numbers items; when nothing is
@@ -237,21 +237,18 @@ before the result enters the model context and before persistence; the frontend
render `cite_id` as per-item chips that match the `[anchor text](cite:eN)` references in
the answer body.
-## Backend client: connection pool & bare-name restoration
+## Backend client: native MCP lifetimes
-The backend connects through AgentScope 2.0's `MCPClient`, centred on two files:
+The backend uses MCP SDK 2.3 `Client(mode="auto")` and AgentScope `ToolBase`. `client.py` owns transport authorization; `lifetime.py` owns SDK scopes and complete paginated discovery. HTTP calls reuse the same client throughout a run. STDIO sessions use exclusive leases scoped to the executing user, chat and configuration; successful sessions remain idle for 60 seconds, while failed or cancelled calls destroy them. There is no stable-connection switch or startup connection pool.
-- **`core/llm/mcp/pool.py` — `MCPConnectionPool`** (process-level singleton): at startup `warmup_mcp_tools()` reads all enabled server configs from the DB and pre-connects. Pooling semantics under 2.0:
- - **stdio servers marked `is_stable=true`** keep their connection across requests (saving the 1–7 s subprocess cold start);
- - **HTTP servers are never pooled** — 2.0's stateful HTTP client is bound to its asyncio task, and reuse across requests triggers cancel-scope crashes, so each request gets a fresh `is_stateful=False` connection;
- - per-request servers (KB retrieval with user headers) connect on demand and are closed via `close_transient()` when the request ends.
-- **`core/llm/mcp/manager.py` — `BareNameMCPClient`**: AgentScope 2.0 rewrites tool names to `mcp____`; this subclass restores the server-side bare name (`internet_search`, not `mcp__internet_search__internet_search`) so the display-name mapping (`core/config/display_names.py`), [citation extraction](chat.md) keyed by tool name, and frontend icon rendering all keep working as in 1.x.
+`callbacks.py` binds isolated text sampling and user elicitation to the active run. Resources, URI templates and prompts become permission-governed tools. Progress reaches the existing tool card. Structured results and resource links remain in tool metadata and model-facing output. SDK Tasks extensions are not available in 2.3.
+
+MCP execution budgets exclude time waiting for user answers. Valid questions pause the current invocation; answering or cancelling a question resumes its remaining budget. Repeated or overlapping questions do not reset that budget. Other invocations keep independent timers. Existing human-interaction deadlines still apply, and cancelling a run clears pending questions. Invocation budgets bound SDK requests and HTTP reads; connection, write and discovery timeouts remain active.
-The 2.0 `Toolkit` is constructed once, in `core/llm/factory/build.py`: `Toolkit(tools=[...], mcps=clients)`.
## Unified tool-permission gateway
-`core/llm/tool_permissions.py` provides the run-scoped
+`core/llm/tool_permissions/` provides the run-scoped
`ToolPermissionRegistry` and `ToolPermissionMiddleware`. The registry is a
**governed-tool list**, not a mandatory declaration table:
@@ -270,7 +267,7 @@ The 2.0 `Toolkit` is constructed once, in `core/llm/factory/build.py`: `Toolkit(
tools become dispatchable.
Native tools may opt in with
-`ToolCollector.register_tool_function(..., permission=spec)`. The platform's
+`ToolAssembly.append(function_tool(..., dispatch_spec=spec))`. The platform's
local-file, Bash, and My Space rules live in `builtin_tool_permission()`.
Native tools with neither an explicit spec nor a built-in entry pass through.
@@ -297,7 +294,7 @@ tools that should be governed need a registry rule.
## Registration: DB-driven config + catalog gating
-The source of truth for MCP server configuration is the `admin_mcp_servers` table (ORM: `core/db/models.py::AdminMcpServer`), read through `core/capabilities/mcp/service.py::McpServerConfigService` with a 30-second TTL cache, in a dict format compatible with the legacy `MCP_SERVERS` (`transport / command / args / env / url / headers / is_stable`). `core/config/mcp_config.py` remains as the URL builder for the built-in servers (first-deployment seeds).
+The source of truth for MCP server configuration is the `admin_mcp_servers` table (ORM: `core/db/models/`), read through `core/capabilities/mcp/service.py::McpServerConfigService` with a 30-second TTL cache, using native client configuration (`transport / command / args / env / url / headers`). `core/config/mcp_config.py` remains as the URL builder for the built-in servers (first-deployment seeds).
Whether a server is **visible to the model** additionally passes through [catalog](catalog.md) gating: each entry in the `mcp` section of `core/config/catalog.json` corresponds to a server_id, and a server whose `is_enabled(id, "mcp_server")` is false will not be registered with the agent even if connected.
@@ -309,7 +306,7 @@ The `/config` system console's **MCP Tools → MCP Server Management** view maps
- **Probe-on-create**: `_probe_connectivity` performs a real connection; failures are rejected before persisting;
- **Toggle & ordering**: `POST /{id}/toggle` switches a server instantly (refreshing the catalog and the connection pool);
- **Secret protection**: HTTP header values are encrypted at rest and returned as `***`; secret-looking `env_vars` values are masked as well;
-- **Test & reload**: `POST /{id}/test` re-probes a single server; `POST /reload-pool` hot-rebuilds the connection pool.
+- **Test & reload**: `POST /{id}/test` re-probes a single server; `automatic cache invalidation` refreshes discovery on the next run.
- **Move to MCP Marketplace**: creates a credential-free marketplace snapshot
from an existing remote HTTP/SSE MCP and disables the original global
instance. The service then leaves the MCP server list and can be reviewed,
@@ -329,7 +326,7 @@ Regular users can add remote MCP servers visible **only to themselves** (`api/ro
- `POST /v1/me/mcp-servers`: add a private remote MCP over public HTTP or HTTPS using HTTP/SSE transports; the user entry point deliberately forbids stdio (no arbitrary command execution on the server), and DNS/IP validation blocks localhost, private, link-local, and reserved addresses; probe-on-create means unreachable endpoints are never persisted. HTTP is plaintext, so HTTPS remains recommended for production;
- `DELETE /v1/me/mcp-servers/{id}`: remove one's own private MCP.
-Implementation reuses the same `admin_mcp_servers` table: `owner_user_id` = current user for owner isolation, auto-generated `umcp_` server IDs to avoid clashes, and `is_stable=False` to keep them out of the warmup pool. HTTP header values are encrypted with Fernet at rest and decrypted only at runtime. The feature is gated by the per-user `can_add_mcp` permission flag (open by default in the single-tenant Community Edition; granted per user by organisation admins in the Enterprise Edition — see [editions](../editions/overview.md)).
+Implementation reuses the same `admin_mcp_servers` table: `owner_user_id` = current user for owner isolation, auto-generated `umcp_` server IDs to avoid clashes. HTTP header values are encrypted with Fernet at rest and decrypted only at runtime. The feature is gated by the per-user `can_add_mcp` permission flag (open by default in the single-tenant Community Edition; granted per user by organisation admins in the Enterprise Edition — see [editions](../editions/overview.md)).
## MCP marketplace
@@ -409,19 +406,19 @@ docker-compose up -d --build mcp
| `src/backend/extensions/mcp_servers/_serve.py` | Unified stdio / streamable-http entry point |
| `src/backend/extensions/mcp_servers/registry.py` | Installation placement, exposure, packages, and base ports |
| `src/backend/extensions/mcp_servers/_ports.py` | Deployment port mapping |
-| `src/backend/core/llm/mcp/pool.py` | MCP connection pool (stdio pooled / HTTP per-request) |
+| `src/backend/core/llm/mcp/lifetime.py` | Native MCP client ownership and discovery |
| `src/backend/core/llm/mcp/manager.py` | MCPClient construction + bare tool-name restoration |
-| `src/backend/core/llm/tool_permissions.py` | Governed-tool registry, decisions, approval routing, and execution tickets |
+| `src/backend/core/llm/tool_permissions/` | Governed-tool registry, decisions, approval routing, and execution tickets |
| `src/backend/core/capabilities/mcp/service.py` | DB-driven server config service (30 s cache) |
-| `src/backend/core/services/mcp_marketplace_service.py` | Marketplace publishing, review, installation, visibility, and security controls |
+| `src/backend/features/marketplace/` | Marketplace publishing, review, installation, visibility, and security controls |
| `src/backend/core/capabilities/mcp/oauth.py` | OAuth 2.1 login, SDK metadata discovery, encrypted token storage, and refresh |
-| `src/backend/core/services/mcp_marketplace_monitor.py` | Periodic remote tool-snapshot revalidation and drift monitoring |
+| `src/backend/features/marketplace/` | Periodic remote tool-snapshot revalidation and drift monitoring |
| `src/backend/core/config/mcp_config.py` | Built-in server URL builder (http://mcp:NNNN/mcp/) |
| `src/backend/core/config/catalog.json` | Capability catalog: MCP enable/disable seeds |
| `src/backend/api/routes/v1/admin_mcp_servers.py` | Admin custom-MCP API |
| `src/backend/api/routes/v1/me_capabilities.py` | User self-service private MCP / skill API |
-| `src/backend/api/routes/v1/mcp_marketplace.py` | User-facing MCP marketplace API |
-| `src/backend/api/routes/v1/admin_mcp_marketplace.py` | Admin MCP marketplace and review API |
+| `src/backend/features/marketplace/api/mcp_marketplace.py` | User-facing MCP marketplace API |
+| `src/backend/features/marketplace/api/admin_mcp_marketplace.py` | Admin MCP marketplace and review API |
| `docker/Dockerfile.mcp` | mcp container image (MCP runtime, plotting dependencies, and CJK fonts) |
Related docs: [Capability catalog](catalog.md) · [Agent skills](agent-skills.md) · [Knowledge base](knowledge-base.md) · [Editions & licensing](../editions/overview.md)
diff --git a/document/en/modules/memory.md b/document/en/modules/memory.md
index 328e9364..77acffa8 100644
--- a/document/en/modules/memory.md
+++ b/document/en/modules/memory.md
@@ -68,7 +68,7 @@ save_memories_background()
· sanitizer gate → write L1/L2/L3/session → audit side channel
```
-The integration layer for retrieval and injection lives in `src/backend/orchestration/memory_integration.py`. mem0's LLM / embedder / Milvus / reranker configuration is assembled in `src/backend/core/memory/service.py`, while Neo4j graph reads and writes live in `src/backend/core/memory/graph.py`. Model configs are resolved from the DB roles `memory` / `embedding` first, falling back to environment variables.
+The integration layer for retrieval and injection lives in `src/backend/core/agent_runtime/integrations/memory.py`. mem0's LLM / embedder / Milvus / reranker configuration is assembled in `src/backend/core/memory/service.py`, while Neo4j graph reads and writes live in `src/backend/core/memory/graph.py`. Model configs are resolved from the DB roles `memory` / `embedding` first, falling back to environment variables.
## Write pipeline and extractors
@@ -140,7 +140,7 @@ Two independent toggles, both stored in `users_shadow.metadata` (ORM column `ext
| `memory_enabled` | Persistent memory **read**: inject the frozen block at session start | `false` |
| `memory_write_enabled` | **Write**: extract and save memories after each conversation | `false` |
-Project chats get their own scope: personal projects and the default workspace use the real `user_id`, while team projects use `scope_user_id = "team:"` — all team members write into the same mem0 bucket for shared recall, with the real author preserved in `metadata.author_user_id` (see `orchestration/memory_integration.py::save_memories_background` and `api/routes/v1/memories.py::list_memories`). Project-level toggles live in `projects.metadata` (`memory_enabled` / `memory_write_enabled`, defaulting to `true` inside a project); see [Projects & MySpace](./projects-myspace.md).
+Project chats get their own scope: personal projects and the default workspace use the real `user_id`, while team projects use `scope_user_id = "team:"` — all team members write into the same mem0 bucket for shared recall, with the real author preserved in `metadata.author_user_id` (see `core/agent_runtime/integrations/memory.py::save_memories_background` and `api/routes/v1/memories.py::list_memories`). Project-level toggles live in `projects.metadata` (`memory_enabled` / `memory_write_enabled`, defaulting to `true` inside a project); see [Projects & MySpace](./projects-myspace.md).
## Frontend memory center
@@ -233,8 +233,8 @@ See the [environment variable reference](../deployment/environment-variables.md)
| `src/backend/core/memory/sanitizer.py` | Sanitizer gate (hardcoded + DB-managed rules) |
| `src/backend/core/memory/audit.py` | Audit side channel (Enterprise Edition, EE) |
| `src/backend/core/memory/context.py` | `MemoryContext` and workspace / layer resolution |
-| `src/backend/orchestration/memory_integration.py` | Retrieval launch, frozen-block assembly and injection, save delegation |
-| `src/backend/orchestration/workflow.py` | Main orchestration: memory hook wiring |
+| `src/backend/core/agent_runtime/integrations/memory.py` | Retrieval launch, frozen-block assembly and injection, save delegation |
+| `src/backend/core/agent_runtime/runner.py` | Main orchestration: memory hook wiring |
| `src/backend/api/routes/v1/memories.py` | `/v1/memories` management API |
| `src/backend/core/db/models/memory.py` | `ProfileMemory`, `MemoryOutbox`, and `MemorySanitizerRule` ORM |
| `src/backend/edition_ee/db/models/memory.py` | `MemoryAudit` ORM (EE only) |
diff --git a/document/en/modules/model-providers.md b/document/en/modules/model-providers.md
index bd31c831..bea68107 100644
--- a/document/en/modules/model-providers.md
+++ b/document/en/modules/model-providers.md
@@ -9,7 +9,7 @@ In Config → Model Management (EE), use the switch in the provider list's Statu
## Configuring models: providers + roles
-Two tables (`core/db/models.py`): `model_providers` (base_url / api_key / model_name / extra_config / is_active) and `model_role_assignments` (role_key → provider_id). Roles are defined in `core/db/model_repository.py::ROLE_DEFINITIONS`:
+Two tables (`core/db/models/`): `model_providers` (base_url / api_key / model_name / extra_config / is_active) and `model_role_assignments` (role_key → provider_id). Roles are defined in `core/models/repository.py::ROLE_DEFINITIONS`:
| role_key | Purpose | Type |
|---|---|---|
@@ -36,6 +36,8 @@ Precedence is **shared subagent model → an agent's existing explicit model →
### Wire protocol: Responses by default, chat completions only as a fallback
+The pinned AgentScope version natively parses raw reasoning and summary events from Responses. The platform preserves each event's `item_id` and part index to prevent streaming failures and keep distinct reasoning items and parts separate.
+
OpenAI-compatible endpoints come in two wire protocols: the older `POST /chat/completions` and the newer `POST /responses`. The platform **defaults to Responses**, because it carries the model's reasoning as a first-class `reasoning` item that can be handed back verbatim on the next turn; `/chat/completions` has nowhere to put reasoning, so it has to be smuggled through a `reasoning_content` field. Chat completions is used only for endpoints that genuinely do not route `/responses` — commonly thin relay gateways.
The choice lives in `extra_config.api_protocol` (`responses` / `chat_completions`). It is never inferred from the vendor name: saving a provider probes the endpoint, and only an endpoint that clears both stages is recorded as Responses-capable.
@@ -124,7 +126,7 @@ Retry policy: the model layer runs with `max_retries=0`; the agent layer's `Mode
## Dynamic model switching (chat_mode)
-Each frontend message may carry `chat_mode` (`turbo / fast / medium / high / max`); before the reply starts, `core/llm/middlewares.py::DynamicModelMiddleware` (`on_reply`) hot-swaps `agent.model`:
+Each frontend message may carry `chat_mode` (`turbo / fast / medium / high / max`); before the reply starts, `core/llm/runtime/` (`on_reply`) hot-swaps `agent.model`:
```
chat_mode → hooks._resolve_chat_mode(agent.state)
@@ -134,7 +136,7 @@ chat_mode → hooks._resolve_chat_mode(agent.state)
high/max → reasoning_effort=high/max (endpoint must declare support)
```
-Legacy clients that only send `enable_thinking` are mapped to medium / fast. This is the AgentScope 2.0 incarnation of the old 1.x "pre_reply dynamic-model hook" — the hook factories were refactored into middlewares, and `core/llm/runtime/hooks.py` now only hosts pure-function helpers.
+Legacy clients that only send `enable_thinking` are mapped to medium / fast. This is the AgentScope 2.0 incarnation of the old 1.x "pre_reply dynamic-model hook" — the hook factories were refactored into middlewares, and `core/harness/hooks.py` now only hosts pure-function helpers.
## Environment variables and MCP subprocess injection
@@ -157,7 +159,7 @@ Endpoints: `GET/POST /v1/me/api-keys`, `PATCH /v1/me/api-keys/{id}` (toggle), `D
## Billing and usage
-Token usage is accumulated into the end-of-stream `meta.usage` (`orchestration/streaming.py` sums `ModelCallEndEvent`s) and persisted with the message. Two console report groups sit on top (`CONFIG_TOKEN`):
+Token usage is accumulated into the end-of-stream `meta.usage` (`core/llm/runtime/streaming.py` sums `ModelCallEndEvent`s) and persisted with the message. Two console report groups sit on top (`CONFIG_TOKEN`):
- **Usage logs** `api/routes/v1/admin_usage_logs.py`: `GET /v1/admin/usage-logs` (detail), `/summary` (aggregate), `/models` (distinct model names).
- **Billing reports** `api/routes/v1/admin_billing.py` (**Enterprise Edition (EE)** — part of the full admin console): `GET /v1/admin/billing/summary` (cost aggregated by user/model), model pricing CRUD (`/pricing`, input/output unit prices, currency), `GET /v1/admin/billing/export` (CSV cost export).
@@ -166,21 +168,21 @@ Community Edition users can see their own token usage; organization-level billin
## Routing strategy (ROUTER_STRATEGY)
-`orchestration/strategy.py`: `ROUTER_STRATEGY=main_only` (default) always routes to the main agent; `llm_router` is a reserved placeholder that currently also falls back to `MainOnlyStrategy` (safe by default). Actual multi-agent dispatch happens via `@mentions` + the `call_subagent` tool — see [Chat & Agent Orchestration](chat.md).
+`core/agent_runtime/strategy.py`: `ROUTER_STRATEGY=main_only` (default) always routes to the main agent; `llm_router` is a reserved placeholder that currently also falls back to `MainOnlyStrategy` (safe by default). Actual multi-agent dispatch happens via `@mentions` + the `call_subagent` tool — see [Chat & Agent Orchestration](chat.md).
## Source map
| Topic | Path |
|---|---|
-| Model factory / JxOpenAIChatModel | `src/backend/core/llm/chat_models.py` |
-| Role resolution service (DB + cache) | `src/backend/core/services/model_config.py` |
-| Role definitions / provider repository | `src/backend/core/db/model_repository.py` |
+| Model factory / JxOpenAIChatModel | `src/backend/core/models/chat_models.py` |
+| Role resolution service (DB + cache) | `src/backend/core/models/config.py` |
+| Role definitions / provider repository | `src/backend/core/models/repository.py` |
| Management API | `src/backend/api/routes/v1/models.py` |
-| Dynamic-model middleware | `src/backend/core/llm/middlewares.py::DynamicModelMiddleware`, helpers in `core/llm/runtime/hooks.py` |
-| MCP env injection | `src/backend/core/services/model_config.py::get_mcp_env_overlay`, `core/config/runtime_env.py` |
+| Dynamic-model middleware | `src/backend/core/llm/runtime/`, helpers in `core/harness/hooks.py` |
+| MCP env injection | `src/backend/core/models/config.py::get_mcp_env_overlay`, `core/config/runtime_env.py` |
| Personal API keys | `src/backend/api/routes/v1/api_keys.py`, `core/auth/api_keys.py`, `core/auth/backend.py` |
| Usage logs / billing | `src/backend/api/routes/v1/admin_usage_logs.py`, `api/routes/v1/admin_billing.py` |
-| Routing strategy | `src/backend/orchestration/strategy.py` |
+| Routing strategy | `src/backend/core/agent_runtime/strategy.py` |
| Fail-fast on missing main model | `src/backend/api/routes/v1/chats/__init__.py::_ensure_main_model_configured` |
### Child cancellation and parameter overrides
diff --git a/document/en/modules/personal-agent.md b/document/en/modules/personal-agent.md
index ae667c61..9bf7a32a 100644
--- a/document/en/modules/personal-agent.md
+++ b/document/en/modules/personal-agent.md
@@ -217,24 +217,18 @@ When a Linux desktop runtime has no input driver, the connection offers session
Prepare Enterprise Edition, PostgreSQL, Redis, and the OpenSandbox images using the existing deployment guide.
-1. Build the computer image: `docker build -f docker/Dockerfile.personal-agent -t hugagent-personal-agent:v5 .`. Do not overwrite a tag that a running computer uses.
+1. Build the computer image: `docker build -f docker/personal-agent/Dockerfile.personal-agent -t hugagent-personal-agent:v5 .`. Do not overwrite a tag that a running computer uses.
2. Set HOST_STORAGE_PATH to an absolute host path.
3. Set PERSONAL_AGENT_OPENSANDBOX_API_KEY to a separate random secret of at least 32 characters.
4. Set PERSONAL_AGENT_OPENSANDBOX_DOMAIN to personal-agent-opensandbox:8080.
5. Set PERSONAL_AGENT_IMAGE to hugagent-personal-agent:v5. Existing computers use the new image when they are recreated.
-6. Run the root Compose file with docker/compose.personal-agent.yml and the personal-agent profile.
+6. Run the root Compose file with docker/personal-agent/compose.personal-agent.yml and the personal-agent profile.
7. Apply the existing database migration procedure. Apply personalagent01 through personalagent05. These migrations create module tables, backfill message links, create task sessions, and add device session operations.
The extra Compose file creates a separate controller, compute network, and private configuration volume. The controller needs Docker socket access. Do not commit credentials. The existing database encryption key protects sandbox and email credentials.
Missing Personal Agent controller settings cause a failure. The service does not fall back to the ordinary controller. The worker checks permanent computers once a minute. Pausing the task queue preserves the computer.
-## Verification
-
-Tests cover identity, concurrency, leases, approvals, revocation, history pagination, and ordinary chat restrictions. Isolated service tests also cover real models, cloud screenshots and input, persistent files, SMTP/IMAP attachments, and the frontend flow.
-
-Ethereal captures test email. It does not prove delivery to normal Internet inboxes. Mac and Windows native desktop tests require their respective systems. See the internal acceptance record for passed and pending checks.
-
Wait until the agent has replied to every message before you downgrade personalagent02. The downgrade archives task links and scoped instructions in message metadata. Upgrading again restores them.
## Status panel
diff --git a/document/en/modules/projects-myspace.md b/document/en/modules/projects-myspace.md
index b592ab92..2435daf7 100644
--- a/document/en/modules/projects-myspace.md
+++ b/document/en/modules/projects-myspace.md
@@ -35,7 +35,7 @@ Key ORM models (`src/backend/core/db/models/`):
## Projects
-Routes: `src/backend/api/routes/v1/projects.py` (CE router table); business logic in `core/projects/service.py` and `project_file_service.py`.
+Routes: `src/backend/api/routes/v1/projects.py` (CE router table); business logic in `core/projects/` and `project_file_service.py`.
| Method | Path | Description |
|---|---|---|
@@ -76,11 +76,11 @@ When a conversation is started inside a project (the request carries `project_id
1. Project metadata is loaded — `project_name`, `project_instructions`, the linked folder name and working directory. Instructions still come from `AGENTS.md`; cloud and local project files are not recursively enumerated;
2. `core/llm/factory/build.py` injects these into the system prompt via `_build_project_section` (`build_system_prompt(cfg, ctx=...)`);
3. The project-level memory scope takes effect: the workspace becomes `project:`; team projects use the mem0 bucket `team:` (members share memories), and the project's own `metadata.memory_enabled` / `memory_write_enabled` override the user-level toggles (defaulting to on inside projects) — see [Memory System](./memory.md);
-4. Sandbox path scoping: in project conversations, the agent's `/myspace/...` file operations are redirected under the linked folder (the explicit `ProjectScope` parameter mechanism in `core/llm/tools/myspace_vfs/__init__.py`).
+4. Sandbox path scoping: in project conversations, the agent's `/myspace/...` file operations are redirected under the linked folder (the explicit `ProjectScope` parameter mechanism in `core/files/spaces/`).
Custom project-mode prompt templates use project names, directory scope and instructions. Before upgrading an existing deployment, remove obsolete file-inventory and file-count placeholders from saved templates; the runtime no longer supplies those variables.
-Team project permissions follow team roles: owner/admin always have admin rights; members are governed by `file_permission` (editor/viewer) (`core/auth/permissions_iface.py::require_project_access`).
+Team project permissions follow team roles: owner/admin always have admin rights; members are governed by `file_permission` (editor/viewer) (`core/auth/personal_permissions_iface.py::require_project_access`).
### Project sidebar controls
@@ -167,7 +167,7 @@ Permission model: `TeamMember.role` (owner/admin/member) + `file_permission` (vi
Three complementary paths:
-1. **Attachment injection (hooks)**: frontend requests send only `file_id`, `name`, and `mime_type` in each `attachments[]` item, never the file body. `core/llm/runtime/hooks.py` lazily downloads and parses by `file_id`, caching `parsed_text` and its summary; the first turn injects only metadata and a bounded preview of at most 5,000 characters, while xlsx previews also include total dimensions, headers, and a few data rows. The model reads content beyond the preview on demand through `read_artifact`; images still use multimodal injection. Every read validates `user_id` ownership to block forged cross-user access;
+1. **Attachment injection (hooks)**: frontend requests send only `file_id`, `name`, and `mime_type` in each `attachments[]` item, never the file body. `core/harness/hooks.py` lazily downloads and parses by `file_id`, caching `parsed_text` and its summary; the first turn injects only metadata and a bounded preview of at most 5,000 characters, while xlsx previews also include total dimensions, headers, and a few data rows. The model reads content beyond the preview on demand through `read_artifact`; images still use multimodal injection. Every read validates `user_id` ownership to block forged cross-user access;
2. **Project file access**: a project conversation's system prompt supplies project instructions and directory scope without an automatic file inventory or count. The agent discovers files on demand through directory, search and read tools;
3. **Sandbox virtual filesystem**: with code execution enabled, the `/myspace/...` path maps MySpace into the sandbox (lazy loading + reverse sync); team projects map the team folder — see [Sandbox](./sandbox.md).
@@ -176,8 +176,8 @@ Three complementary paths:
| Path | Responsibility |
|---|---|
| `src/backend/api/routes/v1/projects.py` | Projects API |
-| `src/backend/core/projects/service.py` / `project_file_service.py` | Project business logic |
-| `src/backend/core/projects/scope.py` | `ProjectScope` (sandbox path scoping) |
+| `src/backend/core/projects/` / `project_file_service.py` | Project business logic |
+| `src/backend/core/projects/personal_scope.py` | `ProjectScope` (sandbox path scoping) |
| `src/backend/api/routes/v1/myspace_folders.py` | Personal folders API |
| `src/backend/api/routes/v1/artifacts.py` | Asset list / chat favorites / add-to-KB |
| `src/backend/edition_ee/routes/team_files.py` | Team folders & files API (Enterprise Edition, EE) |
@@ -186,9 +186,9 @@ Three complementary paths:
| `src/backend/core/db/models/identity.py` | Shared identity ORM such as `UserFolder` |
| `src/backend/edition_ee/db/models/identity.py` | `Team` / `TeamMember` / `TeamFolder` ORM (EE only) |
| `src/backend/core/db/models/artifact.py` | `Artifact` ORM |
-| `src/backend/core/llm/runtime/hooks.py` | Attachment context injection (`_build_file_context`, etc.) |
+| `src/backend/core/harness/hooks.py` | Attachment context injection (`_build_file_context`, etc.) |
| `src/backend/core/llm/factory/build.py` | Project section injection into the system prompt |
-| `src/backend/core/llm/tools/myspace_vfs/__init__.py` | MySpace ↔ sandbox mapping layer |
+| `src/backend/core/files/spaces/` | MySpace ↔ sandbox mapping layer |
| `src/frontend/src/components/projects/` | Project frontend components |
| `src/frontend/src/components/sidebar/Sidebar.tsx` | Sidebar project groups, quick chat creation, and project actions |
| `src/frontend/src/components/myspace/` | MySpace frontend components |
@@ -272,13 +272,13 @@ never restore duplicate records into the running service.
### Space file tools
-Space file tools share the `core/llm/tools/space_tools.py` registration entry point: `space_list_myspace_files`, `space_stage_myspace_file`, `space_create_folder`, `space_move`, and `space_delete`. EE also provides `space_list_team_files`. Generic Read/Write/Edit/Glob/Grep names remain unchanged.
+Space file tools share the `core/files/spaces/` registration entry point: `space_list_myspace_files`, `space_stage_myspace_file`, `space_create_folder`, `space_move`, and `space_delete`. EE also provides `space_list_team_files`. Generic Read/Write/Edit/Glob/Grep names remain unchanged.
Favorite-chat tools have been removed; historical chats remain accessible through `list_related_chats` and `read_chat`.
### Unified mounted-space synchronization
-Personal listeners live in `core/space_sync/`; team adapters live in `edition_ee/services/space_sync/`. Callbacks enqueue in memory, workers batch a durable SQLite journal outside writable mounts, then update database metadata and object storage with authorization and version checks. Native moves retain file/folder IDs. Empty directories, deletion, restart recovery and parsed-cache invalidation share this pipeline. Committed database changes refresh registered mounts; rollback publishes nothing. Conflicts preserve local bytes and fail synchronization barriers. Network transfers and human approval are outside callback latency; measure complete synchronization in the actual deployment.
+Personal listeners live in `core/files/spaces/`; team adapters live in `edition_ee/services/workspace_adapter.py`. Callbacks enqueue in memory, workers batch a durable SQLite journal outside writable mounts, then update database metadata and object storage with authorization and version checks. Native moves retain file/folder IDs. Empty directories, deletion, restart recovery and parsed-cache invalidation share this pipeline. Committed database changes refresh registered mounts; rollback publishes nothing. Conflicts preserve local bytes and fail synchronization barriers. Network transfers and human approval are outside callback latency; measure complete synchronization in the actual deployment.
### Project environment
diff --git a/document/en/modules/prompts.md b/document/en/modules/prompts.md
index 44e044d5..752eb6da 100644
--- a/document/en/modules/prompts.md
+++ b/document/en/modules/prompts.md
@@ -49,7 +49,7 @@ Pool management and runtime assembly share this read path. After an active versi
### Execution manifest and runtime binding
-Before an Agent can execute, `core/llm/runtime/execution_manifest.py::PromptManifestBuilder` records the exact final request surface. Every ordered prompt section records `id`, origin, trust level, priority, cache class, budget, token estimate, content hash and version. Every final tool definition similarly hashes its name, complete description, canonical parameter schema, permission policy and recovery policy. Tool definitions are sorted by stable id; prompt sections retain their deliberate prefix order.
+Before an Agent can execute, `core/harness/execution_manifest.py::PromptManifestBuilder` records the exact final request surface. Every ordered prompt section records `id`, origin, trust level, priority, cache class, budget, token estimate, content hash and version. Every final tool definition similarly hashes its name, complete description, canonical parameter schema, permission policy and recovery policy. Tool definitions are sorted by stable id; prompt sections retain their deliberate prefix order.
The builder emits `prompt_hash`, `prompt_manifest_hash`, `tool_manifest_hash`, `context_hash` and one `aggregate_hash`. Manifest construction and AgentScope model input consume the same run-scoped frozen tool/skill snapshot, so the recorded surface cannot differ from the request. Progressive plugin activation invalidates that snapshot and publishes an explicit next `surface_generation` before the next model call.
@@ -100,8 +100,8 @@ their own enablement state.
| kind | Runtime consumer | Resolution order |
|---|---|---|
| `code_exec` | `factory/build.py` appends this segment (the code-capability prompt) to the system prompt when `CODE_CAPABILITY_ENABLED=true`; the single source of truth is `prompt_version_service.render_code_capability_segment()`, also used by the console preview | active DB version → `prompts/prompt_text/code_exec/system/*.system.md` |
-| `distillation` | Skill distillation (`core/llm/skill_distiller.py`, distilling conversation trajectories into reusable skills) | active DB version → `prompts/prompt_text/distillation/skill_distiller.system.md` |
-| `plan_mode` | The plan-generation sub-agent (`orchestration/subagents/plan_mode.py::_load_plan_prompt`) | active `plan_mode` DB version → legacy `system/90_plan_mode` part → `prompts/prompt_text/plan_mode/plan_mode.system.md` → hardcoded fallback |
+| `distillation` | Skill distillation (`features/distillation/`, distilling conversation trajectories into reusable skills) | active DB version → `prompts/prompt_text/distillation/skill_distiller.system.md` |
+| `plan_mode` | The plan-generation sub-agent (`orchestration/plans/`) | active `plan_mode` DB version → legacy `system/90_plan_mode` part → `prompts/prompt_text/plan_mode/plan_mode.system.md` → hardcoded fallback |
| `turbo` | Turbo mode (when the chat-mode picker is set to Turbo, `factory/build.py` **replaces** the assembled main system prompt with this one). The tool set is admin-configurable via Config "System Config → Turbo" (`turbo.mcp_server_ids`, defaulting to internet search / web fetch / KB retrieval, **independent of catalog enable/disable state**); explicitly summoned skills / @sub-agents / plugins may join per turn (`turbo.manual_invoke_enable`); the react-iteration cap is configurable (`turbo.max_iters`, default 4). The single source of truth is `prompt_version_service.render_turbo_system_prompt()` | active `turbo` DB version → `prompts/prompt_text/turbo/turbo.system.md` → hardcoded fallback |
Sub-agents do not use the full pool assembly: `prompt_runtime.py::build_subagent_system_prompt()` builds around the user-defined `system_prompt`, reusing the `20_tools_policy` / `65_citations` / `60_format` parts from the active version (or files). See [Chat & Agent Orchestration](chat.md).
diff --git a/document/en/modules/sandbox.md b/document/en/modules/sandbox.md
index b65116e4..640e625f 100644
--- a/document/en/modules/sandbox.md
+++ b/document/en/modules/sandbox.md
@@ -65,7 +65,7 @@ The runtime retains at most 64 process records and only evicts completed records
The sandbox identity is resolved by `resolve_sandbox_session(sandbox_session_id, chat_id)`: a non-empty explicit ID wins, while a missing or empty value falls back to `chat_id`. The main agent, plan execution, batch items, and every subagent therefore use one conversation sandbox, and a child run never destroys it when it finishes.
-**MySpace real-time registration**: a file written under `/myspace` **is** a file in the user's My Space, and both sides must show the same state at any moment. Registration is driven by **the filesystem itself** (`core/myspace/watcher.py`): any write or delete under the mirror directory `myspace_cache/{uid}/` is registered back into the artifact ledger, **regardless of who wrote it**.
+**MySpace real-time registration**: a file written under `/myspace` **is** a file in the user's My Space, and both sides must show the same state at any moment. Registration is driven by **the filesystem itself** (`core/files/spaces/`): any write or delete under the mirror directory `myspace_cache/{uid}/` is registered back into the artifact ledger, **regardless of who wrote it**.
The earlier design had each writing tool register its own change (write / edit / file delete-move-mkdir / a before-and-after directory snapshot around `Bash`). That assumes changes can only arrive through those entry points, and they cannot: a `nohup`-ed background process writes after the command returns, sub-agents and batch runs write from another coroutine, skill CLIs write directly, and so do MCP servers. Every entry point left out shows up the same way — the file sits on the user's disk, is invisible and undeletable in the UI, and every new sandbox mounts that directory again, which is where "leftover intermediate files from the previous session" came from.
@@ -86,9 +86,7 @@ Other points:
- **The reference point is the artifact record, not the mirror cache**: with the bind mount on, the sandbox's `/workspace/myspace/{uid}` *is* the backend's `myspace_cache/{uid}`, so "sandbox file matches mirror cache" is trivially true and can no longer mean "already registered". That mismatch used to make write-back a permanent no-op.
- **"Newer on disk than in the ledger" is the only signal**: when the backend writes into the mirror itself (materialising a UI upload, restoring a declined change) it aligns the file's mtime to the registration time, so the watcher never mistakes the backend's own write for a fresh sandbox change — no separate "I wrote this" bookkeeping is needed.
- **Every worker watches; a claim deduplicates**: the confirmation bar lives in the process running that chat, so the watcher has to run in every worker (the cost is inotify watches scaling as workers × directories against `fs.inotify.max_user_watches`; raise it on deployments with very many folders — exceeding it drops events **silently**); one change is still handled once, via the claim in `core.infra.ephemeral`, and preferably by the worker hosting that user's chat (only it can raise the bar).
-- **When the sandbox directory is not on this host** (`script_runner` / `cube`: the session workspace lives inside a container, or the whole sandbox is remote), `core/myspace/sandbox_sync.py` copies the sandbox's current state into the mirror directory after the command, and everything downstream is identical.
-
-The backlog that accumulated before this shipped is a **one-off data migration**: run `scripts/reconcile_myspace_mirror.py` by hand once (`--dry-run` first; add `--prune-stale` to clear leftover copies). It is deliberately not a scan that runs on every startup — that would be wasted work, and it would paper over real gaps in the live path.
+- **When the sandbox directory is not on this host** (`script_runner` / `cube`: the session workspace lives inside a container, or the whole sandbox is remote), `core/files/spaces/sandbox.py` copies the sandbox's current state into the mirror directory after the command, and everything downstream is identical.
### Large artifact delivery
@@ -185,8 +183,8 @@ fix on both execution surfaces. Updating only the cloud does not fix old clients
## Administrator sandbox management
-- **Read-only monitoring (security console)**: `api/routes/v1/config_security.py` exposes `/v1/config/security/sandbox/*` — overview, instance list, per-instance detail, snapshot list, rebuild history and effective configuration; everything goes through the providers' `admin_*` interfaces, with the UI trimmed by each provider's `admin_capabilities()` declaration (script_runner can't enumerate instances, so those columns are greyed out).
-- **Dependency rebuild (Enterprise EE)**: `api/routes/v1/admin_sandbox.py` (`/v1/admin/sandbox/*`) aggregates the pip/apt dependencies declared by all skills (`core/capabilities/skills/deps/aggregator.py`) and lets an admin trigger a sandbox image rebuild with one click: the `script-runner` / `opensandbox` targets run a local `docker compose build`, while the `cube` target rebuilds the template on the remote node over SSH and hot-swaps it (`core/services/sandbox_rebuild_service.py` + `cube_template_builder`), with per-run status and logs. New skill dependencies get baked into the images without hand-editing Dockerfiles.
+- **Read-only monitoring (security console)**: `features/security_console/api/config_security.py` exposes `/v1/config/security/sandbox/*` — overview, instance list, per-instance detail, snapshot list, rebuild history and effective configuration; everything goes through the providers' `admin_*` interfaces, with the UI trimmed by each provider's `admin_capabilities()` declaration (script_runner can't enumerate instances, so those columns are greyed out).
+- **Dependency rebuild (Enterprise EE)**: `features/sandbox_ops/api/admin_sandbox.py` (`/v1/admin/sandbox/*`) aggregates the pip/apt dependencies declared by all skills (`core/capabilities/skills/deps/aggregator.py`) and lets an admin trigger a sandbox image rebuild with one click: the `script-runner` / `opensandbox` targets run a local `docker compose build`, while the `cube` target rebuilds the template on the remote node over SSH and hot-swaps it (`features/sandbox_ops/rebuild.py` + `cube_template_builder`), with per-run status and logs. New skill dependencies get baked into the images without hand-editing Dockerfiles.
## Key environment variables
@@ -220,17 +218,17 @@ Full list in the [environment variable reference](../deployment/environment-vari
| `src/backend/core/sandbox/factory.py` | Explicit provider selection; initialization errors propagate |
| `src/backend/core/sandbox/providers/script_runner/provider.py` | Lightweight sandbox (CE) |
| `src/backend/core/sandbox/providers/opensandbox/provider.py` | Shared OpenSandbox provider |
-| `src/backend/core/sandbox/providers/opensandbox/session.py` | Sessions / snapshots / park-restore workers (EE) |
+| `src/backend/core/sandbox/providers/opensandbox/` | Sessions / snapshots / park-restore workers (EE) |
| `src/backend/core/sandbox/providers/opensandbox/execution.py` | Execution path + idle reaper (EE) |
-| `src/backend/core/sandbox/providers/opensandbox/internals.py` | Volume builders, metadata, user pool (EE) |
+| `src/backend/core/sandbox/providers/opensandbox/` | Volume builders, metadata, user pool (EE) |
| `src/backend/core/sandbox/providers/opensandbox/pool.py` | Two-bucket warm pool |
| `src/backend/core/sandbox/providers/cube/provider.py` | Cube remote-MicroVM provider (EE) |
| `src/backend/core/llm/tools/sandbox_tool.py` | Bash / write_stdin / sandbox_put_artifact / sandbox_get_artifact |
| `src/backend/core/llm/context/offloader.py` | Overflow offloading to the session workspace |
-| `src/backend/api/routes/v1/admin_sandbox.py` | Dependency-rebuild admin API (EE) |
-| `src/backend/api/routes/v1/config_security.py` | Security-console read-only sandbox views |
-| `src/backend/core/services/sandbox_rebuild_service.py` | Image/template rebuild orchestration (EE) |
-| `docker/Dockerfile.script-runner` / `docker/Dockerfile.opensandbox` / `docker/Dockerfile.cube-sandbox` | The three sandbox images |
+| `src/backend/features/sandbox_ops/api/admin_sandbox.py` | Dependency-rebuild admin API (EE) |
+| `src/backend/features/security_console/api/config_security.py` | Security-console read-only sandbox views |
+| `src/backend/features/sandbox_ops/rebuild.py` | Image/template rebuild orchestration (EE) |
+| `docker/sandbox/Dockerfile.script-runner` / `docker/sandbox/Dockerfile.opensandbox` / `docker/sandbox/Dockerfile.cube-sandbox` | The three sandbox images |
Related docs: [Agent skills](agent-skills.md) · [MCP tool system](mcp-tools.md) · [Projects & My Space](projects-myspace.md) · [Editions & licensing](../editions/overview.md)
@@ -258,7 +256,7 @@ The backend collects status and logs and keeps the sandbox active. The model can
The command ledger stores bounded logs, execution leases and completion delivery state. OpenSandbox recovery uses the original execution identity, sandbox binding and committed log cursor. It never restarts the command. Other providers stop owned processes on orderly shutdown. After an unexpected restart, an unrecoverable execution is reported as `unknown`.
-Migration `bashbackground01` creates the command ledger. Apply it through the normal release procedure before using the feature. Tests use isolated databases and disposable sandbox sessions. See the [architecture decision](../../internal/adr/20261018-background-bash-completion.md) and [validation record](../../internal/background-bash-validation-20261018.md).
+Migration `bashbackground01` creates the command ledger. Apply it through the normal release procedure before using the feature. Tests use isolated databases and disposable sandbox sessions.
### Reclaim accounting and multi-worker administration
@@ -273,5 +271,31 @@ OpenSandbox instance lists and counts use the controller's current running
inventory instead of cached process handles. Controller query failures mean
the count is unavailable, never a successful zero. Pool diagnostics include
scope=worker and worker_pid. They describe one backend process and are not a
-global per-user quota. This fix does not convert local pool limits into
-cross-process admission limits.
+global per-user quota. Controller admission now enforces the shared ceiling described below.
+
+## Shared capacity and maintenance ownership
+
+The controller now enforces OPENSANDBOX_POOL_MAX_TOTAL against Docker inventory
+across backend workers, warm instances, restored sessions and computer instances.
+Worker pool counts remain local diagnostics. Container creation holds a file lock
+on the controller data volume; mount initialization stays outside that critical
+section. Capacity exhaustion or a competing admission returns 429 for later retry.
+Processes of the same controller must share its opensandbox_data volume.
+
+The leader discovers idle sessions from shared bindings, including sessions
+created by other workers or surviving a restart. Identity-checked cleanup of a
+missing binding does not block other sessions. Snapshot work stops with leadership;
+GC deletes metadata only for the snapshot_id it processed.
+
+Output reads no longer prepare mounts or write object directory markers.
+Directory initialization happens during creation, while execution and file access
+still check authorization. An uncertain or timed-out health probe preserves the
+FUSE helper; only confirmed missing or disconnected mounts permit replacement.
+Command observation retries are bounded before durable recovery takes over.
+Processes without a recoverable reference require confirmed termination, not loss
+of their control handle merely because observation failed.
+
+Roll out both backend and OpenSandbox controller images. Updating only the backend
+does not activate the shared capacity limit.
+
+Skill mounts require HOST_STORAGE_PATH to identify the host directory corresponding to backend STORAGE_PATH. Missing configuration fails explicitly; source-tree fallback is removed. Restart adoption requires explicit ownership metadata and never infers ownership from old entrypoints.
diff --git a/document/en/modules/sites.md b/document/en/modules/sites.md
index 92ae7e65..6cf07e80 100644
--- a/document/en/modules/sites.md
+++ b/document/en/modules/sites.md
@@ -66,13 +66,13 @@ The platform counts HTML page views per site (asset files excluded), shown on th
| Part | Location |
|---|---|
| Publish and lookup tools | `src/backend/extensions/mcp_servers/site_publish_mcp/` (`publish_site` / `list_sites`, shipped by the Sites plugin) |
-| Internal endpoints | `src/backend/api/routes/v1/internal_sites.py` (`/v1/internal/sites/publish`, `/v1/internal/sites/list`) |
-| Site lookup service | `src/backend/core/services/site_listing.py` (one record shape for local and cloud) |
-| Business service | `src/backend/core/services/site_service.py` |
-| Public hosting route | `src/backend/api/routes/sites_serve.py` (`GET /site/{slug}/{path}`) |
-| Management API | `src/backend/api/routes/v1/sites.py` (`/v1/sites`) |
-| Access password | `src/backend/core/services/site_password.py` (hash + credential), `src/backend/api/routes/site_gate.py` (unified password page) |
-| Database table | `sites` (`core/db/models/site.py`) |
+| Internal endpoints | `src/backend/features/sites/api/internal_sites.py` (`/v1/internal/sites/publish`, `/v1/internal/sites/list`) |
+| Site lookup service | `src/backend/features/sites/listing.py` (one record shape for local and cloud) |
+| Business service | `src/backend/features/sites/management.py` |
+| Public hosting route | `src/backend/features/sites/api/sites_serve.py` (`GET /site/{slug}/{path}`) |
+| Management API | `src/backend/features/sites/api/sites.py` (`/v1/sites`) |
+| Access password | `src/backend/features/sites/` (hash + credential), `src/backend/features/sites/api/site_gate.py` (unified password page) |
+| Database table | `sites` (`features/sites/models.py`) |
| Frontend management panel | `src/frontend/src/components/sites/SitesPanel.tsx` (Lab → Sites), `SitePasswordField.tsx` (password management) |
| nginx forwarding | `location /site/` in `src/frontend/default.conf.template` |
diff --git a/document/en/modules/storage.md b/document/en/modules/storage.md
index 83079ed6..947ea06c 100644
--- a/document/en/modules/storage.md
+++ b/document/en/modules/storage.md
@@ -4,7 +4,7 @@ Cloud Docker deployments in CE and EE default to S3-compatible storage. Desktop
Set STORAGE_TYPE=s3 and configure S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY and S3_SECRET_KEY. The backend and sandbox controller must reach the endpoint.
-The optional docker/compose.object-storage.yml starts an independent S3 service. It uses its own volume and does not publish host ports. It does not share Milvus storage.
+The optional docker/object-storage/compose.object-storage.yml starts an independent S3 service. It uses its own volume and does not publish host ports. It does not share Milvus storage.
CE initialization generates separate random access and secret keys. Run python3 scripts/configure-ce-docker.py in a new CE checkout. It refuses to overwrite an existing .env.
diff --git a/document/en/modules/workflow-jobs.md b/document/en/modules/workflow-jobs.md
index 40d7f5e9..69f867b6 100644
--- a/document/en/modules/workflow-jobs.md
+++ b/document/en/modules/workflow-jobs.md
@@ -23,11 +23,3 @@ Omitting `tools` inherits the parent's MCPs. A non-empty explicit list selects i
Derived instances use the main-agent iteration policy without lightweight or subagent turn caps. Each complete task executes once. Invalid output, execution failure or an uncertain callback result never automatically repeats potentially completed side effects. Inspect existing actions before explicitly resuming.
Windows local jobs use `.job//` to reduce directory depth and keep packaged Python job SDK imports below the traditional path-length limit. Each execution retains its complete unique attempt ID; resume and cancellation use the persisted actual directory and exact process reference.
-
-The native desktop end-to-end acceptance script is `src/backend/tests/integration/job_agent_local_smoke.py`. Run it with the packaged private Python and a new isolated output directory. It exercises real HTTP callbacks, managed job processes, concurrent derived main agents, child agents, Bash and file results. Only model decisions are supplied by a local scripted service. It uses no real accounts or data and does not replace installer or WebView2 window acceptance.
-
-## Validation
-
-Unit and regression tests live under src/backend/tests/orchestration/test_job*.py. The opt-in real-sandbox test, src/backend/tests/integration/job_managed_smoke.py, requires an existing job for sandbox context. It creates synthetic jobs and a temporary callback endpoint to test concurrent ledger writes, thread exit, isolated cancellation, cross-process adoption, resume, and startup failure. It does not call a model or rerun the original business script.
-
-Before deployment, verify the actual source directory mounted into the container. The working tree and running service may use different checkouts; restarting a container with the wrong mount will not apply these changes.
diff --git a/document/zh-CN/README.md b/document/zh-CN/README.md
index 4d737be3..dd1765e7 100644
--- a/document/zh-CN/README.md
+++ b/document/zh-CN/README.md
@@ -1,60 +1,95 @@
# HugAgentOS 中文文档
-> 最后更新:2026-07-21 | [English](../en/README.md)
+> 最后更新:2026-10-23 | [English](../en/README.md)
-HugAgentOS 是一个企业级 AI Agent 平台:FastAPI 后端 + React 前端 + AgentScope 2.0 智能体底座,全栈 Docker 化部署,以 open-core 模式提供**开源社区版(CE)**与**商业版(EE)**。本目录是面向部署者、使用者与二次开发者的完整产品文档。
+HugAgentOS 是一个开源、可自托管的 Agent Harness:你提出的问题在云端持续执行,关掉电脑也不会中断;
+在此之上提供 7×24 小时在线的 Personal Agent,并以 HugAgentOS Cloud(服务器)和 HugAgentOS Local
+(桌面本机)两种形态运行。平台以开源社区版(CE)与商业版(EE)双形态发行。本目录是面向部署者、
+使用者与二次开发者的完整产品文档。
## 快速上手
| 文档 | 说明 |
|------|------|
-| [产品简介](getting-started/introduction.md) | 是什么、核心特性总览、CE/EE 一览、技术栈与整体架构 |
-| [快速开始](getting-started/quick-start.md) | 选择一键安装或 Docker Compose,快速运行 HugAgentOS |
+| [产品简介](getting-started/introduction.md) | Durable Agent、运行形态、能力总览、技术栈与整体架构 |
+| [快速开始](getting-started/quick-start.md) | 选择 HugAgentOS Cloud、桌面客户端或一键安装,并启用 Personal Agent |
| [快速构建领域本体](getting-started/domain-ontology-quickstart.md) | 用最小 Domain Pack 跑通校验、发布、测试和工具绑定 |
## 部署
-先看 [部署指南(选择部署方式)](deployment/README.md),再按方式查阅:
+先看[部署指南(选择部署方式)](deployment/README.md),再按方式查阅:
| 文档 | 说明 |
|------|------|
| [部署指南 · 总览](deployment/README.md) | 各部署方式对比与选型、部署后验证 |
-| [无 Docker 一键安装](deployment/quick-install.md) | 个人单机零依赖:一条命令装好,SQLite + 进程内事件流/短时状态 + 子进程 MCP/沙箱 |
-| [Docker Compose 部署](deployment/docker-compose.md) | 团队/生产标准形态:全部服务拓扑、profiles、rebuild 流程、数据库迁移 |
-| [离线生产部署](deployment/offline-production.md) | 隔离环境:镜像 tarball 打包 / 生产侧加载 / 提示词快照迁移(商业版 EE) |
-| [Windows 部署](deployment/windows-deployment.md) | Docker Desktop / WSL2 部署差异、换行符、路径与沙箱限制 |
+| [Docker Compose 部署](deployment/docker-compose.md) | HugAgentOS Cloud 标准形态:服务拓扑、profiles、重建流程、数据库迁移 |
+| [桌面离线本机模式](deployment/desktop-local-mode.md) | HugAgentOS Local:私有运行时、仅本机 / 仅云端 / 本机 + 云端、升级与回滚 |
+| [本机项目工作目录](deployment/local-project-workspace.md) | 桌面端绑定本地项目后的执行目录与权限规则 |
+| [本机与云端技能、插件管理](deployment/local-capability-management.md) | 混合模式下技能与插件的来源和管理 |
+| [无 Docker 一键安装](deployment/quick-install.md) | 个人单机:SQLite + 进程内状态 + 子进程 MCP / 沙箱 |
+| [Windows 安装与部署](deployment/windows-deployment.md) | 桌面端单机服务或 Docker Desktop + WSL2 |
+| [macOS 签名与升级](deployment/macos-signing.md) | 桌面端签名、公证与升级 |
+| [我的空间文件挂载](deployment/space-files.md) | 网页与沙箱共用的文件目录 |
| [环境变量参考](deployment/environment-variables.md) | 全量环境变量逐组说明(默认值 / 作用 / CE·EE 相关性) |
## 架构
| 文档 | 说明 |
|------|------|
-| [总体架构](architecture/overview.md) | 分层架构图、一次对话的完整生命周期、容器拓扑、关键设计决策 |
-| [后端架构](architecture/backend.md) | `src/backend/` 全目录详解:api / orchestration / core 15 子模块 / 路由注册表 |
-| [前端架构](architecture/frontend.md) | 五入口分发、组件组、Zustand store、hooks、构建链 |
-| [数据模型](architecture/data-model.md) | 76 张表分组速览、alembic 双迁移链、CE/EE 表边界 |
+| [总体架构](architecture/overview.md) | 分层架构、本体控制平面、一次对话的完整生命周期、容器拓扑 |
+| [后端架构](architecture/backend.md) | `src/backend/` 目录详解与路由注册表 |
+| [前端架构](architecture/frontend.md) | 入口分发、组件组、Zustand store、模块页面与应用中心 |
+| [数据模型](architecture/data-model.md) | 表分组速览、迁移链、CE/EE 表边界 |
## 功能模块
+**Harness 与长时运行**
+
| 文档 | 说明 |
|------|------|
-| [对话与编排](modules/chat.md) | 端到端对话流、SSE 事件、引用系统、计划模式、子智能体、断线续播 |
-| [提示词系统](modules/prompts.md) | 装配优先级、版本池、提示词广场、跨环境迁移 |
-| [能力目录](modules/catalog.md) | catalog 单一真源、能力门控、用户自助能力 |
-| [模型接入](modules/model-providers.md) | 模型供应商与角色体系、动态切换、个人 API-Key、计费 |
-| [MCP 工具系统](modules/mcp-tools.md) | 内置及插件 Server、连接池、管理员/用户自定义 MCP |
-| [技能系统](modules/agent-skills.md) | SKILL.md 机制、技能包分层、技能市场、技能蒸馏 |
-| [沙箱执行](modules/sandbox.md) | 三种 provider、bash 工具、快照持久化、MySpace 直挂 |
-| [记忆系统](modules/memory.md) | L1/L2/L3 三层记忆、脱敏、审计、mem0 基础设施 |
-| [领域本体治理与校验](modules/ontology-harness.md) | CE 设置页治理、Domain Pack、工具门禁、交付前评审与人审演进 |
-| [知识库](modules/knowledge-base.md) | 自建知识库(混合检索)与 Dify 外接、公共库管理 |
-| [知识库 Wiki](modules/knowledge-base-wiki.md) | LLM 抽取实体与概念、生成互链条目页与概念图谱 |
-| [对象存储](modules/storage.md) | local / s3 / oss 三后端、产物仓、文件链路 |
-| [项目空间与我的空间](modules/projects-myspace.md) | 项目工作区、个人/团队文件夹、文件入上下文 |
-| [认证与权限](modules/auth.md) | AUTH_MODE 三模式、SSO、权限位、管理凭证 |
-| [管理台](modules/admin-console.md) | /admin 与 /config 双管理台、19 组管理路由 |
+| [对话与智能体编排](modules/chat.md) | Run 与连接解耦、断线续播、运行中转向、上下文按步恢复与压缩、计划模式、子智能体 |
+| [Personal Agent](modules/personal-agent.md) | 7×24 个人智能体:后台任务、永久云电脑、定时与心跳、IM 渠道、审批、文件记忆与 Dreaming |
+| [自主循环](modules/autonomous-loop.md) | 目标跨轮存活、maker≠checker、停滞护栏、断点续跑 |
+| [工作流后台作业](modules/workflow-jobs.md) | 沙箱受管进程、租约接管、派生主智能体 |
| [自动化与计划模式](modules/automation.md) | 定时任务调度、计划模式 |
-| [数据画布与产物](modules/canvas-artifacts.md) | Univer 画布、代码产物、Artifact 中心、会话分享 |
+
+**能力与扩展**
+
+| 文档 | 说明 |
+|------|------|
+| [MCP 工具系统](modules/mcp-tools.md) | 内置及插件 Server、连接池、自定义 MCP |
+| [技能系统](modules/agent-skills.md) | SKILL.md 机制、技能包分层、技能市场 |
+| [浏览器自动化插件](modules/browser-automation.md) | 浏览网页、操作表单与网页测试 |
+| [能力目录](modules/catalog.md) | catalog 单一真源、能力门控、用户自助能力 |
+| [沙箱执行](modules/sandbox.md) | provider、Bash 工具、快照持久化、我的空间直挂 |
+| [模型接入](modules/model-providers.md) | 模型供应商与角色体系、动态切换、个人 API-Key |
+| [提示词系统](modules/prompts.md) | 装配优先级、版本池、跨环境迁移 |
+
+**知识、记忆与治理**
+
+| 文档 | 说明 |
+|------|------|
+| [记忆系统](modules/memory.md) | L1/L2/L3 三层记忆、持久 Outbox、做法沉淀、脱敏与审计 |
+| [领域本体治理与校验](modules/ontology-harness.md) | Domain Pack、工具门禁、交付前评审、演进草案人工审核 |
+| [知识库](modules/knowledge-base.md) | 自建知识库(混合检索)与外接知识库 |
+| [知识库 Wiki](modules/knowledge-base-wiki.md) | 抽取实体与概念,生成互链条目页与概念图谱 |
+
+**工作区与产物**
+
+| 文档 | 说明 |
+|------|------|
+| [项目空间与我的空间](modules/projects-myspace.md) | 项目工作区、个人文件夹、云文档在线编辑、文件入上下文 |
+| [数据画布与产物](modules/canvas-artifacts.md) | Univer 画布、代码产物、产物中心、会话分享 |
+| [站点](modules/sites.md) | 对话建站与平台托管 |
+| [应用数据库与 MCP 托管](modules/application-hosting.md) | 应用独立数据库、表单数据与 MCP 发布 |
+| [对象存储](modules/storage.md) | 本地与 S3 兼容存储、产物仓、文件链路 |
+
+**平台管理**
+
+| 文档 | 说明 |
+|------|------|
+| [认证与权限](modules/auth.md) | AUTH_MODE、SSO、权限位、管理凭证 |
+| [管理台](modules/admin-console.md) | /admin 与 /config 管理台 |
## API 参考
@@ -67,21 +102,40 @@ HugAgentOS 是一个企业级 AI Agent 平台:FastAPI 后端 + React 前端 +
| 文档 | 说明 |
|------|------|
-| [社区版 vs 商业版](editions/overview.md) | open-core 模式、功能对比矩阵、三种运行形态、升级路径 |
-| [License 机制](editions/license.md) | 离线签名验签、状态机、能力位执法、签发工具(商业版 EE) |
+| [社区版 vs 商业版](editions/overview.md) | open-core 模式、功能对比矩阵、运行形态、升级路径 |
+| [License 机制](editions/license.md) | 离线签名验签、状态机、能力位执法(商业版 EE) |
| [CE 构建管线](editions/build-ce.md) | manifest、build_ce.py 流水、overlay、验收闸门 |
## 开发指南
| 文档 | 说明 |
|------|------|
-| [后端开发](development/backend.md) | Docker 内开发模式、测试、迁移、分层规范、新增路由/MCP/技能 |
-| [前端开发](development/frontend.md) | 构建热替、目录规范、API 调用约定、edition 门控 |
-
----
-
-内部设计与方案文档(CE/EE 拆分施工图、沙箱快照设计等)见 [docs 根目录](../README.md)。
+| [后端开发](development/backend.md) | 开发模式、测试、迁移、分层规范、新增路由 / MCP / 技能 |
+| [前端开发](development/frontend.md) | 构建、目录规范、API 调用约定、edition 门控 |
+| [客户对话界面定制](development/frontend-customization.md) | 页面配置、客户组件包与独立前端 |
-- [浏览器自动化插件](modules/browser-automation.md)
+**工作台应用(Workbench)**
-- [Personal Agent](modules/personal-agent.md)
+| 文档 | 说明 |
+|------|------|
+| [工作台协议](development/workbench-protocol.md) | JSON Schema、协议版本与接入方式 |
+| [Workbench SDK 与插件页面](development/workbench-sdk.md) | SDK 1.0.0、UI v3 插件页面 |
+| [开发预览](development/workbench-preview.md) | 独立工程与权限预览 |
+| [构建与离线制品](development/workbench-build.md) | 协议、SDK 与宿主前端的构建交付 |
+| [应用地址与插件导航](development/workbench-routing.md) | `/apps/` 地址与 applicationKey |
+| [应用资源](development/workbench-resources.md) | 包内 resource runtime |
+| [界面状态与有限保活](development/workbench-state.md) | 状态格式版本与保存恢复 |
+| [角标、通知与后台运行](development/workbench-activity.md) | badge、notification、background 能力 |
+| [平台适配](development/workbench-platform-adapters.md) | Web、Tauri 与 Electron 适配接口 |
+| [来源与离线安装](development/workbench-origins.md) | 本机与云端同名插件的入口 |
+| [插件升级与权限审批](development/workbench-plugin-upgrades.md) | 升级、重装与授权继承 |
+| [应用目录与布局策略](development/workbench-access-policies.md) | 目录接口、可用性与默认入口 |
+| [模块导航与应用目录](development/workbench-navigation-preferences.md) | 侧栏模块与应用中心 |
+| [目录通知与撤销](development/workbench-invalidation.md) | ETag 与目录失效通知 |
+| [普通外部应用](development/workbench-external-apps.md) | 外链应用的管理与使用 |
+| [外部应用认证](development/workbench-external-auth.md) | 外部应用认证配置(商业版 EE 管理台) |
+| [外部页面嵌入](development/workbench-external-embedding.md) | 嵌入工作区的 origin 与就绪确认 |
+| [应用诊断](development/workbench-diagnostics.md) | 诊断编号与重试 |
+| [错误码](development/workbench-errors.md) | 工作台错误码 |
+| [管理员指南](development/workbench-administration.md) | 工作台管理 |
+| [兼容与迁移](development/workbench-migration.md) | 版本兼容与迁移 |
diff --git a/document/zh-CN/api/overview.md b/document/zh-CN/api/overview.md
index abadb591..8583aa0c 100644
--- a/document/zh-CN/api/overview.md
+++ b/document/zh-CN/api/overview.md
@@ -65,7 +65,7 @@ HugAgentOS 后端是一个 FastAPI 应用,所有业务接口挂在 `/v1/*` 前
| **CONFIG_TOKEN** | `Authorization: Bearer ` | `/config` 系统配置台后端(`/v1/config/*`、`/v1/models`、`/v1/service-configs`、部分 `/v1/admin/*` 观测接口) | `require_config`(环境变量 `CONFIG_TOKEN`) |
| **ADMIN 或 CONFIG 任一** | 同上 | 提示词快照导入/导出(`/v1/content/prompts/export|import`) | `require_admin_or_config` |
| **super_admin 会话** | 会话 Cookie(用户 `extra_data.role == "super_admin"`),合法 ADMIN_TOKEN 可兜底 | 个别跨用户管理操作 | `require_super_admin` |
-| **BACKEND_INTERNAL_TOKEN** | `Authorization: Bearer ` | 站点、浏览器与安全工具的内部服务调用;未配置时拒绝请求 | `api/routes/v1/internal_site_auth.py`、`internal_security.py` |
+| **BACKEND_INTERNAL_TOKEN** | `Authorization: Bearer ` | 站点、浏览器与安全工具的内部服务调用;未配置时拒绝请求 | `features/sites/api/internal_site_auth.py`、`internal_security.py` |
鉴权优先级(`get_current_user`):**Cookie 会话 → API-Key Bearer(`sk-jx-` 前缀)→ mock/remote Bearer**。携带了形似 API-Key 的 Bearer 但校验失败时直接 401,绝不静默降级为匿名。
@@ -120,7 +120,7 @@ curl -X POST 'https:///api/v1/agents/responses' \
## SSE 流式协议
-流式聊天端点 `POST /v1/agents/responses`(`src/backend/api/routes/v1/agent_responses.py`,`stream: true`)校验通过后启动一个后台 run,再以 SSE 跟随该 run 实时下发事件;断线后可用 `GET /v1/chats/stream/{run_id}` 从任意偏移续播。事件由 `src/backend/orchestration/workflow.py` 产出、`src/backend/orchestration/chat_run_executor.py` 序列化上 wire。
+流式聊天端点 `POST /v1/agents/responses`(`src/backend/api/routes/v1/agent_responses.py`,`stream: true`)校验通过后启动一个后台 run,再以 SSE 跟随该 run 实时下发事件;断线后可用 `GET /v1/chats/stream/{run_id}` 从任意偏移续播。事件由 `src/backend/core/agent_runtime/runner.py` 产出、`src/backend/core/chat/runtime/` 序列化上 wire。
**Wire 格式**:`Content-Type: text/event-stream`,每个事件一行 `data: {JSON}`,事件类型放在 JSON 的 `type` 字段里(不使用 SSE 的 `event:` 行);流以 `data: [DONE]` 结束。静默超过 15 秒时发送 SSE 注释行 `: heartbeat` 维持反代连接。
@@ -197,7 +197,7 @@ data: [DONE]
答案必须与原问题数量、ID 和顺序完全一致;私有 option ID 必须来自 pending 响应,单选题不能提交多个选项,且每题必须作答或明确 `skipped=true`。后端会先把这些私有 ID 映射回原始选项 label,再向模型返回 DSH 兼容结果 `{"answers":[{"id":"scope","selected":["最小范围"]}]}`。第一个有效回答/取消会原子取得请求,之后的并发提交返回 `stale=true`,不会重复唤醒工具。成功 POST 已代表服务端原子取得请求,当前标签页可立即按 `request_id` 移除;`user_question_resolved` 和周期 pending 权威快照负责同步其他标签页与断线客户端。
-正文中的 `[ref:tool_name-N]` 引用标记由 `orchestration/citations.py` 解析为 `citations` 项,前端据此渲染角标(见 [对话模块](../modules/chat.md))。
+正文中的 `[ref:tool_name-N]` 引用标记由 `core/llm/citations/` 解析为 `citations` 项,前端据此渲染角标(见 [对话模块](../modules/chat.md))。
### 续播、Steer 与取消
diff --git a/document/zh-CN/architecture/backend.md b/document/zh-CN/architecture/backend.md
index 5721ce27..beb8c6bb 100644
--- a/document/zh-CN/architecture/backend.md
+++ b/document/zh-CN/architecture/backend.md
@@ -145,9 +145,9 @@ src/backend/
|---|---|
| `core/chat` | workflow 上下文组装(`context.py`)、SSE 工具日志事件构造(`tool_log.py`) |
| `core/files/content` | 附件解析(`file_parser.py`)、KB 文档分块/关键词/向量化(`kb_processing.py`)、上传校验(`file_validation.py`)、产物读取与摘要(`artifact_reader/refs/summary.py`)、内容块导入导出(`content_blocks.py`)、`svg_fit.py` |
-| `core/kb` / `edition_ee/kb` | 自建知识库解析与 Milvus 向量库保留在 `core/kb`;Dify 客户端与外部检索适配器只存在于 `edition_ee/kb`(商业版 EE) |
+| `features/knowledge` / `edition_ee/kb` | 自建知识库解析与 Milvus 向量库保留在 `features/knowledge`;Dify 客户端与外部检索适配器只存在于 `edition_ee/kb`(商业版 EE) |
| `core/files/artifacts` | 产物存储 `store.py`:本地 / OSS 双模式 |
-| `core/infra` | 统一响应(`responses.py`)、异常(`exceptions.py`)、结构化日志(`logging.py`)、限流(`rate_limit.py`)、Redis 单例(`redis.py`)、指标(`metrics.py`)、后台任务注册表(`runtime_state.py`)、脱敏(`data_masking.py`)、蒸馏预算闸门(`distillation_budget.py`,商业版 EE) |
+| `core/infra` | 统一响应(`responses.py`)、异常(`exceptions.py`)、结构化日志(`logging.py`)、Redis 单例(`redis.py`)、指标(`metrics.py`)、后台任务注册表(`runtime_state.py`)、脱敏(`data_masking.py`)、蒸馏预算闸门(`distillation_budget.py`,商业版 EE) |
| `edition_ee/licensing` | 能力枚举、验签、时钟回拨防护、席位策略、中间件和 manager 全部只存在于商业版 EE;CE 树物理不含 License 包,由 `api/middleware/edition.py` 的 CE overlay 提供固定版本探针 |
| `core/files/storage` | 存储协议 `protocol.py` + 工厂 `factory.py`;`local.py`(CE)、`s3.py` / `oss.py`(商业版 EE) |
@@ -219,8 +219,8 @@ CE 路由位于 `api/routes/v1/`;已物理拆分的商业路由位于 `edition
| 应用入口与启动钩子 | `src/backend/api/app.py` |
| 路由注册表 | `src/backend/api/routes/v1/__init__.py` |
| 智能体工厂 | `src/backend/core/llm/factory/build.py` |
-| Run 执行器 / 工作流 | `src/backend/orchestration/chat_run_executor.py`、`workflow.py` |
-| 能力目录 | `src/backend/core/config/catalog.py` |
+| Run 执行器 / 工作流 | `src/backend/core/chat/runtime/`、`workflow.py` |
+| 能力目录 | `src/backend/core/capabilities/catalog/` |
| 沙箱协议 | `src/backend/core/sandbox/protocol.py` |
| 记忆流水线 | `src/backend/core/memory/pipeline.py` |
| EE License 实现 / CE 版本中间件 | `src/backend/edition_ee/licensing/`、`src/backend/api/middleware/edition.py` |
diff --git a/document/zh-CN/architecture/data-model.md b/document/zh-CN/architecture/data-model.md
index 60ebd30b..126931cd 100644
--- a/document/zh-CN/architecture/data-model.md
+++ b/document/zh-CN/architecture/data-model.md
@@ -179,10 +179,10 @@ user_feedback # 问题反馈审核 /
|---|---|
| 共享 ORM / EE ORM | `src/backend/core/db/models/`、`src/backend/edition_ee/db/models/` |
| 本体仓储 | `src/backend/core/db/repository/ontology.py` |
-| 问题反馈模型 / 仓储 | `src/backend/edition_ee/db/models/feedback.py`、`db/repository/feedback.py` |
+| 问题反馈模型 / 仓储 | `src/backend/edition_ee/db/models/feedback.py`、`edition_ee/db/repository/feedback.py` |
| 引擎与启动建表 | `src/backend/core/db/engine.py` |
| 仓储层 | `src/backend/core/db/repository/` |
-| CE/EE 建表边界 | `src/backend/core/db/edition_tables.py` |
+| CE/EE 建表边界 | `src/backend/edition_ee/db/edition_tables.py` |
| EE 迁移主链 | `src/backend/alembic/versions/` |
| CE 迁移基线 | `ce/overlay/src/backend/alembic/versions/ce_0001_initial.py` |
| 提示词版本池服务 | `src/backend/core/llm/prompt_versions.py` |
diff --git a/document/zh-CN/architecture/overview.md b/document/zh-CN/architecture/overview.md
index a233d149..06677e4c 100644
--- a/document/zh-CN/architecture/overview.md
+++ b/document/zh-CN/architecture/overview.md
@@ -1,8 +1,8 @@
# 系统整体架构
-> 最后更新:2026-07-19
+> 最后更新:2026-10-23
-HugAgentOS 是一个面向企业的 AgentOS,同时提供轻量本地运行形态和全容器化生产形态。前端为 React 单页应用,后端为 FastAPI 异步服务,智能体底座为 AgentScope 2.0 的 ReActAgent,工具生态通过 MCP(Model Context Protocol)以独立进程提供,代码执行落在可插拔的沙箱驱动上。平台以「开源社区版(CE)+ 商业版(EE)」双形态发行,两版共用一套代码,通过路由注册表、建表边界与 license 能力位三道接缝裁剪(详见 [版本与授权](../editions/overview.md))。
+HugAgentOS 是一个开源、可自托管的 Agent Harness,提供 HugAgentOS Cloud(全容器化服务端)与 HugAgentOS Local(桌面本机)两种运行形态。前端为 React 单页应用,后端为 FastAPI 异步服务,智能体底座为 AgentScope 2.0 的 ReActAgent,工具生态通过 MCP(Model Context Protocol)以独立进程提供,代码执行落在可插拔的沙箱驱动上。平台以「开源社区版(CE)+ 商业版(EE)」双形态发行,两版共用一套代码,通过路由注册表、建表边界与 license 能力位三道接缝裁剪(详见 [版本与授权](../editions/overview.md))。
本文从五个视角描述系统:分层架构、本体驱动的企业可信控制平面、一次对话请求的完整生命周期、容器拓扑、关键设计决策。更深入的模块拆解见 [后端架构](./backend.md)、[前端架构](./frontend.md) 与 [数据模型](./data-model.md)。
@@ -23,48 +23,38 @@ HugAgentOS 是一个面向企业的 AgentOS,同时提供轻量本地运行形
```
┌────────────────────────────────────────────────────────────────────┐
-│ 浏览器(React SPA) │
-│ App.tsx 主应用 · AdminApp 内容台(EE) · ConfigApp 系统台(EE) │
-│ api.ts 类型化客户端 + useStreaming SSE 解析 │
+│ 入口:Web(React SPA)· 桌面客户端(Tauri)· IM 渠道 · API · 定时 · 心跳 │
└──────────────────────────────┬─────────────────────────────────────┘
│ HTTP / SSE(/api 前缀)
┌──────────────────────────────▼─────────────────────────────────────┐
-│ Nginx(frontend 容器,:80) │
-│ 静态资源托管 · /api → backend 反向代理 │
+│ FastAPI(src/backend/api/) │
+│ 中间件:CORS · 结构化日志 · 统一错误处理 · 版本路由注册表 │
└──────────────────────────────┬─────────────────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────────────────┐
-│ FastAPI(backend 容器,src/backend/api/) │
-│ 中间件:CORS · 结构化日志 · 统一错误处理 │
-│ 路由注册表:CE_ROUTERS + EE_ROUTERS(license 能力位守卫) │
+│ Run 层 │
+│ core/runs:持久 Run、数据库租约、启动对账、工具副作用意图与回执 │
+│ core/chat/runtime:Run 准入、聊天 worker、续播、steer、压缩 │
└──────────────────────────────┬─────────────────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────────────────┐
-│ 编排层(src/backend/orchestration/) │
-│ chat_run_executor:Run 与 HTTP 连接解耦、断线续播 │
-│ workflow:流式编排 · strategy:路由策略 · citations:引用提取 │
-│ memory_integration:记忆读写 · subagents/plan_mode:计划模式 │
-│ schedulers:自动化定时任务 · 技能蒸馏 cron(EE) │
-└──────────────────────────────┬─────────────────────────────────────┘
- │
-┌──────────────────────────────▼─────────────────────────────────────┐
-│ 智能体层(src/backend/core/llm/) │
-│ factory/build → AgentScope 2.0 ReActAgent │
-│ ToolCollector → Toolkit(自研工具 + 技能 + MCP 工具) │
-│ middlewares:动态模型/文件上下文/系统提醒 · offloader:超长结果落盘 │
-│ prompts/prompt_runtime:系统提示词装配(DB 优先,文件兜底) │
+│ 智能体执行 │
+│ core/agent_runtime:runner.astream_chat_workflow、路由策略、 │
+│ 记忆 / 本体 / 进化集成 │
+│ core/llm:factory/build → AgentScope ReActAgent、工具、上下文、引用 │
+│ orchestration/:plans 计划模式 · loops 自主循环 · automations 自动化 │
+│ personal_agent/:身份、任务队列、调度、渠道、设备连接、工作区 │
└───────┬──────────────────────┬──────────────────────┬──────────────┘
│ streamable-http │ 沙箱驱动协议 │ SQL / 向量
┌───────▼───────────┐ ┌────────▼──────────┐ ┌─────────▼─────────────┐
-│ MCP 工具池 │ │ 沙箱 │ │ 数据层 │
-│ mcp 工具进程 │ │ script-runner(CE) │ │ PostgreSQL · Redis │
-│ 端口见 _ports.py │ │ OpenSandbox(CE/EE) │ │ Milvus · Neo4j(可选) │
-│ 搜索/抓取/插件工具 │ │ CubeSandbox(EE) │ │ 本地 / S3 / OSS 存储 │
-│ 数仓/产业链(EE) │ │ │ │ │
+│ MCP 工具池 │ │ 沙箱与作业 │ │ 数据层 │
+│ mcp 工具进程 │ │ OpenSandbox │ │ PostgreSQL / SQLite │
+│ 端口见 _ports.py │ │ script-runner │ │ Redis · Milvus · Neo4j│
+│ 插件工具 │ │ core/jobs 后台作业 │ │ S3 兼容 / 本地存储 │
└───────────────────┘ └───────────────────┘ └───────────────────────┘
```
-依赖方向自上而下单向:`api → orchestration → core`,`core` 内部 `services → db`。MCP 服务器是独立进程,与后端只通过 HTTP 通信,崩溃互不影响。
+依赖方向自上而下单向:`api → features / orchestration / personal_agent → core`;`core` 不依赖上层,`integrations/` 承载钉钉、飞书、邮箱等外部系统接入。MCP 服务器是独立进程,与后端只通过 HTTP 通信,崩溃互不影响。
## 领域本体驱动的企业可信控制平面
@@ -139,21 +129,22 @@ RAG 则提供支撑决策所需的文档与证据。
### 2. 启动 Run(与 HTTP 连接解耦)
-`orchestration/chat_run_executor.py::start_run` 把 AI 工作流注册为后台 Run(落 `chat_runs` 表),HTTP 响应只是「跟随」这个 Run:
+`core/chat/runtime/admission.py::start_run` 把本轮注册为后台 Run(落 `chat_runs` 表),由持有数据库租约的聊天 worker 执行;HTTP 响应只是「跟随」这个 Run:
-- 浏览器断线、刷新页面后可调 `GET /v1/chats/stream/{run_id}` 从指定偏移续播;
-- 后端重启时 lifespan 钩子 `_startup_recover_chat_runs` 恢复/收割未完成的 Run。
+- 浏览器断线、刷新页面后可调 `GET /v1/chats/stream/{run_id}?from=N` 从指定偏移续播;
+- 运行中可通过 `POST /v1/chat-runs/{run_id}/steer` 追加指令,在下一个安全 ReAct 边界生效;
+- 后端启动时 `core/chat/runtime/recovery.py::recover_orphan_runs` 依据最后提交的安全点对账租约过期的 Run:可恢复的继续,不可恢复的明确收尾。
### 3. 编排与智能体执行
-`orchestration/workflow.py::astream_chat_workflow` 驱动单轮执行:
+`core/agent_runtime/runner.py::astream_chat_workflow` 驱动单轮执行:
-1. **记忆检索**——`orchestration/memory_integration.py` 读取 L1 画像 / L2 向量记忆(开启时),注入系统消息;
-2. **路由决策**——`orchestration/strategy.py`,`ROUTER_STRATEGY=main_only`(默认)恒路由到主智能体;
+1. **记忆检索**——`core/agent_runtime/integrations/memory.py` 读取 L1 画像 / L2 向量记忆(开启时),注入系统消息;
+2. **路由决策**——`core/agent_runtime/strategy.py`,`ROUTER_STRATEGY=main_only`(默认)恒路由到主智能体;
3. **构建智能体**——`core/llm/factory/build.py::create_agent_executor`:装配提示词(`prompts/prompt_runtime.py`,DB 版本池优先)、按 catalog 与用户能力位筛选 MCP 服务器、注册自研工具(Read/Write/Edit/Glob/Grep/bash/技能加载等,见 `core/llm/tools/`)、最终一次性构造 AgentScope Toolkit;
-4. **流式执行**——`orchestration/streaming.py::StreamingAgent` 包装 ReActAgent,把推理增量、工具调用、工具结果逐条产出;
-5. **引用提取**——`orchestration/citations.py` 从工具结果中解析 `[ref:tool_name-N]` 标记生成引用项;
-6. **收尾**——落库助手消息与工具调用日志,`core/memory/pipeline.py` 在后台异步抽取记忆(不阻塞 SSE 主链路),独立接口生成追问问题(`orchestration/followups.py`)。
+4. **流式执行**——`core/llm/runtime/streaming.py::StreamingAgent` 包装 ReActAgent,把推理增量、工具调用、工具结果逐条产出;
+5. **引用提取**——`core/llm/citations/` 从工具结果中解析 `[ref:tool_name-N]` 标记生成引用项;
+6. **收尾**——落库助手消息与工具调用日志,`core/memory/pipeline.py` 在后台异步抽取记忆(不阻塞 SSE 主链路),独立接口生成追问问题(`core/chat/followups.py`)。
### 4. SSE 事件流
@@ -166,9 +157,9 @@ RAG 则提供支撑决策所需的文档与证据。
| `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_result` | 工具执行结果(含引用、产物卡片载荷) | `core/chat/tool_log.py` + `core/chat/tool_payloads.py` |
| `tool_progress` | 长工具的进度上报 | `chats.py` |
-| `meta` | 末尾元信息:路由、引用源、产物列表等 | `orchestration/workflow.py` |
+| `meta` | 末尾元信息:路由、引用源、产物列表等 | `core/agent_runtime/runner.py` |
| `error` | 错误事件(随后立即 `[DONE]`) | `chats.py` |
前端 `src/frontend/src/hooks/useStreaming.ts` 按 `type` 分发,并按 `tool_id` 把 start、delta、最终调用与结果原位合并为一张工具卡片。
@@ -181,13 +172,14 @@ RAG 则提供支撑决策所需的文档与证据。
|---|---|---|---|
| `frontend` | hugagent-frontend | `3002:80` | Nginx:静态资源 + `/api` 反代 |
| `backend` | hugagent-backend | `3001` | FastAPI 主服务 |
-| `mcp` | hugagent-mcp | 内部独立端口 | 9 个 MCP 服务器常驻进程(streamable-http;CE 7 个通用工具,EE 另含 2 个行业工具) |
+| `mcp` | hugagent-mcp | 内部独立端口 | 内置 MCP 服务器常驻进程(streamable-http;数仓查询与行业工具为 EE) |
| `postgres` | hugagent-postgres | `5432` | 主数据库 |
| `redis` | hugagent-redis | `6379` | 会话 / 缓存 |
-| `script-runner`(profile `script_runner`) | hugagent-script-runner | 内部 | 轻量脚本执行 sidecar(无 DB/密钥访问权) |
-| `opensandbox` + `opensandbox-config-init`(profile `opensandbox`) | hugagent-opensandbox | 内部 | 持久沙箱(商业版 EE) |
+| `script-runner`(profile `script_runner`) | hugagent-script-runner | 内部 | 可选的轻量脚本执行 sidecar(无 DB/密钥访问权) |
+| `opensandbox` + `opensandbox-config-init`(profile `opensandbox`) | hugagent-opensandbox | 内部 | 默认沙箱:持久会话、快照恢复(与 script-runner 互斥) |
| `etcd` / `minio` / `milvus`(profile `mem0`) | hugagent-etcd 等 | 内部 | L2 向量记忆基础设施 |
| `neo4j`(profile `mem0`) | hugagent-neo4j | `7474/7687` | L3 图谱记忆(可选) |
+| `personal-agent-opensandbox`(profile `personal-agent`,`docker/personal-agent/compose.personal-agent.yml`) | — | 内部 | Personal Agent 永久云电脑的专用控制器 |
所有服务挂在同一 `hugagent-network` 网络;`postgres_data` 等具名卷跨重建持久化。部署细节见 [Docker Compose 部署](../deployment/docker-compose.md)。
@@ -195,7 +187,7 @@ RAG 则提供支撑决策所需的文档与证据。
### Catalog 单一真源
-`src/backend/core/config/catalog.json` 是能力开关的唯一权威,四类条目:`skills` / `agents` / `mcp` / `kb`。`core/config/catalog.py` 暴露 `is_enabled(kind, id)` / `get_enabled_ids(kind)`,从 MCP 工具装载、技能注册到路由决策全部经此门控;管理台的启停操作写 `catalog_overrides` 表叠加在文件之上(`core/config/catalog_loader.py`)。新能力只要进 catalog 即可被前端能力中心与智能体工厂同时感知,无需改编排代码。详见 [能力目录](../modules/catalog.md)。
+`src/backend/core/config/catalog.json` 是能力开关的唯一权威,四类条目:`skills` / `agents` / `mcp` / `kb`。`core/capabilities/catalog/` 暴露 `is_enabled(kind, id)` / `get_enabled_ids(kind)`,从 MCP 工具装载、技能注册到路由决策全部经此门控;管理台的启停操作写 `catalog_overrides` 表叠加在文件之上(`core/capabilities/catalog/loader.py`)。新能力只要进 catalog 即可被前端能力中心与智能体工厂同时感知,无需改编排代码。详见 [能力目录](../modules/catalog.md)。
### 统一响应信封
@@ -207,15 +199,15 @@ RAG 则提供支撑决策所需的文档与证据。
### MCP 工具独立进程
-每个工具是 `src/backend/extensions/mcp_servers//` 下的独立 MCP 服务器,统一运行在专用 `mcp` 容器内,以 streamable-http 常驻监听(端口表 `extensions/mcp_servers/_ports.py` 为单一真源),后端经 `core/llm/mcp/manager.py` 的 HTTP 客户端连接池复用连接。好处:工具崩溃不影响主进程、依赖隔离(文档编辑与导出已迁入沙箱技能)、可独立扩缩。CE 保留 7 个通用 Server;`query_database` 与 `ai_chain_information_mcp` 两个行业工具依赖内网数据源,归商业版 EE。详见 [MCP 工具](../modules/mcp-tools.md)。
+每个工具是 `src/backend/extensions/mcp_servers//` 下的独立 MCP 服务器,统一运行在专用 `mcp` 容器内,以 streamable-http 常驻监听(端口表 `extensions/mcp_servers/_ports.py` 为单一真源),后端经 `core/llm/mcp/manager.py` 的 HTTP 客户端连接池复用连接。好处:工具崩溃不影响主进程、依赖隔离(文档编辑与导出已迁入沙箱技能)、可独立扩缩。`query_database` 与 `ai_chain_information_mcp` 两个行业工具依赖内网数据源,归商业版 EE。详见 [MCP 工具](../modules/mcp-tools.md)。
-### Run 与连接解耦
+### Run 与连接、进程解耦
-流式对话不绑定 HTTP 连接生命周期:`chat_run_executor` 把执行状态写入 `chat_runs` 表并支持按偏移续播,彻底解决「刷新页面丢回复」「弱网断流」问题,也让自动化调度器能复用同一执行通道。
+流式对话不绑定 HTTP 连接,也不绑定某个进程:Run 状态与按步上下文落库,worker 通过数据库租约领取执行,过期执行者不能再提交结果或调用工具;进程中断后由其他 worker 接管。工具调用先记录意图、有回执才算完成(`core/runs/tool_effects/`),恢复时只重放能证明尚未生效的调用,结果未知的外部操作交给用户确认。自动化、计划模式、自主循环与 Personal Agent 复用同一套 Run 机制。
### CE/EE 同源双形态
-商业版主仓即全量代码;社区版由 `scripts/build_ce.py` 按 `ce/manifest.yaml` 派生(排除 `edition_ee` 实现 + 文本变换 + CE overlay)。运行时边界包括路由注册表 `api/routes/v1/__init__.py`、建表边界 `core/db/edition_tables.py`(CE 不注册 20 张 EE 专属表)、EE 的 `edition_ee/licensing/` 与 CE 固定版本中间件,以及站点可见性等版本策略缝隙。详见 [版本与授权](../editions/overview.md)。
+商业版主仓即全量代码;社区版由 `scripts/build_ce.py` 按 `ce/manifest.yaml` 派生(排除 `edition_ee` 实现 + 文本变换 + CE overlay)。运行时边界包括路由注册表 `api/routes/v1/__init__.py`、建表边界 `edition_ee/db/edition_tables.py`(CE 不注册 20 张 EE 专属表)、EE 的 `edition_ee/licensing/` 与 CE 固定版本中间件,以及站点可见性等版本策略缝隙。详见 [版本与授权](../editions/overview.md)。
## 相关源码
@@ -223,12 +215,13 @@ RAG 则提供支撑决策所需的文档与证据。
|---|---|
| FastAPI 应用与启动钩子 | `src/backend/api/app.py` |
| 流式对话入口 | `src/backend/api/routes/v1/chats/__init__.py` |
-| Run 执行器 | `src/backend/orchestration/chat_run_executor.py` |
-| 流式编排 | `src/backend/orchestration/workflow.py` |
+| Run 执行器 | `src/backend/core/chat/runtime/` |
+| 流式编排 | `src/backend/core/agent_runtime/runner.py` |
| 智能体工厂 | `src/backend/core/llm/factory/build.py` |
| MCP 配置与端口 | `src/backend/core/config/mcp_config.py`、`src/backend/extensions/mcp_servers/_ports.py` |
-| 能力目录 | `src/backend/core/config/catalog.json`、`catalog.py` |
+| 能力目录 | `src/backend/core/config/catalog.json`、`src/backend/core/capabilities/catalog/` |
+| Run、租约与恢复 | `src/backend/core/runs/`、`src/backend/core/chat/runtime/recovery.py` |
| 响应信封 | `src/backend/core/infra/responses.py` |
| 提示词装配 | `src/backend/prompts/prompt_runtime.py` |
-| CE/EE 接缝 | `src/backend/api/routes/v1/__init__.py`、`src/backend/core/db/edition_tables.py`、`src/backend/api/middleware/edition.py`、`src/backend/edition_ee/licensing/` |
+| CE/EE 接缝 | `src/backend/api/routes/v1/__init__.py`、`src/backend/edition_ee/db/edition_tables.py`、`src/backend/api/middleware/edition.py`、`src/backend/edition_ee/licensing/` |
| 容器编排 | `docker-compose.yml` |
diff --git a/document/zh-CN/deployment/README.md b/document/zh-CN/deployment/README.md
index 5a64dba2..cb1ce781 100644
--- a/document/zh-CN/deployment/README.md
+++ b/document/zh-CN/deployment/README.md
@@ -9,10 +9,8 @@ HugAgentOS 支持多种部署方式,从「个人单机零依赖尝鲜」到「
| 部署方式 | 适用场景 | Docker | 数据库 | 多用户 | 文档 |
|---|---|---|---|---|---|
| **桌面端离线本机模式** | Windows / macOS / Linux 个人用户;安装后直接使用 | 不需要 | SQLite | 否(单用户) | [desktop-local-mode.md](desktop-local-mode.md) |
-| **UOS 1070 ARM64 桌面端** | 统信 UOS 1070 aarch64;Electron 43 `.deb` | 不需要 | SQLite / 远程 | 本机单用户 / 云端多用户 | [uos1070-desktop.md](uos1070-desktop.md) |
| **无 Docker 一键安装** | 个人单机尝鲜、二次开发体验;一条命令装好即用 | 不需要 | SQLite | 否(单用户) | [quick-install.md](quick-install.md) |
| **Docker Compose** | 团队 / 生产的标准形态,多用户、全功能 | 需要 | PostgreSQL | 是 | [docker-compose.md](docker-compose.md) |
-| **离线生产部署(商业版 EE)** | 政务 / 内网等隔离环境,镜像 tarball 离线交付 | 需要 | PostgreSQL | 是 | [offline-production.md](offline-production.md) |
跨平台与参考:
@@ -20,7 +18,6 @@ HugAgentOS 支持多种部署方式,从「个人单机零依赖尝鲜」到「
|---|---|
| [Windows 部署](windows-deployment.md) | 桌面端本机服务一键安装,或以 Docker Desktop + WSL2 跑 Compose 版 |
| [桌面端离线本机模式](desktop-local-mode.md) | 三平台随包私有 Python、原子升级回滚与完整/精简包 |
-| [UOS 1070 ARM64 桌面端](uos1070-desktop.md) | Electron 43、glibc 2.28、ARM64 `.deb` 构建与验收 |
| [环境变量参考](environment-variables.md) | 全量环境变量逐组说明(默认值 / 作用 / CE·EE 相关性) |
## 一句话对比
@@ -30,7 +27,6 @@ HugAgentOS 支持多种部署方式,从「个人单机零依赖尝鲜」到「
联网构建。定位是单进程单用户。
- **无 Docker 一键安装**:Linux/macOS 上最快的命令行路径。运行 `curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh | bash`,安装器会创建管理员、配置模型、启动服务并打开浏览器。数据保存在 `~/.hugagent/`。**单进程单用户**,适合个人试用与开发,不适合多人或生产环境。
- **Docker Compose**:推荐的标准部署。全部服务由一个 `docker-compose.yml` 编排(PostgreSQL + Redis + 后端 + MCP + 前端 + 沙箱),支持多用户、持久沙箱、分层记忆等全部能力。
-- **离线生产部署(EE)**:面向无法在线拉镜像的隔离环境,在联网侧打镜像 tarball,拷到生产侧 `docker load` + `compose up`。属商业版交付范畴。
> 能力对比、社区版 / 商业版差异见 [版本对比](../editions/overview.md)。
diff --git a/document/zh-CN/deployment/artifact-storage-key-repair.md b/document/zh-CN/deployment/artifact-storage-key-repair.md
deleted file mode 100644
index 0a63ffd0..00000000
--- a/document/zh-CN/deployment/artifact-storage-key-repair.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# 历史产物存储地址修复
-
-适用于旧版本把 `artifacts/.docx` 等对象登记为 `artifacts/` 的问题。
-当前生成流程要求真实存储地址,个人空间文件列表只等待本地改动登记,不再等待所有云端文件下载。
-导入窗口把请求失败显示为错误并提供重试,失败不会再显示为“暂无文件”。
-
-## 安全边界
-
-修复只匹配 `storage_key == artifacts/`。必须确认旧对象不存在、
-对应文件名后缀的对象存在、下载字节数与原登记大小一致。服务超时、鉴权失败、
-大小不符或找不到替代对象时,保留原记录。真实存在的无后缀对象保持不变。
-
-写入采用带原地址、文件名、大小、更新时间条件的原子更新;不修改内容版本。
-原地址、新地址、SHA-256 和原内容版本与新地址一起存入
-`metadata.storage_key_repair`,支持审计及有条件回滚。
-个人空间投影下载失败时会用同样的校验自动修复;未保存的本地内容仍按原有冲突保护处理。
-
-## 执行
-
-遵守仓库发布授权:预发验证后,生产代码与数据变更必须另行取得用户确认。
-在已授权环境的项目根目录执行,使用该环境已有配置与 Python 运行时。
-不要复制其他环境的 `.env` 或凭据。
-
-```bash
-# 默认只验证、输出候选地址,不改数据库。
-PYTHONPATH=src/backend python scripts/repair_artifact_storage_keys.py --user-id <用户ID>
-
-# 确认候选结果后执行;重复运行不会重复修改已修复项。
-PYTHONPATH=src/backend python scripts/repair_artifact_storage_keys.py --user-id <用户ID> --apply
-
-# 仅回滚内容版本、当前地址都未变化的已审计修复。
-# 回滚会恢复旧地址;若后台投影已升级,仍可能再次进行自动修复。
-PYTHONPATH=src/backend python scripts/repair_artifact_storage_keys.py --user-id <用户ID> --rollback
-```
-
-工具一次只处理明确指定的账号,按文件 ID 分批读取。错误退出非零,避免把部分失败当作全部成功。
-修复既有记录包含回收站中的历史项;不会恢复已删除文件或变更所属目录。
-
-## 验收
-
-1. 地址修复候选、对象大小和审计记录正确;再次运行没有重复改写。
-2. 个人空间根目录和原文件目录的列表 API 返回成功;有效文件能下载。
-3. Windows 桌面本机模式分别用 `@` 和 `+ → 从我的空间导入` 选择文件,实际发送后可读取正文。
-4. 注入单个不存在的对象时,其他文件列表可用;文件获取仍报告真实失败。
-5. 请求失败显示错误和重试;切换筛选或目录后旧响应不能覆盖当前结果。
-
-前端源码变化必须随桌面客户端发行才对已安装客户端生效。完成源码构建不代表已经发布,
-也不代表现有生产数据已修改;发布记录应注明预发、生产和客户端各自的验证结果。
diff --git a/document/zh-CN/deployment/desktop-local-mode.md b/document/zh-CN/deployment/desktop-local-mode.md
index 9add6669..2459a1ff 100644
--- a/document/zh-CN/deployment/desktop-local-mode.md
+++ b/document/zh-CN/deployment/desktop-local-mode.md
@@ -1,7 +1,6 @@
# 桌面端离线本机模式
-> 适用于 Windows x86_64、macOS Apple Silicon / Intel、Linux x86_64;UOS 1070
-> aarch64 使用独立的 [Electron 43 桌面发行线](uos1070-desktop.md)。
+> 适用于 Windows x86_64、macOS Apple Silicon / Intel、Linux x86_64。
桌面端默认发行“完整离线包”。安装包同时携带当前平台的 CE 服务源码和私有 CPython 3.11
运行时,最终用户不需要安装 Python、uv、pip、编译器或 Docker,也不需要在首次启动时访问
diff --git a/document/zh-CN/deployment/docker-compose.md b/document/zh-CN/deployment/docker-compose.md
index d16978e4..8ede9286 100644
--- a/document/zh-CN/deployment/docker-compose.md
+++ b/document/zh-CN/deployment/docker-compose.md
@@ -14,7 +14,7 @@ CE 的 Docker 部署默认使用 OpenSandbox;无 Docker 安装及桌面本机
```bash
python3 scripts/configure-ce-docker.py
-docker build -f docker/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
+docker build -f docker/sandbox/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
docker compose build backend
docker compose up -d --build
```
@@ -65,8 +65,8 @@ CE 在空数据库首次启动时会全局安装并启用 `automation`、
| 服务 | profile | 容器名 | 镜像 / 构建 | 作用 |
|---|---|---|---|---|
-| `script-runner` | `script_runner` | hugagent-script-runner | `docker/Dockerfile.script-runner` | 轻量沙箱:1 GB 内存 / 1 CPU / read-only rootfs + tmpfs,技能目录只读挂载到 `/workspace/skills` |
-| `opensandbox-config-init` | `opensandbox` | hugagent-opensandbox-config-init | `python:3.12-alpine` | 一次性 init:把 `docker/opensandbox-config.toml.tpl` 中的 `@@HOST_REPO_PATH@@` / `@@HOST_STORAGE_PATH@@` 渲染进 named volume `opensandbox_config` |
+| `script-runner` | `script_runner` | hugagent-script-runner | `docker/sandbox/Dockerfile.script-runner` | 轻量沙箱:1 GB 内存 / 1 CPU / read-only rootfs + tmpfs,技能目录只读挂载到 `/workspace/skills` |
+| `opensandbox-config-init` | `opensandbox` | hugagent-opensandbox-config-init | `python:3.12-alpine` | 一次性 init:把 `docker/opensandbox-server/opensandbox-config.toml.tpl` 中的 `@@HOST_REPO_PATH@@` / `@@HOST_STORAGE_PATH@@` 渲染进 named volume `opensandbox_config` |
| `opensandbox` | `opensandbox` | hugagent-opensandbox | `hugagent-opensandbox-server:v0.1.13-3` | CE / EE 共享控制器:经 Docker socket 管理沙盒;execd 执行命令并保留会话文件。CE 不映射宿主端口;全量仓库可配置调试端口 |
### mem0 记忆基础设施(profile `mem0`,可选)
@@ -214,19 +214,19 @@ make migrate-new msg="describe change"
| 功能 | 文件 |
|---|---|
-| 服务编排 | `docker-compose.yml`(CubeSandbox 叠加:`docker-compose.cube.yml`) |
+| 服务编排 | `docker-compose.yml`(CubeSandbox 叠加:`docker/sandbox/compose.cube.yml`) |
| 后端镜像 | `docker/Dockerfile`(multi-stage,target `production`) |
| MCP 镜像 | `docker/Dockerfile.mcp` |
-| 沙箱镜像 | `docker/Dockerfile.script-runner`、`docker/Dockerfile.opensandbox` |
+| 沙箱镜像 | `docker/sandbox/Dockerfile.script-runner`、`docker/sandbox/Dockerfile.opensandbox` |
| 前端镜像 + nginx 反代 | `src/frontend/Dockerfile`、`src/frontend/nginx.conf`、`src/frontend/default.conf.template` |
| 后端入口(自动迁移) | `src/backend/scripts/backend_entrypoint.sh` |
-| OpenSandbox 配置模板 | `docker/opensandbox-config.toml.tpl` |
+| OpenSandbox 配置模板 | `docker/opensandbox-server/opensandbox-config.toml.tpl` |
| 迁移脚本 | `alembic.ini`、`src/backend/alembic/` |
| MCP 端口单一真源 | `src/backend/extensions/mcp_servers/_ports.py` |
### OpenSandbox 命令延迟与控制器镜像
-控制器使用 `docker/Dockerfile.opensandbox-server` 构建
+控制器使用 `docker/opensandbox-server/Dockerfile.opensandbox-server` 构建
`hugagent-opensandbox-server:v0.1.13-3`。它基于固定的上游版本,对已知沙盒使用
按名称 inspect 并校验归属标签,且在线程池中执行生命周期与代理的同步 Docker 查询,
避免阻塞其它请求。构建时先校验上游源文件摘要并运行回归测试;升级上游版本时须重新审查补丁。
@@ -239,7 +239,7 @@ make migrate-new msg="describe change"
## 站点数据与浏览器部署检查
-普通 Compose 启动不启用应用数据托管,也不重建 OpenSandbox 按需运行镜像。需要这两项能力时,部署前分别完成[应用数据升级](../modules/application-hosting.md#外部检验修复与升级)和[浏览器运行镜像检查](../modules/browser-automation.md)。配置不同的 APPLICATION_DB_PASSWORD 与 APPLICATION_OWNER_DB_PASSWORD、共享 Redis 和适合网络环境的 BROWSER_DNS_RESOLVER_URL;使用 application_owner 作为应用数据运行账号。已有数据库卷也必须重新执行 application-database-init,然后更新后端、MCP、前端及沙箱运行镜像,并创建新浏览器会话进行验证。
+普通 Compose 启动不启用应用数据托管,也不重建 OpenSandbox 按需运行镜像。需要这两项能力时,部署前分别完成[应用数据升级](../modules/application-hosting.md#部署要求与数据安全)和[浏览器运行镜像检查](../modules/browser-automation.md)。配置不同的 APPLICATION_DB_PASSWORD 与 APPLICATION_OWNER_DB_PASSWORD、共享 Redis 和适合网络环境的 BROWSER_DNS_RESOLVER_URL;使用 application_owner 作为应用数据运行账号。已有数据库卷也必须重新执行 application-database-init,然后更新后端、MCP、前端及沙箱运行镜像,并创建新浏览器会话进行验证。
### WSL 内存紧张时限制构建资源
@@ -254,11 +254,11 @@ make migrate-new msg="describe change"
BUILDX_BUILDER=hugagent-limited COMPOSE_PARALLEL_LIMIT=1 docker compose build mcp
BUILDX_BUILDER=hugagent-limited COMPOSE_PARALLEL_LIMIT=1 docker compose build frontend
docker buildx build --builder hugagent-limited --load \
- -f docker/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
+ -f docker/sandbox/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .
docker buildx stop hugagent-limited
builder 已存在时直接复用。构建结束后停止专用 builder,保留缓存供下次使用。
-应用托管部署仍需叠加 docker-compose.application-hosting.yml。
+应用托管部署仍需叠加 docker/application-hosting/compose.application-hosting.yml。
不要同时运行完整构建和多组浏览器测试。WSL 上限应结合其他容器及编辑器的实际占用设置。
## 模型网关启动与排障(商业版 EE)
diff --git a/document/zh-CN/deployment/environment-variables.md b/document/zh-CN/deployment/environment-variables.md
index e83e6ab5..3361ef4b 100644
--- a/document/zh-CN/deployment/environment-variables.md
+++ b/document/zh-CN/deployment/environment-variables.md
@@ -23,7 +23,7 @@
| `HOST_REPO_PATH` | 仓库绝对路径 | 宿主机仓库根路径;backend 经 docker.sock 调宿主 daemon 重建沙盒镜像时解析 compose 相对路径用 | CE |
| `DOCKER_GID` | `999` | 宿主 docker 组 GID,授予 backend 容器读写 `/var/run/docker.sock`(`stat -c '%g' /var/run/docker.sock` 查询) | CE |
| `COMPOSE_PROFILES` | CE Docker: `opensandbox` | compose profile 选择(`script_runner` / `opensandbox`,可叠加 `,mem0`),须与 `SANDBOX_PROVIDER` 一致 | CE |
-| `COMPOSE_FILE` | (未设) | 叠加 compose 文件;CubeSandbox 时设 `docker-compose.yml:docker-compose.cube.yml` | CE |
+| `COMPOSE_FILE` | (未设) | 叠加 compose 文件;CubeSandbox 时设 `docker-compose.yml:docker/sandbox/compose.cube.yml` | CE |
| `MAX_REQUEST_SIZE` | `52428800`(代码默认 50 MB) | 后端单请求体上限(字节) | CE |
| `DB_MIGRATION_RETRIES` / `DB_MIGRATION_RETRY_INTERVAL` | `20` / `2`(入口脚本默认) | 启动迁移重试次数 / 间隔秒 | CE |
@@ -57,7 +57,7 @@
| `ENABLE_SUMMARY` | `true` | 关闭则标题回退为消息截断 | CE |
| `SUMMARY_MAX_ROUNDS` | `3` | 超过该轮数后标题不再更新 | CE |
| `OPENAI_API_KEY` / `OPENAI_API_BASE` | (空)/ `https://api.openai.com/v1` | 备用 OpenAI 直连配置(compose 透传) | CE |
-| `ROUTER_STRATEGY` | `main_only`(代码默认) | 路由策略(`orchestration/strategy.py`) | CE |
+| `ROUTER_STRATEGY` | `main_only`(代码默认) | 路由策略(`core/agent_runtime/strategy.py`) | CE |
| `FOLLOWUP_ENABLED` | `true`(代码默认) | 追问建议生成开关 | CE |
## 鉴权与登录
@@ -217,13 +217,13 @@ Config 管理台会分别保存 Dify、FastGPT、WeKnora 的 URL、API Key 与
|---|---|---|
| `OPENSANDBOX_DOMAIN` | `http://opensandbox:8080` | OpenSandbox server 地址 |
| `OPENSANDBOX_API_KEY` | CE 初始化生成 | 控制器与后端使用相同密钥;不在日志中输出 |
-| `OPENSANDBOX_IMAGE` | `hugagent-opensandbox-custom:latest`(compose 默认;代码兜底 `opensandbox/code-interpreter:v1.0.2`) | 沙盒运行时镜像;自建镜像预装项目全部依赖(`docker/Dockerfile.opensandbox`) |
+| `OPENSANDBOX_IMAGE` | `hugagent-opensandbox-custom:latest`(compose 默认;代码兜底 `opensandbox/code-interpreter:v1.0.2`) | 沙盒运行时镜像;自建镜像预装项目全部依赖(`docker/sandbox/Dockerfile.opensandbox`) |
| `OPENSANDBOX_READY_TIMEOUT_S` | `90` | 等沙盒就绪上限(秒) |
| `OPENSANDBOX_REQUEST_TIMEOUT_S` | `120` | 单次 HTTP 调用超时(秒) |
| `OPENSANDBOX_PORT` | 全量仓库 `8910`;CE 不映射 | 控制器容器内固定 8080 |
| `OPENSANDBOX_POOL_JUPYTER_MIN_IDLE` / `MAX_IDLE` | `.env.example` 1/3;compose 默认 2/3 | 用户会话池(历史配置名);CE 最小预热数为 0 |
| `OPENSANDBOX_POOL_LIGHT_MIN_IDLE` / `MAX_IDLE` | `2` / `5` | 轻量桶(一次性执行) |
-| `OPENSANDBOX_POOL_MAX_TOTAL` | CE: `4`; full: `20` | 全池上限(含使用中) |
+| `OPENSANDBOX_POOL_MAX_TOTAL` | CE: `4`; full: `20` | 控制器全局上限(所有 worker、预热与使用中实例);backend 和控制器使用同一值 |
| `OPENSANDBOX_SNAPSHOT_ENABLED` | `true` | 快照持久化总开关(idle 时 snapshot+kill,重连恢复) |
| `OPENSANDBOX_SNAPSHOT_RETENTION_DAYS` | `7` | 快照保留天数(GC 周期清理) |
| `OPENSANDBOX_SNAPSHOT_WAIT_TIMEOUT_S` | `120` | 等快照 Ready 的轮询上限(秒) |
@@ -317,16 +317,10 @@ Config 管理台会分别保存 Dify、FastGPT、WeKnora 的 URL、API Key 与
License 细节见 [License 机制](../editions/license.md)。
-## 其他(限流、熔断、安全、告警)
+## 其他(安全、告警)
| 变量 | 默认值 | 说明 | 版本 |
|---|---|---|---|
-| `RATE_LIMIT_ENABLED` | `true` | 接口限流开关 | CE |
-| `RATE_LIMIT_STORAGE` | `memory://` | 限流计数存储(可 `redis://...`) | CE |
-| `RATE_LIMIT_GLOBAL` / `RATE_LIMIT_PER_USER` | `500/minute` / `50/minute` | 全局 / 单用户限流 | CE |
-| `CB_USER_CENTER_THRESHOLD` / `CB_USER_CENTER_TIMEOUT` | `5` / `60` | 用户中心熔断器 | EE |
-| `CB_MODEL_API_THRESHOLD` / `CB_MODEL_API_TIMEOUT` | `10` / `30` | 模型 API 熔断器 | CE |
-| `CB_STORAGE_THRESHOLD` / `CB_STORAGE_TIMEOUT` | `5` / `60` | 存储熔断器 | CE |
| `CORS_ORIGINS` | compose 默认 `http://localhost:3000,http://localhost:5173` | 允许跨域来源 | CE |
| `ENABLE_SECURITY_HEADERS` | (注释,建议生产 `true`) | 安全响应头 | CE |
| `AUDIT_LOG_RETENTION_DAYS` / `AUDIT_LOG_EXPORT_ENABLED` | `90` / `true`(注释示例) | 审计日志保留 / 导出 | EE |
@@ -340,7 +334,7 @@ License 细节见 [License 机制](../editions/license.md)。
|---|---|
| 变量样例(部署起点) | `.env.example` |
| 集中式设置读取(唯一入口) | `src/backend/core/config/settings.py` |
-| compose 层注入与默认值 | `docker-compose.yml`、`docker-compose.cube.yml` |
+| compose 层注入与默认值 | `docker-compose.yml`、`docker/sandbox/compose.cube.yml` |
| 沙箱 provider 选择 | `src/backend/core/sandbox/`、`settings.py::SandboxSettings` |
| 记忆设置 | `settings.py::MemorySettings`、`src/backend/core/memory/`(service.py / pipeline.py) |
| 问题反馈 GitHub 配置 | `src/backend/edition_ee/services/system_config.py`、`feedback_service.py` |
@@ -389,7 +383,7 @@ License 细节见 [License 机制](../editions/license.md)。
模块开启后,社区版已存在的登录用户即可使用。企业版额外检查 Config 后台的用户、角色或团队 **Personal Agent** 权限(默认关闭);收回权限会隐藏入口,并停止排队、定时触发和渠道转交任务。不需要 persistent_sandbox License。两个版本都保留对象归属检查和操作批准。
-请阅读 [Personal Agent](../modules/personal-agent.md)。部署时使用根 Compose 文件、docker/compose.personal-agent.yml 和 personal-agent profile。
+请阅读 [Personal Agent](../modules/personal-agent.md)。部署时使用根 Compose 文件、docker/personal-agent/compose.personal-agent.yml 和 personal-agent profile。
diff --git a/document/zh-CN/deployment/offline-production.md b/document/zh-CN/deployment/offline-production.md
deleted file mode 100644
index 820d28e6..00000000
--- a/document/zh-CN/deployment/offline-production.md
+++ /dev/null
@@ -1,79 +0,0 @@
-# 离线生产部署(商业版 EE)
-
-> 最后更新:2026-10-16 | [English](../../en/deployment/offline-production.md) | 返回 [部署指南](README.md)
-
-离线交付使用 `deploy` 分支的编译加固镜像及配套部署 kit。
-构建和打包在供应方的独立 `deploy` 工作区执行;目标机加载镜像并使用同批 kit 启动。
-构建、分支同步、上传和生产发布分别遵守项目授权规则。
-
-## 联网侧:准备完整交付包
-
-1. 核对已验证的 `deploy` 提交、干净工作区、产品身份及目标 CPU 架构。
-2. 完整读取该提交的 `deploy_kit/save_pack_ee.sh` 和配套 Compose 文件。
-3. 构建并打包:
-
- ```bash
- # 在 deploy 分支的工作区根目录执行
- bash deploy_kit/save_pack_ee.sh --build
- # 编译镜像已存在且已核对本次提交时,可直接打包
- bash deploy_kit/save_pack_ee.sh
- ```
-
-脚本校验编译后端、MCP、前端及必备基础设施镜像,并按本机存在情况加入可选镜像。
-交付方还须核对目标 profiles 的全部依赖,不能把“可选镜像未安装”当作目标机不需要它。
-镜像名、版本、输出目录与文件名以该提交的脚本和 manifest 为准,不手工改名。
-交付包同时包含镜像归档、manifest 和匹配的部署 kit;License 单独安全交付。
-不得用普通源码镜像或只包含后端和前端的增量包替代完整 EE 交付包。
-
-## 目标侧:加载与上线
-
-按交付 kit 的说明配置独立项目名、数据目录、License 和必要的受保护环境文件。
-升级前备份数据库与文件,确认镜像 manifest、目标架构和所选 profiles 一致。
-授权后加载指定镜像归档,并使用同批 kit 的启动入口及完整 Compose 覆盖文件。
-目标机不能联网拉镜像时,必须先确认所需镜像已全部加载,启动不得触发现场构建。
-不使用旧源码部署脚本,也不自动选取目录里时间最新但未经核验的归档。
-
-验收至少包括 `/health`、`/ready`、登录、附件、模型调用及实际启用的沙箱能力。
-数据库迁移会改变 schema;数据卷持久化不代表升级不会修改业务数据。
-失败时按交付记录中的镜像、配置和数据备份恢复,不删除数据卷。
-
-## 提示词快照随包带入
-
-系统提示词存在数据库 `content_blocks` 表(`prompt_versions` 版本池 + `prompt_hub` 提示词广场),**不进仓库、不在镜像里**。要把新提示词推上生产,需作为单独数据文件随镜像包带入并在生产侧导入:
-
-### 1. 联网侧导出
-
-通过 `GET /v1/content/prompts/export` 导出快照。请求使用源环境的管理员或系统配置令牌。
-将响应的 `data` 对象保存为 `prompts_snapshot_.json`,不要保存完整响应信封。
-
-### 2. 拷贝
-
-把 `prompts_snapshot_.json` 与镜像 tarball 一起拷到生产机。
-
-### 3. 生产侧导入
-
-前提:生产 backend 镜像须已包含 `POST /v1/content/prompts/import` 接口(`src/backend/api/routes/v1/content.py`)。在新 backend 起好后:
-
-```bash
-BACKEND_CONTAINER=your-backend-container
-docker cp prompts_snapshot_.json "${BACKEND_CONTAINER}:/tmp/"
-docker exec "$BACKEND_CONTAINER" curl -sS -X POST \
- 'http://localhost:3001/v1/content/prompts/import?overwrite=true' \
- -H "Authorization: Bearer <生产ADMIN_TOKEN>" \
- -H 'Content-Type: application/json' \
- -d @/tmp/prompts_snapshot_.json
-```
-
-API 导入会自动失效提示词缓存,**无需重启 backend**。
-
-> 这是按需的一次性数据操作:DB 卷持久化,不必每次发版都导提示词——只在要把新提示词推上生产时执行。
-
-## 相关源码
-
-| 功能 | 文件 |
-|---|---|
-| EE 完整镜像包(deploy 分支) | `deploy_kit/save_pack_ee.sh` |
-| EE 编译镜像(deploy 分支) | `docker/Dockerfile.backend-compiled`、`docker/Dockerfile.mcp-compiled` |
-| Compose 覆盖(deploy 分支) | `docker-compose.prod.yml`、`docker-compose.deploy.yml`;运行时以交付 kit 为准 |
-| 提示词导出 / 导入 | `src/backend/api/routes/v1/content.py` |
-| 提示词版本池 | `src/backend/core/llm/prompt_versions.py` |
diff --git a/document/zh-CN/deployment/quick-install.md b/document/zh-CN/deployment/quick-install.md
index 51912d98..d24ab26b 100644
--- a/document/zh-CN/deployment/quick-install.md
+++ b/document/zh-CN/deployment/quick-install.md
@@ -187,7 +187,7 @@ hugagent doctor # 环境自检(Python 版本、端口占用、数据目录
| CLI(onboard / serve / doctor) | `src/backend/cli.py` |
| 本地模式开关 | `src/backend/core/config/settings.py`(`DeploySettings`,`DEPLOY_PROFILE=local`) |
| 前端静态托管 + `/api` 桥接 | `src/backend/api/local_hosting.py` |
-| MCP / 沙箱子进程督管 | `src/backend/orchestration/local_subprocess.py` |
-| 进程内事件流 / 短时状态 | `src/backend/orchestration/run_event_stream.py`、`src/backend/core/infra/ephemeral.py`(`REDIS_URL` 留空即启用) |
+| MCP / 沙箱子进程督管 | `src/backend/core/infra/local_runtime/` |
+| 进程内事件流 / 短时状态 | `src/backend/core/runs/events/`、`src/backend/core/infra/ephemeral.py`(`REDIS_URL` 留空即启用) |
| 内置 MCP 目录种子 | `src/backend/core/capabilities/mcp/service.py`(`seed_builtin_mcp_servers_if_empty`) |
| 环境变量参考 | [environment-variables.md](environment-variables.md)(「无 Docker 本地模式」段) |
diff --git a/document/zh-CN/deployment/space-files.md b/document/zh-CN/deployment/space-files.md
index 275082d5..571ec0cd 100644
--- a/document/zh-CN/deployment/space-files.md
+++ b/document/zh-CN/deployment/space-files.md
@@ -44,20 +44,16 @@ S3 服务部署在本机时,请使用容器可达的服务地址。
撤回团队权限。确认该用户无法访问该团队目录。
验证成功后恢复用户操作。
+## 限制
-## 验证范围
-
-S3 挂载通过本地独立 MinIO、控制器、沙盒和浏览器验证。
-测试覆盖上传、下载、目录列表、只读写入拒绝、目录隔离和挂载撤销。
-OSS 和 OBS 适配尚需在各自的测试桶中验证。
-对象存储目录移动包含多个对象操作。它不是原子的文件系统重命名。
+对象存储目录移动包含多个对象操作,不是原子的文件系统重命名。
OBS 的 V4 配置依据[华为云说明](https://support.huaweicloud.com/obs_faq/obs_faq_0274.html)。
OSS 的 S3 V2 签名依据[阿里云说明](https://www.alibabacloud.com/help/tc/oss/user-guide/0002-00000307)。
## 本机 S3 服务(CE / EE)
-将 `docker/compose.object-storage.yml` 加入 `COMPOSE_FILE`。
+将 `docker/object-storage/compose.object-storage.yml` 加入 `COMPOSE_FILE`。
该服务使用独立数据卷。它不发布宿主端口,也不使用 Milvus 的数据卷。
配置 `S3_ENDPOINT=http://object-storage:9000` 和 `S3_BUCKET=hugagent-files`。
分别生成随机 `S3_ACCESS_KEY` 与 `S3_SECRET_KEY`,写入权限为 600 的环境文件。
@@ -114,4 +110,4 @@ MCP 接口位于 `src/backend/extensions/mcp_servers/space_files_mcp/`。
缺失、过期或参数变更的票据不能发出网关请求。
桌面升级需同时交付新版内置后端和云端后端,并重新同步能力清单。
-对象存储镜像使用 MinIO 官方 GitHub Release 中固定 SHA-256 的二进制文件。Docker 通过 docker/Dockerfile.object-storage 构建此镜像。已有 Milvus 镜像和数据卷保持独立。
+对象存储镜像使用 MinIO 官方 GitHub Release 中固定 SHA-256 的二进制文件。Docker 通过 docker/object-storage/Dockerfile.object-storage 构建此镜像。已有 Milvus 镜像和数据卷保持独立。
diff --git a/document/zh-CN/deployment/uos1070-desktop.md b/document/zh-CN/deployment/uos1070-desktop.md
deleted file mode 100644
index ea0c741e..00000000
--- a/document/zh-CN/deployment/uos1070-desktop.md
+++ /dev/null
@@ -1,87 +0,0 @@
-# UOS 统信 1070 aarch64 桌面端
-
-> [English](../../en/deployment/uos1070-desktop.md)
-
-UOS 1070 aarch64 使用仓库中的 `desktop-uos/` Electron 43 发行线。交付格式固定为
-Debian ARM64 `.deb`;它不依赖系统 WebKitGTK 4.1,因此不受 Tauri 2 在 Debian 10 / UOS
-1070 上的 WebView 依赖阻断。
-
-## 支持范围
-
-| 项目 | 生产基线 |
-|---|---|
-| 操作系统 | UOS 1070 桌面版,aarch64 |
-| libc | glibc 2.28 |
-| 桌面壳 | Electron 43.4.1 |
-| 安装包 | Debian `arm64` `.deb` |
-| 本机运行时 | 私有 CPython 3.11,`aarch64-manylinux_2_28` 锁 |
-| 数据目录 | `~/.hugagent` |
-| 配置目录 | `~/.config/com.hugagent.desktop` |
-
-完整包支持本机、云端和双模式,能力与现有桌面端一致:deep-link 登录、回环反代、流式
-对话、托盘/快捷问答、原生通知、本地文件夹项目、双上游路由、身份与模型配置桥接、离线
-本机服务安装/升级/回滚、SHA-256 验证更新。
-
-UOS 默认隐藏窗口内菜单栏,保留系统标题栏与窗口控制。托盘的“文件、编辑、视图、帮助”
-提供完整桌面命令;Ctrl+Shift+N 新建窗口、Ctrl+N 新建对话继续可用。工作区与 macOS
-共用模块导航渐变、内层侧栏与内容圆角、浅深主题和加载布局;快速问答保持原有紧凑布局。
-UOS 的原生标题栏位于网页区域外,因此不再额外占用网页顶部安全区。
-
-发行品牌可以为完整包预设首启模式;固定双模式的发行版会直接进入一键初始化。初始化与
-安装页使用与现有桌面端一致的光晕、轨道和进度高光动画,只显示阶段、百分比与可操作错误,
-不在页面底部展示原始日志。日志仍保存在本机服务日志目录,便于排障。
-
-## 构建与验证
-
-完整包只能在原生 Linux aarch64 构建机生成,推荐直接使用 UOS 1070 构建机。x86_64
-机器只能交叉生成不含 Python 运行时的精简包。
-
-```bash
-cd desktop-uos
-npm install
-npm test
-npm run build
-npm run verify:deb -- "dist/HugAgentOS UOS_0.5.15_uos1070_arm64.deb"
-```
-
-验收时至少检查:
-
-1. `dpkg-deb --field <包> Architecture` 返回 `arm64`;
-2. Electron 主 ELF 的最高 GLIBC 引用不超过 2.28;
-3. 断网冷启动完整包,本机服务安装阶段没有 `pip install`、uv 或 Python 下载;
-4. 本机、云端和双模式分别完成登录、SSE 对话、附件上传下载、文件夹选择和通知;
-5. 双模式下云端项目进云端,本地文件夹项目进本机;
-6. 使用测试版更新清单完成 SHA-256 校验、系统授权安装和重启。
-
-UOS 与 Windows、macOS 共用同一个发布索引:客户端按自己的平台取更新,某个平台还没发包
-时明确提示「当前平台暂无可用更新」,而不带平台参数的旧客户端读到的共同版本要等该版本
-三个平台都发齐才前进。桌面端版本号三平台保持一致,构建时会校验。
-
-具体命令、产物和发布步骤见 [`desktop-uos/README.md`](../../../desktop-uos/README.md)。
-
-> Electron 43 官方支持计划在 2027 年 1 月结束。它是 UOS 1070 的当前兼容基线,生产
-> 维护仍需跟进最新 43.x 补丁,并持续验证 Electron 44+ 后续迁移。
-
-## 与当前 Tauri 功能同步
-
-UOS 复用当前源码中的 React 前端与 CE 本机后端。重新构建完整包会带入最新能力来源、
-本机启停、技能同步和工作目录逻辑;只更新云端不会替换已安装客户端的这些资源。
-
-- “文件”菜单支持打开文件夹并进入本机项目对话;普通包可重新选择运行模式。
-- “视图”提供放大、缩小、实际大小(Ctrl + +/-/0,或 Ctrl+滚轮)。缩放档位与 Tauri
- 一致,保存在 `prefs.json`,新窗口和重启沿用;系统 DPI 保持原生处理。
-- “帮助”、托盘和侧栏共用更新流程。后台订阅发布通知,仅提示新版;确认后显示下载进度,
- 下载阶段可取消。安装仍校验 Ed25519 签名和 SHA-256,并由 UOS 系统授权。
- 旧服务器使用低频检查,断线退避重连;并发检查不会重复安装。
-- 回环反代在注入会话和桥接凭据前检查 Origin/Fetch Metadata;不带来源的直接请求被拒绝。
-- 服务日志运行中轮转,每份最大 32 MiB,只保留当前和上一份。能力未就绪时的检查间隔
- 从 500 ms 逐步增加至 15 秒。升级保留原 UOS 数据及能力目录,不自动迁移已有配置。
-
-完整包还支持与 Tauri 相同的仅混合模式构建入口:
-
-```bash
-JX_DEFAULT_SERVER_BASE=https://agent.example.com npm run build:hybrid-only
-```
-
-该入口固定云端地址与双模式,必须提供云端地址;精简包不能用于仅混合模式。
-完整 ARM64 离线包仍须在原生 aarch64 构建机完成构建和安装验收。
diff --git a/document/zh-CN/development/backend.md b/document/zh-CN/development/backend.md
index 414a2dc2..8e8c4b21 100644
--- a/document/zh-CN/development/backend.md
+++ b/document/zh-CN/development/backend.md
@@ -68,10 +68,10 @@ PYTHONPATH=src/backend python -m pytest src/backend/tests/memory -q
## Alembic 迁移流程
-1. 在 `src/backend/core/db/models.py` 改 ORM 模型;
+1. 在 `src/backend/core/db/models/` 改 ORM 模型;
2. `make migrate-new msg="add xxx table"` 自动生成迁移(**人工审阅生成结果**,autogenerate 不可全信);
3. `make migrate`(或容器内 `alembic upgrade head`)应用;
-4. 若是 **EE 专属表**:把表名加进 `core/db/edition_tables.py::EE_ONLY_TABLES`(CE 不建 EE 空表,集合内表名有存在性断言)。
+4. 若是 **EE 专属表**:把表名加进 `edition_ee/db/edition_tables.py::EE_ONLY_TABLES`(CE 不建 EE 空表,集合内表名有存在性断言)。
注意:主仓 alembic 链是 EE 链;CE 派生树用独立的 `ce_0001` 基线链(见 [CE 构建管线](../editions/build-ce.md#ce-数据库差异)),主仓新增迁移不会进入 CE。
@@ -188,7 +188,7 @@ PYTHONPATH=src/backend python -m extensions.mcp_servers._mcp._selftest
## 新功能检查清单
-- [ ] ORM 模型 + 索引 + 时间戳(`core/db/models.py`),EE 表已加 `EE_ONLY_TABLES`
+- [ ] ORM 模型 + 索引 + 时间戳(`core/db/models/`),EE 表已加 `EE_ONLY_TABLES`
- [ ] Repository 过滤软删除;Service 承载业务与权限
- [ ] 路由用信封响应、异常走 `core/infra/exceptions`
- [ ] 路由已登记 `CE_ROUTERS` / `EE_ROUTERS`(EE 配能力位 + manifest 排除)
@@ -205,10 +205,10 @@ PYTHONPATH=src/backend python -m extensions.mcp_servers._mcp._selftest
| 响应信封 | `src/backend/core/infra/responses.py` |
| 异常体系 | `src/backend/core/infra/exceptions.py` |
| 依赖注入 | `src/backend/api/deps.py` |
-| ORM / 建表边界 | `src/backend/core/db/models.py`、`src/backend/core/db/edition_tables.py` |
+| ORM / 建表边界 | `src/backend/core/db/models/`、`src/backend/edition_ee/db/edition_tables.py` |
| MCP 端口表 / 连接配置 | `src/backend/extensions/mcp_servers/_ports.py`、`src/backend/core/config/mcp_config.py` |
| 能力目录 | `src/backend/core/config/catalog.json` |
| 技能加载 | `src/backend/core/capabilities/skills/`(`config.py` / `loader.py` / `registry.py`) |
-| 工作流编排 | `src/backend/orchestration/workflow.py` |
+| 工作流编排 | `src/backend/core/agent_runtime/runner.py` |
相关阅读:[前端开发指南](frontend.md) · [API 总览](../api/overview.md) · [MCP 工具](../modules/mcp-tools.md) · [技能系统](../modules/agent-skills.md)
diff --git a/document/zh-CN/development/container-layout.md b/document/zh-CN/development/container-layout.md
deleted file mode 100644
index 3eb59e90..00000000
--- a/document/zh-CN/development/container-layout.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# 容器响应式布局
-
-聊天工具栏跟随所在面板的可用宽度调整,首页、对话页、项目页和分屏共用同一套布局。
-
-- 工具区与模型/发送区分组,空间不足时自然换行。
-- 标签逐级收起,保留图标、可访问名称和模式退出按钮;模型名称保留并省略长文本。
-- 触屏使用真实的 44px 点击区域,参与排版,不叠加透明命中层。
-- 项目、能力目录和知识库卡片按可用宽度自动排列,长名称不会撑破网格。
-- 系统安全区、页面高度与弹出菜单仍按其实际视口处理。
-
-实现入口:前端的 components/chat/ComposerToolbar.tsx、styles/chat/toolbar-layout.css、
-styles/projects.css 和 styles/catalog/。容器边界负责提供宽度,后代负责响应;
-不要让元素查询自身,也不要通过 JavaScript 重复维护 CSS 断点。
-
-在 WSL 项目目录进入 src/frontend,使用项目支持的 Node.js 版本执行:
-
- npm run test:container-layout-browser
- npm run test:canvas-layout-browser
- npm run test:chat-width-browser
- npm run lint
- npm run build
-
-新增用例覆盖固定大窗口中的窄容器、手机窗口、分数像素边界、深浅主题、
-长名称、全部模式、触屏目标、键盘退出及发送/中止。
diff --git a/document/zh-CN/development/frontend.md b/document/zh-CN/development/frontend.md
index 93e5c6cf..9c2a8d93 100644
--- a/document/zh-CN/development/frontend.md
+++ b/document/zh-CN/development/frontend.md
@@ -132,6 +132,13 @@ const multiTenancy = useEditionStore((s) => (s.loaded ? !!s.features.multi_tenan
- 新增 EE 界面区块时:① 用上面的选择器按对应能力位门控;② 若整个页面属 EE(如 /admin、/config),无需 runtime 门控——CE 派生树直接物理排除组件目录并用 overlay 的 `main.tsx` 摘掉路由(见 [CE 构建管线](../editions/build-ce.md));
- 处理接口报错时记得识别 `LicenseError`(402),给出「联系管理员更新 license」而非笼统失败提示。
+## 容器响应式布局
+
+聊天工具栏、项目 / 能力目录 / 知识库卡片按所在面板的可用宽度排版,首页、对话页、项目页和分屏共用同一套规则:
+容器提供宽度,后代按容器响应;不要让元素查询自身,也不要用 JavaScript 重复维护 CSS 断点。触屏点击区域为真实的 44px。
+入口:`components/chat/ComposerToolbar.tsx`、`styles/chat/toolbar-layout.css`、`styles/projects.css`、`styles/catalog/`。
+修改后运行 `npm run test:container-layout-browser`、`npm run test:chat-width-browser`。
+
## 新功能检查清单
- [ ] 组件 PascalCase + named export + Props 接口
diff --git a/document/zh-CN/development/workbench-routing.md b/document/zh-CN/development/workbench-routing.md
index 2c558825..40ab74c9 100644
--- a/document/zh-CN/development/workbench-routing.md
+++ b/document/zh-CN/development/workbench-routing.md
@@ -10,6 +10,6 @@
一个应用可以在二级视图中引用自己的 workspace 模块。启动接口只接受主模块及该应用声明的二级模块,不接受安装中其他未声明模块。
-此功能没有数据库结构迁移。旧 UI v1/v2、托管应用接口和旧业务地址保留原有契约。完整多平台端到端验证由集成验收报告记录;专项夹具通过不表示原生客户端已通过。
+此功能没有数据库结构迁移。旧 UI v1/v2、托管应用接口和旧业务地址保留原有契约。
目录为空时保留深链接并显示空状态。准确应用查询的网络或服务器故障显示重试,不误报为无权限或不存在。
diff --git a/document/zh-CN/editions/build-ce.md b/document/zh-CN/editions/build-ce.md
index 8ac88134..966063fa 100644
--- a/document/zh-CN/editions/build-ce.md
+++ b/document/zh-CN/editions/build-ce.md
@@ -81,10 +81,10 @@ CE 前端不按工具名猜测远程 MCP 的业务结构。用户接入的远程
| `.hugagent-edition` | 仅在派生后出现的机器可读 `ce` 标识;让发布工具区分派生 CE checkout 与生成器异常缺失的源代码 checkout |
| `.github/workflows/desktop-release.yml` | 公开 CE 桌面发版 workflow,包含 release tag / 版本前置门禁 |
| `src/backend/api/routes/v1/__init__.py` | CE 路由注册表;`EE_ROUTERS` 恒为空 |
-| `src/backend/core/auth/permissions_iface.py` | 单租户 owner-only 权限接口;不导出团队权限函数 |
-| `src/backend/core/files/artifacts/edition.py` | 个人空间 artifact 作用域接口;不暴露团队字段、权限或仓储方法 |
+| `src/backend/core/auth/personal_permissions_iface.py` | 单租户 owner-only 权限接口;不导出团队权限函数 |
+| `src/backend/core/files/edition.py` | 个人空间 artifact 作用域接口;不暴露团队字段、权限或仓储方法 |
| `src/backend/core/llm/tools/edition_{myspace,myspace_vfs,artifact_recovery}.py` | 个人空间工具、VFS 与恢复接口;组织空间实现不进入 CE |
-| `src/backend/core/config/edition_display_names.py` | CE 工具展示名;不包含团队工具名称 |
+| `src/backend/core/config/display_names.py` | CE 工具展示名;不包含团队工具名称 |
| `src/backend/core/memory/audit.py` | 记忆审计 no-op stub(同名接口、不落数据) |
| `src/backend/alembic/versions/ce_000{1,2}_*.py` | CE 独立迁移链基线与幂等 Schema 升级(见下节) |
| `src/backend/orchestration/workers/edition_startup.py` | CE 启动接缝;为 EE 数据源恢复、蒸馏调度提供 no-op 实现,避免导入已裁除模块 |
@@ -183,7 +183,7 @@ EE 始终全量建表。维护规则:**新增 EE 专属模型必须放进 `edi
| overlay 目录 | `ce/overlay/` |
| 派生树版本标识 | `ce/overlay/.hugagent-edition` |
| 公开桌面发版 workflow | `ce/overlay/.github/workflows/desktop-release.yml` |
-| CE/EE 建表边界 | `src/backend/core/db/edition_tables.py` |
+| CE/EE 建表边界 | `src/backend/edition_ee/db/edition_tables.py` |
| 幂等 Schema 升级器 | `src/backend/core/db/schema_reconcile.py` |
| 启动建表 CE 分支 | `src/backend/core/db/engine.py::init_db` |
| CE 独立迁移链 | `ce/overlay/src/backend/alembic/versions/ce_000{1,2}_*.py` |
diff --git a/document/zh-CN/getting-started/introduction.md b/document/zh-CN/getting-started/introduction.md
index c937aa53..8193115f 100644
--- a/document/zh-CN/getting-started/introduction.md
+++ b/document/zh-CN/getting-started/introduction.md
@@ -1,93 +1,118 @@
# 产品简介
-> 最后更新:2026-07-02
+> 最后更新:2026-10-23 | [English](../../en/getting-started/introduction.md)
-HugAgentOS 是一个企业级 AI Agent 平台:以 ReAct 智能体为内核,把对话、工具调用(MCP)、技能、沙箱代码执行、长期记忆、知识库 RAG、自动化与批量处理整合在一套全容器化(Docker Compose)的系统里。后端基于 FastAPI + AgentScope 2.0,前端基于 React 19 + Vite + Zustand,所有能力通过统一的 catalog 注册表按需启停,开箱即是一个可私有化部署的完整产品,而非框架半成品。
+HugAgentOS 是一个开源、可自托管的 **Agent Harness**。模型负责思考,Harness 负责模型之外的一切:
+任务在哪里运行、断了怎么接上、工具和沙箱怎么用、什么操作需要人批准、经验怎么沉淀。
-平台分为**社区版(CE,开源)**与**商业版(EE)**两个形态:社区版面向个人与小团队,包含完整的对话、工具、技能、沙箱、三层记忆、自动化、批量、数据画布等能力;商业版在此之上叠加团队协作、SSO、审计合规、行业数据工具、持久沙箱、云存储与完整管理台等组织级能力。详细边界见 [版本对比](../editions/overview.md)。
+它的核心是 **Durable Agent**:你提出的问题在云端执行,而不是依赖你的浏览器或电脑。关掉网页、
+合上笔记本,任务继续推进;服务器重启或宕机,其他实例从上次保存的进度接着做;不确定是否已经
+执行的外部操作不会被自动重做。在此之上,HugAgentOS 提供一个 7×24 小时在线的
+**Personal Agent**,并以 **HugAgentOS Cloud** 与 **HugAgentOS Local** 两种形态运行。
+
+平台分为**社区版(CE,开源)**与**商业版(EE)**:社区版让个人完整运行 Harness 与
+Personal Agent;商业版补充团队协作、SSO、审计合规、行业数据工具等组织级能力。
+详细边界见[版本对比](../editions/overview.md)。
+
+## 运行形态
+
+| 形态 | 运行位置 | 数据与状态 | 适合 | 部署文档 |
+|---|---|---|---|---|
+| **HugAgentOS Cloud** | 你的服务器或私有云 | PostgreSQL、Redis、S3 兼容存储、OpenSandbox | 7×24 Personal Agent、长任务、IM 渠道、多设备访问 | [Docker Compose](../deployment/docker-compose.md) |
+| **HugAgentOS Local** | 你的电脑(Windows / macOS / Linux) | 本地 SQLite、Milvus Lite、本地文件 | 离线、敏感数据、直接处理本地项目 | [桌面离线本机模式](../deployment/desktop-local-mode.md) |
+| **本机 + 云端** | 云端为控制面,本机为执行面 | 云端项目存云端,本地项目留本机 | 云端常驻,同时操作本机电脑与项目 | [桌面离线本机模式](../deployment/desktop-local-mode.md) |
+| 一键安装 | 开发机 | SQLite、进程内状态、子进程沙箱 | 试用与二次开发 | [无 Docker 一键安装](../deployment/quick-install.md) |
+
+7×24 持久运行需要 HugAgentOS Cloud。一键安装不提供跨进程的租约接管,只适合试用。
## 核心特性总览
| 能力 | 说明 | 版本 |
|---|---|---|
-| 对话智能体 | SSE 流式对话、AgentScope 2.0 ReActAgent、计划模式(Plan Mode)子智能体、深度思考、`[ref:tool-N]` 引用溯源、会话标题自动摘要 | CE |
-| 子智能体 | 用户自建子智能体、@提及协作、路由策略(`ROUTER_STRATEGY`) | CE(版本管理/组织级智能体库为 EE) |
-| MCP 工具生态 | 9 个内置 MCP server(CE 7 个通用工具:联网搜索、网页抓取、图表生成、批量计划、自动化任务管理、技能管理、知识检索;EE 2 个行业工具:数仓查询、产业链信息),独立 `mcp` 容器以 streamable-http 提供;用户可自助接入远程 HTTP/SSE MCP | CE(产业链/企业画像/数仓等行业工具为 EE) |
-| 技能系统 | Agent Skills(SKILL.md + 脚本):内置技能包、管理台上传、技能市场浏览安装、技能蒸馏 | CE(技能审核/组织治理为 EE) |
-| 沙箱执行 | `bash` / `sandbox_put_artifact` / `sandbox_get_artifact` 工具,三种 provider 可切换:script_runner(轻量内置)、OpenSandbox(持久会话 + Jupyter 上下文 + 快照)、CubeSandbox(E2B 兼容 MicroVM) | CE 轻量沙箱;持久沙箱(会话保持/快照)为 EE |
-| 记忆系统 | mem0 三层记忆:L1 个人画像、L2 向量记忆(Milvus)、L3 知识图谱(Neo4j),跨会话注入与后台抽取 | CE(记忆审计为 EE) |
-| 知识库 RAG | 文档上传、分块、向量 + 关键词混合检索、私有知识库;EE 可追加公共库与 Dify 外部知识库 | CE(公共库与 Dify 对接为 EE) |
-| 自动化 | 定时任务 / Cron 调度 / Prompt 与计划自动化 / 失败重试(`orchestration/schedulers/`) | CE |
-| 数据画布 | 基于 Univer 的在线表格编辑 | CE 个人编辑;多人实时协同为 EE |
-| 项目空间 / 我的空间 | 个人项目(文件容量配额)、个人网盘(myspace)、收藏、会话分享 | CE(团队文件夹/权限矩阵为 EE) |
-| 管理台 | `/admin` 内容管理(技能、能力中心、版本说明)+ `/config` 系统配置台(提示词版本池、模型、用户/团队、计费、审计、安全) | CE 基础管理;完整管理台(用户/团队/计费/审计/安全)为 EE |
-
-## CE 与 EE
-
-一句话区分:**社区版让一个人把平台用到极致,商业版让一个组织规模化地用起来**——团队协作、SSO、RBAC、审计合规、行业数据工具、持久沙箱、云存储、white-label 属于商业版。完整功能矩阵与授权方式见 [版本对比](../editions/overview.md) 与 [License 机制](../editions/license.md)。
+| Durable Run | 每次请求是一条持久化的 Run,后台执行;断线续播、运行中追加指令;租约接管与启动对账;工具副作用记录意图与回执,结果未知不自动重放 | CE |
+| Personal Agent | 单一对话入口、并行后台任务、永久云电脑、定时任务与心跳、IM 渠道、操作审批、文件化记忆与 Dreaming、连接本机电脑 | CE(授权治理与跨设备会话管理为 EE) |
+| 自主循环 | 目标跨轮存活、做完为止;worker 干活、只读评审员验收,命令可判定的需求由 driver 机检;中断后可续跑 | CE |
+| 对话与计划模式 | SSE 流式、ReAct 工具编排、深度思考、计划模式、引用溯源、上下文压缩检查点、会话分叉 | CE |
+| 子智能体 | 内置 explorer / worker / reviewer,用户自建子智能体,自动路由或 `@` 提及协作 | CE(组织级智能体库为 EE) |
+| MCP 工具与插件 | 内置联网搜索、网页抓取、知识检索、图表、自动化、技能与插件管理等工具;MCP 市场、自建私有 MCP、插件市场、浏览器自动化插件 | CE(数仓查询与行业工具为 EE) |
+| Agent Skills | SKILL.md + 脚本,内置技能、技能市场与个人技能 | CE(技能审核与组织治理为 EE) |
+| 沙箱执行 | OpenSandbox 会话、环境复用与快照恢复,或本地子进程沙箱;工作流后台作业以受管进程运行,可按租约接管 | CE(CubeSandbox 与团队挂载为 EE) |
+| 记忆与个人进化 | L1 画像、L2 做法记忆(Milvus,可选)、L3 关系图谱(Neo4j,可选);持久 Outbox 写入;进化建议逐条审批 | CE(记忆审计为 EE) |
+| 知识库 | 文档分块、向量 + 关键词混合检索、私有知识库、知识库 Wiki | CE(公共库管理与 Dify 对接为 EE) |
+| 领域本体治理 | Domain Pack 导入与版本发布、工具执行前规则检查、高风险答案独立评审、演进草案人工审核 | CE |
+| 自动化 | 自然语言创建定时任务、Cron 调度、失败重试 | CE |
+| 站点与应用托管 | 对话建站并一键发布;应用独立数据库与 MCP 托管 | CE(项目转换后的站点协作为 EE) |
+| 数据画布与产物 | Univer 在线表格、图表、报告、Office 文件、网页等产物与产物中心 | CE 个人编辑;多人协同为 EE |
+| 项目空间 / 我的空间 | 个人项目、文件夹、收藏、会话分享;桌面端可绑定本地项目目录 | CE(团队文件夹与权限矩阵为 EE) |
+| 桌面客户端 | Tauri 2 客户端,自带私有运行时,支持仅本机、仅云端、本机 + 云端三种模式 | CE |
## 技术栈
-| 层 | 技术 | 备注 |
-|---|---|---|
-| 后端 | FastAPI + Uvicorn(Python 3.11) | `src/backend/api/app.py`,统一响应 envelope |
-| 智能体框架 | AgentScope 2.0(`agentscope==2.0.0`) | ReActAgent + 工具注册,`core/llm/factory/build.py` |
-| 前端 | React 19 + Vite 7 + Zustand 5 + Ant Design 6 | `src/frontend/`,nginx 容器托管并反代 `/api` |
-| 数据库 | PostgreSQL 15(生产)/ SQLite(本地调试兜底) | SQLAlchemy 2 + Alembic 迁移 |
-| 缓存 / 会话 | Redis 7 | 会话存储、流式 follower(Redis Streams) |
-| 向量库 | Milvus 2.4(mem0 profile) | L2 向量记忆与自建知识库检索 |
-| 图数据库 | Neo4j 5 Community(mem0 profile,可选) | L3 知识图谱记忆 |
-| 沙箱 | script_runner sidecar / 阿里 OpenSandbox / 腾讯 CubeSandbox | `core/sandbox/` provider 协议,env 切换 |
-| 部署 | Docker Compose(profiles:`script_runner` / `opensandbox` / `mem0`) | 全部服务容器化,无本地 dev server |
+| 层 | 技术 |
+|---|---|
+| 智能体运行时 | AgentScope 2.0(ReActAgent)、Model Context Protocol |
+| 后端 | Python 3.11、FastAPI、SQLAlchemy 2、Alembic |
+| 前端 | React 19、TypeScript、Vite、Zustand、Ant Design |
+| 桌面端 | Tauri 2、私有 CPython 3.11 运行时 |
+| 数据与状态 | PostgreSQL 15 或 SQLite;Redis 7 或进程内状态 |
+| 存储 | S3 兼容存储(云端)、本地文件(桌面与一键安装) |
+| 沙箱 | OpenSandbox、本地子进程(CubeSandbox 为 EE) |
+| 可选记忆 | Milvus 2.4、Neo4j 5 Community、mem0 |
+| 部署 | Docker Compose、桌面安装包、一键安装脚本、Nginx |
## 整体架构
```
- ┌──────────────────────────────────────────────┐
- Browser ──► Nginx ────►│ FastAPI backend (src/backend/api/app.py) │
- (frontend 容器, │ api/routes/v1/* · 50+ 路由 · 统一 envelope │
- /api 反向代理) └───────────────────┬──────────────────────────┘
- │
- ┌──────────────────────┼─────────────────────────┐
- ▼ ▼ ▼
- orchestration/workflow.py core/services/* core/auth/*
- (SSE 流式编排:text / (业务服务层) (local / mock /
- tool_call / tool_result remote+SSO)
- / meta / done)
- │
- ┌─────────────┼───────────────────┬─────────────────────┐
- ▼ ▼ ▼ ▼
- core/llm/ orchestration/ orchestration/ core/memory/ (svc)
- factory/build strategy.py citations.py + memory_integration
- (AgentScope (路由策略) (引用解析) (mem0 检索/保存)
- 2.0 ReAct) │
- │ ▼
- ├──► core/llm/mcp_manager ──► mcp 容器 Milvus / Neo4j
- │ (9 个 MCP server,独立 streamable-http 端口) (mem0 profile)
- ├──► core/sandbox/* ──► script-runner / OpenSandbox / CubeSandbox
- └──► PostgreSQL · Redis · 存储 (local / S3 / OSS)
+ 入口:Web · 桌面客户端 · IM 渠道 · API · 定时任务 · 心跳
+ │
+ ▼
+ FastAPI(api/) 鉴权 · 版本路由 · 消息先落库再返回
+ │
+ ▼
+ Run 层(core/runs · core/chat/runtime)
+ 持久化 Run · 数据库租约 · 启动对账 · 事件流续播 · steer · 工具副作用回执
+ │
+ ▼
+ 智能体执行(core/agent_runtime · core/llm)
+ ReActAgent · 上下文按步装配与压缩 · 记忆 / 本体 / 进化集成
+ 外层编排(orchestration/):计划模式 · 自主循环 · 自动化
+ │
+ ┌────────────────┼──────────────────┐
+ ▼ ▼ ▼
+ MCP · 技能 · 插件 沙箱 · 后台作业 知识库 · 记忆
+ │ │ │
+ └────────────────┴──────────────────┘
+ ▼
+ PostgreSQL / SQLite · Redis · S3 / 本地存储 · Milvus · Neo4j
```
-请求主链路:浏览器 → frontend 容器内 nginx(`/api` 反代)→ FastAPI → `orchestration/workflow.py` 编排流式输出 → `core/llm/factory/build.py` 构建 ReActAgent → MCP 工具 / 沙箱 / 记忆 → SSE 事件(`text` / `tool_call` / `tool_result` / `meta` / `done`)回推前端。
+Personal Agent(`personal_agent/`)复用同一套 Run、租约与沙箱机制,增加身份、任务队列、
+调度、渠道、设备连接和工作区文件。更完整的请求生命周期见[架构总览](../architecture/overview.md)。
## 下一步
-- [10 分钟快速开始](quick-start.md)
-- [Docker Compose 完整部署](../deployment/docker-compose.md)
-- [环境变量参考](../deployment/environment-variables.md)
-- [架构总览](../architecture/overview.md)
+- [快速开始](quick-start.md)
+- [Personal Agent](../modules/personal-agent.md)
+- [对话与智能体编排](../modules/chat.md)
+- [自主循环](../modules/autonomous-loop.md)
+- [部署指南](../deployment/README.md)
## 相关源码
-| 功能 | 文件 |
+| 功能 | 路径 |
|---|---|
-| FastAPI 应用与路由注册 | `src/backend/api/app.py`、`src/backend/api/routes/v1/` |
-| 流式编排(SSE) | `src/backend/orchestration/workflow.py`、`orchestration/streaming.py` |
+| FastAPI 应用与路由 | `src/backend/api/app.py`、`src/backend/api/routes/v1/` |
+| Run 生命周期、租约与恢复 | `src/backend/core/runs/` |
+| 对话 worker、续播与转向 | `src/backend/core/chat/runtime/` |
+| 智能体执行入口 | `src/backend/core/agent_runtime/`(`runner.astream_chat_workflow`) |
| 智能体构建 | `src/backend/core/llm/factory/build.py` |
-| MCP server 与端口映射 | `src/backend/extensions/mcp_servers/`、`src/backend/extensions/mcp_servers/_ports.py`、`src/backend/core/config/mcp_config.py` |
-| 能力注册表 | `src/backend/core/config/catalog.json`、`core/config/catalog.py` |
-| 沙箱 provider | `src/backend/core/sandbox/`、`src/backend/core/config/settings.py::SandboxSettings` |
-| 记忆系统 | `src/backend/core/memory/`(service.py / pipeline.py)、`src/backend/orchestration/memory_integration.py` |
-| 自动化调度 | `src/backend/orchestration/schedulers/automation_scheduler.py` |
-| 前端入口(App / Admin / Config) | `src/frontend/src/main.tsx`、`App.tsx`、`AdminApp.tsx`、`ConfigApp.tsx` |
-| 版本与 License 设置 | `src/backend/core/config/settings.py::EditionSettings / LicenseSettings` |
+| Harness 契约与执行台账 | `src/backend/core/harness/` |
+| 工作流后台作业 | `src/backend/core/jobs/` |
+| 计划模式、自主循环、自动化 | `src/backend/orchestration/plans/`、`orchestration/loops/`、`orchestration/automations/` |
+| Personal Agent | `src/backend/personal_agent/` |
+| 记忆与进化 | `src/backend/core/memory/`、`src/backend/core/evolution/` |
+| 领域本体 | `src/backend/core/ontology/` |
+| 沙箱 | `src/backend/core/sandbox/` |
+| 内置 MCP、技能、插件与智能体 | `src/backend/extensions/` |
+| 前端入口 | `src/frontend/src/main.tsx`、`App.tsx` |
+| 桌面客户端 | `desktop/src-tauri/` |
diff --git a/document/zh-CN/getting-started/quick-start.md b/document/zh-CN/getting-started/quick-start.md
index 840f1976..e9127b62 100644
--- a/document/zh-CN/getting-started/quick-start.md
+++ b/document/zh-CN/getting-started/quick-start.md
@@ -1,21 +1,85 @@
# 快速开始
-> 最后更新:2026-07-21 |
+> 最后更新:2026-10-23 |
> [English](../../en/getting-started/quick-start.md)
-个人试用可以选择一键安装,长期服务或服务隔离场景可以选择 Docker Compose。
-两种方式都需要准备 OpenAI 兼容 API 或本地模型。
+HugAgentOS 有三种上手方式。需要智能体 7×24 小时在云端持续运行时选择 HugAgentOS Cloud;
+想在自己的电脑上离线使用时选择 HugAgentOS Local 桌面客户端;做二次开发或快速试用时选择
+一键安装。所有方式都需要准备 OpenAI 兼容 API 或本地模型。
## 选择部署方式
-两种部署方式使用不同的运行与持久化模型。
-
| 方式 | 适用场景 | 运行形态 |
|---|---|---|
-| 一键安装 | 个人试用与开发 | SQLite、进程内状态、本地子进程沙箱 |
-| Docker Compose | 长期运行服务器与服务隔离 | PostgreSQL、Redis、容器沙箱、持久化服务卷 |
+| HugAgentOS Cloud(Docker Compose) | 7×24 运行、Personal Agent、IM 渠道、多设备访问 | PostgreSQL、Redis、OpenSandbox、持久化卷 |
+| HugAgentOS Local(桌面客户端) | 离线使用、敏感数据、处理本地项目 | 自带私有运行时、本地 SQLite 与文件 |
+| 一键安装 | 试用与二次开发 | SQLite、进程内状态、本地子进程沙箱 |
+
+**初始账号与密码均为 `admin`,首次登录必须修改;社区版不提供自助注册。**
+
+## 方式一:HugAgentOS Cloud(Docker Compose)
+
+安装 Git、Docker Engine 或 Docker Desktop 和 Docker Compose v2,然后运行:
+
+```bash
+git clone https://github.com/ZJU-REAL/HugAgentOS.git
+cd HugAgentOS
+cp .env.example .env
+mkdir -p data/storage
+docker compose build backend
+docker compose up -d --build
+```
+
+打开 [http://localhost:3002](http://localhost:3002),使用 `admin` / `admin`
+登录并修改密码,然后进入「设置 → 系统管理 → 模型服务」接入模型。使用
+`docker compose ps` 检查服务,使用 `docker compose down` 停止服务且保留数据。
+
+Profiles、数据持久化、生产配置和重建流程见
+[Docker Compose 部署指南](../deployment/docker-compose.md)。
+
+### 启用 Personal Agent(可选)
+
+Personal Agent 默认关闭。它使用独立的 OpenSandbox 控制器为每个用户维护一台永久云电脑。
+
+1. 构建专用电脑镜像:
+
+ ```bash
+ docker build -f docker/personal-agent/Dockerfile.personal-agent -t hugagent-personal-agent:v5 .
+ ```
+
+2. 在 `.env` 中设置:
+
+ | 变量 | 值 |
+ |---|---|
+ | `PERSONAL_AGENT_ENABLED` | `true` |
+ | `HOST_STORAGE_PATH` | 宿主机上的绝对路径 |
+ | `PERSONAL_AGENT_OPENSANDBOX_API_KEY` | 至少 32 个字符的独立随机密钥,例如 `openssl rand -hex 32` 的输出 |
-## 方式一:一键安装
+3. 使用附加 Compose 文件启动:
+
+ ```bash
+ docker compose -f docker-compose.yml -f docker/personal-agent/compose.personal-agent.yml --profile personal-agent up -d
+ ```
+
+启动后,在「新建对话」下方点击「Personal Agent」即可使用。数据库迁移、镜像升级和渠道绑定见
+[Personal Agent](../modules/personal-agent.md)。密钥不要提交到 Git。
+
+## 方式二:HugAgentOS Local(桌面客户端)
+
+1. 从 [Releases](https://github.com/ZJU-REAL/HugAgentOS/releases) 下载对应平台
+ (Windows x86_64、macOS Apple Silicon / Intel、Linux x86_64)的桌面安装包。
+2. 首次启动时选择运行模式:
+ - **仅本机**:服务、数据和执行都在这台电脑上,可完全离线运行;
+ - **仅云端**:连接已部署的 HugAgentOS Cloud;
+ - **本机 + 云端**:使用云端账号,云端项目存云端,本地项目在本机执行;
+ Personal Agent 可以借此连接这台电脑。
+3. 选择本机模式时,客户端会校验并解压自带的运行时,通常数十秒内完成,不需要安装 Python、
+ Docker 或数据库。
+
+安装、升级回滚和数据目录见[桌面离线本机模式](../deployment/desktop-local-mode.md);
+绑定本地项目后的执行目录规则见[本机项目工作目录](../deployment/local-project-workspace.md)。
+
+## 方式三:一键安装
在 Linux、macOS 或通过 WSL2 使用 Windows 时选择此方式。开始前安装下列工具。
@@ -61,33 +125,17 @@ curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh
export PATH="$HOME/.hugagent/venv/bin:$PATH"
```
-## 方式二:Docker Compose
-
-需要持久化服务卷和容器隔离时选择此方式。安装 Git、Docker Engine 或
-Docker Desktop 和 Docker Compose v2,然后运行:
-
-```bash
-git clone https://github.com/ZJU-REAL/HugAgentOS.git
-cd HugAgentOS
-cp .env.example .env
-mkdir -p data/storage
-docker compose up -d --build
-```
-
-打开 [http://localhost:3002](http://localhost:3002),使用 `admin` / `admin`
-登录并修改密码,然后进入「设置 → 系统管理 → 模型服务」接入模型。使用
-`docker compose ps` 检查服务,使用 `docker compose down` 停止服务且保留数据。
-
-Profiles、数据持久化、生产配置和重建流程见
-[Docker Compose 部署指南](../deployment/docker-compose.md)。
+一键安装不提供跨进程的租约接管,不适合 7×24 运行。
## 下一步
首次登录成功后,可以继续阅读:
-- [完整无 Docker 安装指南](../deployment/quick-install.md):安装选项、能力边界和故障排查;
+- [产品简介](introduction.md):Durable Agent、运行形态与能力总览;
- [模型接入](../modules/model-providers.md);
-- [领域本体快速入门](domain-ontology-quickstart.md):用最小 Domain Pack 构建受治理工作流;
+- [Personal Agent](../modules/personal-agent.md)与[自主循环](../modules/autonomous-loop.md);
- [MCP 工具](../modules/mcp-tools.md)、
[Agent Skills](../modules/agent-skills.md)和
- [私有知识库](../modules/knowledge-base.md)。
+ [私有知识库](../modules/knowledge-base.md);
+- [领域本体快速入门](domain-ontology-quickstart.md):用最小 Domain Pack 构建受治理工作流;
+- [完整无 Docker 安装指南](../deployment/quick-install.md):安装选项、能力边界和故障排查。
diff --git a/document/zh-CN/modules/admin-console.md b/document/zh-CN/modules/admin-console.md
index 122ad6e4..a90ed117 100644
--- a/document/zh-CN/modules/admin-console.md
+++ b/document/zh-CN/modules/admin-console.md
@@ -53,11 +53,11 @@ HugAgentOS 提供**两个相互独立的管理入口**,分别面向内容运
`/v1/admin/skills`(`api/routes/v1/admin_skills.py`,`ADMIN_TOKEN`):技能全生命周期管理——CRUD、zip 上传、启停、排序、图标、依赖扫描与编辑、技能内单文件读写删、复刻内置技能、导入 / 导出。技能存于 `admin_skills` 表,技能体系详见 [Agent 技能](agent-skills.md)。
-`/v1/admin/skill-drafts`(`api/routes/v1/admin_skill_drafts.py`,`ADMIN_TOKEN`):自动蒸馏出的技能草稿审核——列表 / 计数 / 详情、先编辑后通过(approve)、驳回(reject)、删除,以及手动触发每日蒸馏扫描。
+`/v1/admin/skill-drafts`(`features/distillation/api/admin_skill_drafts.py`,`ADMIN_TOKEN`):自动蒸馏出的技能草稿审核——列表 / 计数 / 详情、先编辑后通过(approve)、驳回(reject)、删除,以及手动触发每日蒸馏扫描。
### 市场审核(admin_marketplace)(商业版 EE:content_admin)
-`/v1/admin/marketplace`(`api/routes/v1/admin_marketplace.py`,`ADMIN_TOKEN`):两件事——
+`/v1/admin/marketplace`(`features/marketplace/api/admin_marketplace.py`,`ADMIN_TOKEN`):两件事——
1. **安装市场技能为全局技能**:浏览市场列表(标注「是否已全局安装」)、安装后 owner 为空、全员可用,可继续在「技能管理」编辑。
2. **审核用户上架申请**:申请列表(按 status 过滤)、详情(含 SKILL.md 预览)、通过(上架,全员可装)、驳回 / 下架。
@@ -91,14 +91,14 @@ GitHub 绑定默认在此面板维护,也可用 `FEEDBACK_GITHUB_*` 环境变
### 知识库管理(admin_kb)(商业版 EE:content_admin)
-`/v1/admin/kb`(`api/routes/v1/admin_kb.py`,`ADMIN_TOKEN`):自建**公共知识库**管理(`KBSpace.visibility == "public"`,归属系统属主),能力镜像用户态 `kb.py`——库 CRUD、AI 生成简介、文档上传 / 列表 / 原文预览(Office 转 PDF)/ 删除 / 重新索引、分块预览与逐块编辑(内容 / 标签 / 问题)。公共库对全部用户在能力目录可见、可被检索。Dify 模式下仅只读展示 Dify 数据集,写操作返回 409。详见 [知识库](knowledge-base.md)。
+`/v1/admin/kb`(`edition_ee/routes/admin_kb.py`,`ADMIN_TOKEN`):自建**公共知识库**管理(`KBSpace.visibility == "public"`,归属系统属主),能力镜像用户态 `kb.py`——库 CRUD、AI 生成简介、文档上传 / 列表 / 原文预览(Office 转 PDF)/ 删除 / 重新索引、分块预览与逐块编辑(内容 / 标签 / 问题)。公共库对全部用户在能力目录可见、可被检索。Dify 模式下仅只读展示 Dify 数据集,写操作返回 409。详见 [知识库](knowledge-base.md)。
### 沙箱管理(admin_sandbox + config_security/sandbox)
两个互补面板:
-- `/v1/admin/sandbox`(`api/routes/v1/admin_sandbox.py`,`ADMIN_TOKEN`,**商业版 EE:content_admin**):聚合技能依赖清单预览、触发沙盒镜像重建 + 容器热切、重建历史与日志查询。
-- `/v1/config/security/sandbox/*`(`api/routes/v1/config_security.py`,`CONFIG_TOKEN`,**商业版 EE:system_config**):只读安全视图——沙盒概览、运行实例列表 / 详情、快照、重建历史、脱敏配置。Provider 不支持的能力(如 ScriptRunner 无法枚举实例)返回 `code=42210`,前端置灰。
+- `/v1/admin/sandbox`(`features/sandbox_ops/api/admin_sandbox.py`,`ADMIN_TOKEN`,**商业版 EE:content_admin**):聚合技能依赖清单预览、触发沙盒镜像重建 + 容器热切、重建历史与日志查询。
+- `/v1/config/security/sandbox/*`(`features/security_console/api/config_security.py`,`CONFIG_TOKEN`,**商业版 EE:system_config**):只读安全视图——沙盒概览、运行实例列表 / 详情、快照、重建历史、脱敏配置。Provider 不支持的能力(如 ScriptRunner 无法枚举实例)返回 `code=42210`,前端置灰。
详见 [沙箱](sandbox.md)。
@@ -166,13 +166,13 @@ GitHub 绑定默认在此面板维护,也可用 `FEEDBACK_GITHUB_*` 环境变
| 管理凭证依赖 | `src/backend/api/deps.py` |
| License 能力位 | `src/backend/edition_ee/licensing/features.py`、`src/backend/edition_ee/licensing/deps.py` |
| 技能 / 草稿 / 市场 | `src/backend/api/routes/v1/admin_skills.py`、`admin_skill_drafts.py`、`admin_marketplace.py` |
-| 问题反馈与 GitHub 发布 | `src/backend/edition_ee/routes/feedbacks.py`、`admin_feedbacks.py`、`services/feedback_service.py` |
+| 问题反馈与 GitHub 发布 | `src/backend/edition_ee/routes/feedbacks.py`、`admin_feedbacks.py`、`edition_ee/services/feedback_service.py` |
| 提示词 / MCP / 子智能体 | `src/backend/api/routes/v1/admin_prompts.py`、`admin_mcp_servers.py`、`admin_agents.py` |
-| 知识库 / 沙盒 | `src/backend/edition_ee/routes/admin_kb.py`、`src/backend/api/routes/v1/admin_sandbox.py` |
+| 知识库 / 沙盒 | `src/backend/edition_ee/routes/admin_kb.py`、`src/backend/features/sandbox_ops/api/admin_sandbox.py` |
| 计费 / 用量 / 日志 / 会话审查 | `src/backend/api/routes/v1/admin_billing.py`、`admin_usage_logs.py`、`admin_logs.py`、`admin_chat_history.py` |
| 内容管理 | `src/backend/api/routes/v1/content.py` |
| 用户 / 团队 / 邀请 / License(EE) | `src/backend/edition_ee/routes/config_users.py`、`config_teams.py`、`config_invites.py`、`config_license.py` |
-| 安全 | `src/backend/api/routes/v1/config_security.py` |
+| 安全 | `src/backend/features/security_console/api/config_security.py` |
| 服务配置 | `src/backend/api/routes/v1/service_configs.py` |
延伸阅读:[认证与权限](auth.md) · [提示词系统](prompts.md) · [版本与授权](../editions/overview.md)
diff --git a/document/zh-CN/modules/agent-skills.md b/document/zh-CN/modules/agent-skills.md
index a4ba477a..c3d857dd 100644
--- a/document/zh-CN/modules/agent-skills.md
+++ b/document/zh-CN/modules/agent-skills.md
@@ -146,7 +146,7 @@
## 技能市场(Skill Marketplace)
-技能市场是"预置 + 社区"双来源的可安装技能库,核心服务在 `core/services/marketplace_service.py`。
+技能市场是"预置 + 社区"双来源的可安装技能库,核心服务在 `features/marketplace/`。
### 浏览与安装
@@ -197,7 +197,7 @@
GET /v1/admin/visibility/principals ← 用户/团队/角色简表(选择器数据源)
```
-存储:`marketplace_listing_states.visibility`(缺行 = public)+ `marketplace_visibility_grants` 白名单表;用户侧解析收口在 `core/auth/marketplace_visibility.py`(角色含个人直配与经团队获得的部门默认角色)。
+存储:`marketplace_listing_states.visibility`(缺行 = public)+ `marketplace_visibility_grants` 白名单表;用户侧解析收口在 `edition_ee/auth/marketplace_visibility.py`(角色含个人直配与经团队获得的部门默认角色)。
## 管理员技能管理
@@ -223,7 +223,7 @@
approve → 转正为 AdminSkill;reject / delete;可手动触发 trigger-daily-scan
```
-阈值、关键词、预算等全部可由 `DISTILL_*` 环境变量覆盖(`core/config/distillation.py`)。蒸馏管线(`skill_distiller.py`、调度器)属**商业版 EE**,社区版派生树剔除;技能草稿审核台随之仅在商业版可见。
+阈值、关键词、预算等全部可由 `DISTILL_*` 环境变量覆盖(`features/distillation/config.py`)。蒸馏管线(`skill_distiller.py`、调度器)属**商业版 EE**,社区版派生树剔除;技能草稿审核台随之仅在商业版可见。
## 用户自助技能(能力中心)
@@ -274,13 +274,13 @@
| `src/backend/core/llm/tools/skill_tool.py` | 受限 view_text_file + {baseDir} 替换 + Runtime Hint |
| `src/backend/extensions/skills/default/` | 12 个内置技能(含 10 个 OfficeCLI 系列) |
| `src/backend/extensions/skills/marketplace/` | 45 个可安装市场技能包 |
-| `src/backend/core/services/marketplace_service.py` | 市场列表/安装/凭据注入/上架审核 |
-| `src/backend/api/routes/v1/marketplace.py` | 用户侧市场 API(浏览/安装/提交/撤回) |
-| `src/backend/api/routes/v1/admin_marketplace.py` | 管理员市场 API(全局安装/审核上架) |
+| `src/backend/features/marketplace/` | 市场列表/安装/凭据注入/上架审核 |
+| `src/backend/features/marketplace/api/marketplace.py` | 用户侧市场 API(浏览/安装/提交/撤回) |
+| `src/backend/features/marketplace/api/admin_marketplace.py` | 管理员市场 API(全局安装/审核上架) |
| `src/backend/api/routes/v1/admin_skills.py` | 管理员技能 CRUD / zip / 依赖管理 |
-| `src/backend/api/routes/v1/admin_skill_drafts.py` | 蒸馏草稿审核(EE) |
-| `src/backend/core/llm/skill_distiller.py` | 技能蒸馏 LLM 管线(EE) |
-| `src/backend/orchestration/schedulers/distillation_cron_scheduler.py` | 蒸馏每日调度(EE) |
+| `src/backend/features/distillation/api/admin_skill_drafts.py` | 蒸馏草稿审核(EE) |
+| `src/backend/features/distillation/` | 技能蒸馏 LLM 管线(EE) |
+| `src/backend/orchestration/workers/distillation_cron_scheduler.py` | 蒸馏每日调度(EE) |
| `src/backend/api/routes/v1/me_capabilities.py` | 用户自助技能/私有 MCP API |
相关文档:[沙箱执行系统](sandbox.md) · [MCP 工具系统](mcp-tools.md) · [能力目录](catalog.md) · [管理台](admin-console.md) · [版本与许可](../editions/overview.md)
diff --git a/document/zh-CN/modules/application-hosting.md b/document/zh-CN/modules/application-hosting.md
index 20a707ee..a3891425 100644
--- a/document/zh-CN/modules/application-hosting.md
+++ b/document/zh-CN/modules/application-hosting.md
@@ -6,11 +6,11 @@
配置 `APPLICATION_DATABASE_URL` 指向独立数据库。不能指向平台 `DATABASE_URL` 的同一数据库。云部署未配置时,管理界面提示未启用;本地模式可使用 SQLite,但生产使用 PostgreSQL。首次使用先执行 `python -m core.services.application_hosting_setup` 初始化注册表。
-可选 Compose 覆盖文件 `docker-compose.application-hosting.yml` 包含独立 PostgreSQL、持久卷和一次性初始化任务。分别提供不同的 `APPLICATION_DB_PASSWORD`(初始化)与 `APPLICATION_OWNER_DB_PASSWORD`(运行账号),使用 URL 安全的密码或正确编码连接串;初始化任务使用与后端相同的 Dockerfile 构建目标,并只读挂载当前后端源码。部署时后端也必须使用本次源码构建的镜像,并按仓库部署流程重建前端,使 nginx 的 `/applications-mcp/` 转发配置生效。
+可选 Compose 覆盖文件 `docker/application-hosting/compose.application-hosting.yml` 包含独立 PostgreSQL、持久卷和一次性初始化任务。分别提供不同的 `APPLICATION_DB_PASSWORD`(初始化)与 `APPLICATION_OWNER_DB_PASSWORD`(运行账号),使用 URL 安全的密码或正确编码连接串;初始化任务使用与后端相同的 Dockerfile 构建目标,并只读挂载当前后端源码。部署时后端也必须使用本次源码构建的镜像,并按仓库部署流程重建前端,使 nginx 的 `/applications-mcp/` 转发配置生效。
```bash
-docker compose -f docker-compose.yml -f docker-compose.application-hosting.yml config --quiet
-docker compose -f docker-compose.yml -f docker-compose.application-hosting.yml up -d
+docker compose -f docker-compose.yml -f docker/application-hosting/compose.application-hosting.yml config --quiet
+docker compose -f docker-compose.yml -f docker/application-hosting/compose.application-hosting.yml up -d
```
实际启动、重建、迁移和部署仍遵守项目授权流程。存量迁移使用独立应用数据库。本地验证不代表远程环境已经升级。端到端验证使用独立数据库及平台的本地 mock 认证;该认证模式仅供本地测试。
@@ -47,27 +47,7 @@ PostgreSQL 每个应用使用独立 schema 与受限 NOLOGIN 角色;查询和
已实现结构化 SQL 表、约束与索引、原子批量写入、幂等重试、版本更新、受限查询、应用隔离、匿名表单和声明式 MCP 托管。请求有行大小、批量大小、分页和记录配额限制。CSV 流式导出全表。
-未实现任意用户代码 MCP 容器、OAuth 授权服务器、外键关系设计器、全量备份恢复控制台和完整用量计费。当前固定 Bearer 方案不可宣称已完成 OAuth。真实聊天端到端驱动位于 `src/backend/tests/application_hosting_e2e.py`,独立测试环境由同目录的 `application_hosting_e2e_setup.py` 准备。通过生产聊天 API 验证模型、工具、沙箱与发布,验收结果以实际运行记录为准。
-
-## 本地端到端复现
-
-使用已配置真实模型、产业来源和 OpenSandbox 的本地 Docker 环境;先构建当前前端。以下驱动使用生产 API 与真实工具,不替换文件工具或发布接口。测试服务仅绑定回环端口,使用独立 platform_e2e_verified / applications_e2e_verified 数据库和独立存储目录。
-
-```bash
-npm --prefix src/frontend run build
-PYTHONPATH=src/backend .venv/bin/python src/backend/tests/application_hosting_e2e_setup.py
-PYTHONPATH=src/backend .venv/bin/python src/backend/tests/application_hosting_e2e.py
-```
-
-在 `http://127.0.0.1:18533/site/e2e-form-1007/` 用测试信息提交一次,邮箱使用 `browser-e2e@example.test`。再运行:
-
-```bash
-PYTHONPATH=src/backend .venv/bin/python src/backend/tests/application_hosting_lifecycle_e2e.py
-```
-
-生命周期验证会重启上述独立测试服务,并轮换、回滚、撤销和重新发布测试 MCP 凭据。凭据只保存在 `/tmp/application-hosting-full-e2e-credentials.json`(0600),不放入网页或验收报告。已有批次不自动清库;完整聊天重跑应使用干净的独立数据库批次。该流程不升级主环境,也不执行远程部署。
-
-重跑完整聊天时,先停止并移除 `hugagent-application-e2e-backend` 和 `hugagent-application-e2e-mcp` 这两个测试容器,再以 `APPLICATION_E2E_BATCH=run2` 执行 setup。旧批次数据库保留,驱动仍使用相同的回环访问地址。不要删除主环境容器或数据库。
+未实现任意用户代码 MCP 容器、OAuth 授权服务器、外键关系设计器、全量备份恢复控制台和完整用量计费。当前固定 Bearer 方案不可宣称已完成 OAuth。
## 桌面端
@@ -128,9 +108,9 @@ project_synced=false;保留新草稿并报告同步失败,核对线上版本
管理表单和回滚发布遇到尚未发布的项目草稿时返回冲突,不覆盖草稿。项目发布会等待文件工具登记,发布后刷新挂载文件的新版本;只有这些同步步骤完成才报告 project_synced=true。
-## 外部检验修复与升级
+## 部署要求与数据安全
-应用数据仍为可选模块。云端部署必须配置不同的 APPLICATION_DB_PASSWORD 与 APPLICATION_OWNER_DB_PASSWORD,并同时使用 docker-compose.yml 与 docker-compose.application-hosting.yml。首次部署及已有卷升级都执行 application-database-init;该步骤创建恢复历史表,把专用库对象交给 application_owner,并为已有应用角色补齐授权。后端仅使用 NOSUPERUSER、NOCREATEDB、CREATEROLE 的 application_owner;application_admin 只用于专用数据库初始化。CREATEROLE 是创建和回收应用隔离角色所需权限,因此必须使用独立 PostgreSQL 实例,不能复用业务平台数据库。初始化失败时不启动后端,不手工跳过依赖检查。
+应用数据仍为可选模块。云端部署必须配置不同的 APPLICATION_DB_PASSWORD 与 APPLICATION_OWNER_DB_PASSWORD,并同时使用 docker-compose.yml 与 docker/application-hosting/compose.application-hosting.yml。首次部署及已有卷升级都执行 application-database-init;该步骤创建恢复历史表,把专用库对象交给 application_owner,并为已有应用角色补齐授权。后端仅使用 NOSUPERUSER、NOCREATEDB、CREATEROLE 的 application_owner;application_admin 只用于专用数据库初始化。CREATEROLE 是创建和回收应用隔离角色所需权限,因此必须使用独立 PostgreSQL 实例,不能复用业务平台数据库。初始化失败时不启动后端,不手工跳过依赖检查。
访客 API 的成功及错误响应均带 CORS。云端访客写入与密码尝试采用 Redis 原子共享计数;所有后端进程必须使用同一 Redis。Redis 未配置或不可用时返回 503。仅未配置 Redis 的本机单进程模式使用内存计数。
diff --git a/document/zh-CN/modules/auth.md b/document/zh-CN/modules/auth.md
index 2145ace1..0f0c65df 100644
--- a/document/zh-CN/modules/auth.md
+++ b/document/zh-CN/modules/auth.md
@@ -111,7 +111,7 @@ SSO 提供方响应必须是 JSON 对象,且成功码必须为有限数值 0
### 接口层(CE/EE 拆分接缝)
-`core/auth/permissions_iface.py` 是权限符号的**唯一导入入口**:主干代码(deps / files / chats / projects / kb 等)一律从这里导入,不直接引用具体实现。EE 主仓中它纯转发三个真实现;CE 派生树用单租户 stub 整体替换本文件,三个真实现物理不存在:
+`core/auth/personal_permissions_iface.py` 是权限符号的**唯一导入入口**:主干代码(deps / files / chats / projects / kb 等)一律从这里导入,不直接引用具体实现。EE 主仓中它纯转发三个真实现;CE 派生树用单租户 stub 整体替换本文件,三个真实现物理不存在:
| 实现文件 | 职责 |
|---|---|
@@ -201,7 +201,7 @@ Key 形如 `sk-jx-...`,在**所有 AUTH_MODE 下**都可作为 Bearer 调用
| SSO 票据交换 | `src/backend/edition_ee/auth/sso.py`、`src/backend/edition_ee/routes/auth.py` |
| Mock SSO / 本地登录注册页 | `src/backend/api/routes/v1/mock_sso.py`、`src/backend/core/auth/mock_ticket_store.py` |
| 密码哈希 | `src/backend/core/auth/password.py` |
-| 权限接口层(CE/EE 接缝) | `src/backend/core/auth/permissions_iface.py` |
+| 权限接口层(CE/EE 接缝) | `src/backend/core/auth/personal_permissions_iface.py` |
| 团队角色 / 文件权限(EE) | `src/backend/edition_ee/auth/roles.py`、`src/backend/edition_ee/auth/team_permissions.py` |
| 项目 / 会话分享权限(EE) | `src/backend/edition_ee/auth/project_permissions.py`、`src/backend/edition_ee/auth/chat_share_permissions.py` |
| 管理凭证依赖 | `src/backend/api/deps.py` |
diff --git a/document/zh-CN/modules/automation.md b/document/zh-CN/modules/automation.md
index 1a377846..2c53f631 100644
--- a/document/zh-CN/modules/automation.md
+++ b/document/zh-CN/modules/automation.md
@@ -6,8 +6,8 @@ HugAgentOS 内置两种「把一句话变成可重复的生产力」机制,两
| 能力 | 一句话定位 | 后端入口 |
|---|---|---|
-| 定时自动化 | 按 cron 周期(或一次性 / 手动)自动执行一段 Prompt 或一个计划 | `api/routes/v1/automations.py` + `orchestration/schedulers/automation_scheduler.py` |
-| 计划模式 | AI 先把复杂任务拆成结构化步骤,确认后逐步执行 | `api/routes/v1/plans.py` + `orchestration/subagents/plan_mode.py` |
+| 定时自动化 | 按 cron 周期(或一次性 / 手动)自动执行一段 Prompt 或一个计划 | `features/automation/api/automations.py` + `orchestration/automations/automation_scheduler.py` |
+| 计划模式 | AI 先把复杂任务拆成结构化步骤,确认后逐步执行 | `api/routes/v1/plans.py` + `orchestration/plans/` |
## 定时自动化任务
@@ -17,7 +17,7 @@ HugAgentOS 内置两种「把一句话变成可重复的生产力」机制,两
### 任务模型
-任务存于 `scheduled_tasks` 表(`core/db/models/automation.py` 的 `ScheduledTask`),核心字段:
+任务存于 `scheduled_tasks` 表(`edition_ee/db/models/automation.py` 的 `ScheduledTask`),核心字段:
| 字段 | 说明 |
|---|---|
@@ -40,7 +40,7 @@ HugAgentOS 内置两种「把一句话变成可重复的生产力」机制,两
### 调度机制(automation_scheduler.py)
-`orchestration/schedulers/automation_scheduler.py` 是一个随后端启动的 asyncio 轮询调度器:
+`orchestration/automations/automation_scheduler.py` 是一个随后端启动的 asyncio 轮询调度器:
- **轮询**:每 15 秒(外加 0–5 秒随机抖动)查询 DB 中 `next_run_at` 到期的任务。
- **分布式锁**:触发前先抢 Redis 锁 `jx:auto:lock:{task_id}`,执行期间持续续期,完成后按持有者令牌释放,防止多实例重复触发。
@@ -64,7 +64,7 @@ HugAgentOS 内置两种「把一句话变成可重复的生产力」机制,两
## 计划模式
-计划模式把复杂任务拆成结构化步骤再执行,分两阶段(`orchestration/subagents/plan_mode.py`):
+计划模式把复杂任务拆成结构化步骤再执行,分两阶段(`orchestration/plans/`):
1. **生成(Phase 1)**:`POST /v1/plans/generate`(SSE 流式)——AI 分析任务描述,产出含标题、描述、步骤列表的计划草稿;每步可声明 `expected_tools` / `expected_skills` / `expected_agents`(期望用到的工具 / 技能 / 子智能体)。
2. **执行(Phase 2)**:`POST /v1/plans/{plan_id}/execute`(SSE 流式)——逐步顺序执行,每步用独立的 agent;步骤状态、工具调用日志、AI 输出逐步落库。
@@ -99,14 +99,37 @@ POST /v1/automations
| 主题 | 路径 |
|---|---|
-| 自动化 REST API | `src/backend/api/routes/v1/automations.py` |
-| 调度器(轮询 / 锁 / 恢复) | `src/backend/orchestration/schedulers/automation_scheduler.py` |
-| 自动化服务(cron 计算 / 状态机) | `src/backend/core/services/automation_service.py` |
-| 任务 / 运行记录模型 | `src/backend/core/db/models/automation.py` |
+| 自动化 REST API | `src/backend/features/automation/api/automations.py` |
+| 调度器(轮询 / 锁 / 恢复) | `src/backend/orchestration/automations/automation_scheduler.py` |
+| 自动化服务(cron 计算 / 状态机) | `src/backend/features/automation/service.py` |
+| 任务 / 运行记录模型 | `src/backend/features/automation/models.py` |
| 计划 REST API | `src/backend/api/routes/v1/plans.py` |
-| 计划生成 / 执行编排 | `src/backend/orchestration/subagents/plan_mode.py`、`src/backend/core/chat/plans.py` |
+| 计划生成 / 执行编排 | `src/backend/orchestration/plans/`、`src/backend/core/chat/plans.py` |
| 计划 / 步骤模型 | `src/backend/core/db/models/agent.py` |
-| 前端自动化 UI | `src/frontend/src/components/lab/AutomationPanel.tsx` 等、`src/frontend/src/components/automation/RunTimelinePanel.tsx` |
+| 前端自动化 UI | `src/frontend/src/components/automation/AutomationPanel.tsx` 等、`src/frontend/src/components/automation/RunTimelinePanel.tsx` |
| 前端状态 | `src/frontend/src/stores/automationStore.ts`、`automationChatStore.ts` |
延伸阅读:[对话系统](chat.md) · [MCP 工具](mcp-tools.md) · [数据画布与产物](canvas-artifacts.md)
+
+## 多进程运行与恢复
+
+定时和手动触发共用一个调度循环,每 3 秒扫描一次,最多同时观察 8 个执行。
+消费触发信号、推进排期和插入 pending 执行记录在一个数据库事务内完成;
+数据库条件更新保护并发接纳。忙碌任务的新手动触发保留至当前执行结束。
+
+pending 表示已接纳、等待执行。调度器停止或租约失效只转交观察权,
+不会取消已接纳的会话;后续实例继续观察同一个持久执行及其后继会话。
+用户在会话中主动停止仍会停止实际执行。子执行关联和恢复参数与接纳一起提交,
+不通过重跑已启动命令恢复结果。运行中的通知投递仍沿用原有投递语义,
+此变更不承诺外部渠道的恰好一次投递。
+
+升级数据库须应用 FULL 的 automationqueue01 或 CE 的 ce_0032;
+本机 SQLite 使用已安装 automation 模块的 schema 初始化完成约束升级。
+存在 pending 记录时拒绝降级,以免丢弃已接纳工作。先停止旧版本调度器、
+迁移后启动新版本,不混用两套调度协议。
+
+Plan/Loop 将完整启动参数与子任务接纳一同持久化。接纳后、启动前崩溃时,
+后继调度器只领取 pending 子任务;已开始的 running 子任务不会从头重放。
+升级前应排空旧版在途任务,旧版未保存的参数不会由兼容路径猜测补齐。
+
+任务投递统一存储为 delivery_targets。升级迁移将旧 channel_id/conversation_id 转换为目标列表并删除旧字段,保留已明确配置的目标和其他元数据;请求处理不再读取旧格式。
diff --git a/document/zh-CN/modules/autonomous-loop.md b/document/zh-CN/modules/autonomous-loop.md
index d1c5d60a..5ba8e397 100644
--- a/document/zh-CN/modules/autonomous-loop.md
+++ b/document/zh-CN/modules/autonomous-loop.md
@@ -71,7 +71,7 @@
## 相关代码
-- 驱动器 `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`
+- 驱动器 `orchestration/loops/driver.py`、规划器 `orchestration/loops/planner.py`、评审员 `orchestration/loops/reviewer.py`
+- ChatRun 接入 `core/chat/runtime/`(`start_autonomous_loop_run` / 启动对账 `resume_running_loops`)
+- 服务 `orchestration/loops/run.py`(账本镜像 / 启动参数 / steering 队列)、API `api/routes/v1/loops.py`
- 数据表 `agent_loops` / `loop_iterations`
diff --git a/document/zh-CN/modules/browser-automation.md b/document/zh-CN/modules/browser-automation.md
index 9e674501..b0856011 100644
--- a/document/zh-CN/modules/browser-automation.md
+++ b/document/zh-CN/modules/browser-automation.md
@@ -105,7 +105,7 @@ Docker 使用沙箱镜像内的 Python Playwright 与 Chromium;桌面完整包
启动时实际尝试 Chromium 沙箱。仅经过宿主验证的外层隔离容器在出现明确的内部沙箱错误时允许关闭 Chromium 内层沙箱后重试;本机环境不采用此降级。启动失败显示 chromium_sandbox_unavailable、chromium_runtime_missing 或 chromium_launch_failed,而不是仅显示异常类型。
-更新 OpenSandbox 时需显式从仓库根目录构建运行镜像:docker build -f docker/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .。普通 docker compose up --build 不会重建这一按需启动的镜像。核对 OPENSANDBOX_IMAGE 实际标签,并在更新后用 browser_open 创建新会话;只有 Chromium 初始化及真实浏览器验证通过后才报告资源就绪。其他运行提供方同样需重建各自运行镜像。
+更新 OpenSandbox 时需显式从仓库根目录构建运行镜像:docker build -f docker/sandbox/Dockerfile.opensandbox -t hugagent-opensandbox-custom:latest .。普通 docker compose up --build 不会重建这一按需启动的镜像。核对 OPENSANDBOX_IMAGE 实际标签,并在更新后用 browser_open 创建新会话;只有 Chromium 初始化及真实浏览器验证通过后才报告资源就绪。其他运行提供方同样需重建各自运行镜像。
Fake-IP DNS 返回198.18.0.0/15时仍禁止连接,错误提示 fake_ip_dns,并建议配置 BROWSER_DNS_RESOLVER_URL 为受信任的 HTTPS JSON DNS 服务。解析失败返回 dns_failed;不得为解决公网 DNS 问题打开私网访问。缺失元素和重复定位器分别返回 element_not_found、ambiguous_locator,未执行动作不再标为结果未知。
diff --git a/document/zh-CN/modules/canvas-artifacts.md b/document/zh-CN/modules/canvas-artifacts.md
index 0354ebb5..6a92fafd 100644
--- a/document/zh-CN/modules/canvas-artifacts.md
+++ b/document/zh-CN/modules/canvas-artifacts.md
@@ -8,7 +8,7 @@
## 数据画布(Canvas)
-数据画布是聊天界面右侧的滑出面板(`src/frontend/src/components/canvas/CanvasPanel.tsx`),点击聊天附件卡片(`components/chat/ArtifactCardList.tsx`)或「我的空间」中的文件(`components/myspace/MySpacePanel.tsx`)即可打开。面板按文件类型选择渲染器:
+数据画布是聊天界面右侧的滑出面板(`src/frontend/src/components/canvas/CanvasPanel.tsx`),点击聊天附件卡片(`components/chat/MessageArtifacts.tsx`)或「我的空间」中的文件(`components/myspace/MySpacePanel.tsx`)即可打开。面板按文件类型选择渲染器:
| 文件类型 | 渲染方式 | 可编辑 |
|---|---|---|
@@ -78,14 +78,14 @@ Web Worker 中执行,并按真实存在的单元格稀疏转换,不遍历工
### 产物的产生与落库
-对话流式过程中,工具产出的文件先进入会话**工作区**(`core/llm/workspace`),只有被「钉住」(pinned)的文件才会在回合结束时由 `core/files/artifacts/service.py::persist_artifacts` 写入 `artifacts` 表——这道**严格工作区闸门**保证临时中间文件不会污染我的空间。
+对话流式过程中,工具产出的文件先进入会话**工作区**(`core/sandbox/workspace`),只有被「钉住」(pinned)的文件才会在回合结束时由 `core/files/artifacts/service.py::persist_artifacts` 写入 `artifacts` 表——这道**严格工作区闸门**保证临时中间文件不会污染我的空间。
物理存储由 `core/files/artifacts/store.py` 管理,按 `STORAGE_TYPE` 双模式:
- `local`(默认,社区版):文件写在 `${STORAGE_PATH:-result}/artifacts/`,维护本地 JSON 索引(`index.json`),FileResponse 直出。
- `oss`(商业版 EE,`cloud_storage`):上传到阿里云 OSS,本地索引同时备份到 OSS,容器重启不丢。S3 后端见 `core/files/storage/`。
-下载统一走 `GET /files/{file_id}`(`api/routes/files.py`),按 `core/auth/permissions_iface.py::resolve_artifact_access` 做 owner ∪ team 权限判定,Office 文件支持转 PDF 在线预览。
+下载统一走 `GET /files/{file_id}`(`api/routes/files.py`),按 `core/auth/personal_permissions_iface.py::resolve_artifact_access` 做 owner ∪ team 权限判定,Office 文件支持转 PDF 在线预览。
### REST 接口(`/v1/artifacts`,`api/routes/v1/artifacts.py`)
diff --git a/document/zh-CN/modules/catalog.md b/document/zh-CN/modules/catalog.md
index a201d618..7e0529be 100644
--- a/document/zh-CN/modules/catalog.md
+++ b/document/zh-CN/modules/catalog.md
@@ -121,14 +121,14 @@ get_enabled_ids("mcp") # 某类全部启用 id
| 主题 | 路径 |
|---|---|
-| 公共 API(is_enabled 等) | `src/backend/core/config/catalog.py` |
+| 公共 API(is_enabled 等) | `src/backend/core/capabilities/catalog/` |
| 单一真源文件 | `src/backend/core/config/catalog.json`(`CATALOG_PATH` 可覆盖) |
-| 加载 / 动态同步 / 缓存 | `src/backend/core/config/catalog_loader.py`,`catalog_common.py`,`catalog_migration.py` |
+| 加载 / 动态同步 / 缓存 | `src/backend/core/capabilities/catalog/loader.py`,`catalog_common.py`,`catalog_migration.py` |
| 用户覆盖合并 | `src/backend/core/capabilities/catalog/resolver.py`,`core/capabilities/catalog/overrides.py` |
| 目录路由 | `src/backend/api/routes/v1/catalog.py` |
| 用户自助能力 | `src/backend/api/routes/v1/me_capabilities.py` |
| MCP 服务配置(DB) | `src/backend/core/capabilities/mcp/service.py`,`api/routes/v1/admin_mcp_servers.py` |
| 技能管理 | `src/backend/api/routes/v1/admin_skills.py`,`core/capabilities/skills/` |
-| Dify KB 注入(EE) | `src/backend/edition_ee/kb/dify.py`,经共享接缝 `core/kb/external_provider.py` 调用 |
+| Dify KB 注入(EE) | `src/backend/edition_ee/kb/dify.py`,经共享接缝 `features/knowledge/extension.py` 调用 |
| 前端能力中心 | `src/frontend/src/components/catalog/`,`stores/catalogStore.ts` |
| 工厂消费侧 | `src/backend/core/llm/factory/build.py::_effective_mcp_server_keys` |
diff --git a/document/zh-CN/modules/chat.md b/document/zh-CN/modules/chat.md
index 92b78771..e6540f58 100644
--- a/document/zh-CN/modules/chat.md
+++ b/document/zh-CN/modules/chat.md
@@ -85,7 +85,7 @@ SSE follower:chat_run_executor.follow_run_as_sse()
### Run 解耦与断线续播
-每次发送消息会创建一条 `ChatRun` 并启动后台任务(`orchestration/chat_run_executor.py`),事件写入 Redis Stream(`maxlen=5000`;默认 TTL 为最长人机等待 7200 秒加 1800 秒恢复余量)。HTTP 连接只是"跟随者",因此:
+每次发送消息会创建一条 `ChatRun` 并启动后台任务(`core/chat/runtime/`),事件写入 Redis Stream(`maxlen=5000`;默认 TTL 为最长人机等待 7200 秒加 1800 秒恢复余量)。HTTP 连接只是"跟随者",因此:
| 能力 | 端点 |
|---|---|
@@ -133,7 +133,7 @@ SSE follower:chat_run_executor.follow_run_as_sse()
| 替我批准 | `auto` | 普通写入 / 编辑直接放行;**删除类操作**(`DESTRUCTIVE_OPS`)与本地策略标了 `danger:<类别>` 的命令仍然停下来问 |
| 完全放开 | `full` | 所有工具确认一律不再询问 |
-档位在 `core/llm/tool_permissions.py` 里统一声明(`APPROVAL_ASK` / `APPROVAL_AUTO` / `APPROVAL_FULL`),随对话上下文传到 `PermissionRuntime`,是所有工具确认闸的唯一判据;任何一档都只跳过"问"这一步,本地安全策略判定的硬拒绝仍然拒绝。"这次危不危险"由 `core/sandbox/permissions/models.py::danger_categories()` 从裁决理由里的 `danger:` 前缀还原,不靠匹配提示文案。读到早期版本存下的 `standard` / `readonly` 时按最保守的 `ask` 兜底。
+档位在 `core/llm/tool_permissions/` 里统一声明(`APPROVAL_ASK` / `APPROVAL_AUTO` / `APPROVAL_FULL`),随对话上下文传到 `PermissionRuntime`,是所有工具确认闸的唯一判据;任何一档都只跳过"问"这一步,本地安全策略判定的硬拒绝仍然拒绝。"这次危不危险"由 `core/sandbox/permissions/models.py::danger_categories()` 从裁决理由里的 `danger:` 前缀还原,不靠匹配提示文案。读到早期版本存下的 `standard` / `readonly` 时按最保守的 `ask` 兜底。
渠道机器人、定时任务这类无人值守入口走 `default_allow` 分支,不受该档位影响。
@@ -143,17 +143,17 @@ SSE follower:chat_run_executor.follow_run_as_sse()
`create_agent_executor()` 是所有模式(主对话、计划、批量、子智能体、自动化)共用的工厂:
-- **MCP 工具**:经 catalog + 用户覆盖 + 请求上下文三层过滤后(见 [能力目录](catalog.md)),stable 服务复用进程级连接池(`core/llm/mcp/pool.py`),per-request 服务(如 `retrieve_dataset_content` 需带每请求 HTTP header)每次新建;用户自助添加的私有 MCP 按 owner 现查合入。
+- **MCP 工具**:经 catalog + 用户覆盖 + 请求上下文三层过滤后(见 [能力目录](catalog.md)),stable 服务复用进程级连接池(`core/llm/mcp/`),per-request 服务(如 `retrieve_dataset_content` 需带每请求 HTTP header)每次新建;用户自助添加的私有 MCP 按 owner 现查合入。
- **技能**:经 `core/capabilities/skills/loader.py` 注册为 AgentScope Agent Skills,并放行 `view_text_file` 读取 SKILL.md(详见 [技能系统](agent-skills.md))。
- **文件/沙箱工具**:`bash`、`sandbox_put_artifact`、`sandbox_get_artifact` 无条件注册;Read/Edit/Write/Glob/Grep/Delete/Move/mkdir + MySpace 工具受 `CODE_CAPABILITY_ENABLED` 门控,共享同一个 `ReadStateTracker` 维持「先 Read 才能 Edit」不变量。
-- **中间件**(洋葱模型,`core/llm/middlewares.py`):`DynamicModelMiddleware`(按 chat_mode 切模型,见 [模型接入](model-providers.md))、`FileContextMiddleware`(注入上传/历史文件上下文)、`SteerMiddleware`(工具结果之后、下一轮推理之前注入追加指令)、`WorkspacePinHintMiddleware`、`FinishPinGuardMiddleware`。
+- **中间件**(洋葱模型,`core/llm/runtime/`):`DynamicModelMiddleware`(按 chat_mode 切模型,见 [模型接入](model-providers.md))、`FileContextMiddleware`(注入上传/历史文件上下文)、`SteerMiddleware`(工具结果之后、下一轮推理之前注入追加指令)、`WorkspacePinHintMiddleware`、`FinishPinGuardMiddleware`。
- **上下文压缩**:`CompactingAgent` 把轮前、轮内和轮末三个触发时机接入同一个持久化 checkpoint 引擎,并共用 Codex 风格交接提示词;`ContextConfig` 只保留工具结果限长。压缩只使用统一协调器,不再运行框架的第二条摘要回退。压缩失败时保留原始上下文;最终请求仍超预算时保存断点并暂停,不继续调用回复模型。
- **权限**:所有已注册工具 seed 原生 `PermissionRule(ALLOW)`,保留 AgentScope 内置工具的危险操作检查(不使用一刀切 BYPASS)。
- **迭代上限**:主智能体默认 `max_iters=50`,隔离子智能体默认 10。
## SSE 事件类型与负载
-`orchestration/streaming.py::StreamingAgent` 把 AgentScope 2.0 `reply_stream` 的细粒度事件归并为内部事件;工具参数小分片按 256 字符或 50ms 合批,兼顾实时可见与 SSE 事件量。`workflow.py` 与 `chats.py::_stream_sse_response` 再补充会话级字段后落到 wire 上。前端实际收到的事件:
+`core/llm/runtime/streaming.py::StreamingAgent` 把 AgentScope 2.0 `reply_stream` 的细粒度事件归并为内部事件;工具参数小分片按 256 字符或 50ms 合批,兼顾实时可见与 SSE 事件量。`workflow.py` 与 `chats.py::_stream_sse_response` 再补充会话级字段后落到 wire 上。前端实际收到的事件:
| `type` | 含义 | 关键字段 |
|---|---|---|
@@ -200,7 +200,7 @@ data: {"type":"meta","route":"main","citations":[...],"usage":{"prompt_tokens":3
data: [DONE]
```
-`meta` 之后,`chat_run_executor.py` 持久化助手消息、回填 artifact,并起后台任务生成追问问题(`orchestration/followups.py`,结果写进消息 `extra_data.follow_up_questions`,前端经 `GET /v1/chats/{chat_id}/messages/{message_id}/followups` 拉取)。本体事件在前端汇总为独立的“领域本体治理”模块,不再写入或显示在“思考过程”中。模型草稿保持逐 token 流式展示;委员会仅在实际修订答案时发送一次 `content_replace`,前端原位替换正文,数据库只保存评审后的最终答案。`ontology_governance` 随助手消息持久化,刷新历史会话后仍可回显。
+`meta` 之后,`chat_run_executor.py` 持久化助手消息、回填 artifact,并起后台任务生成追问问题(`core/chat/followups.py`,结果写进消息 `extra_data.follow_up_questions`,前端经 `GET /v1/chats/{chat_id}/messages/{message_id}/followups` 拉取)。本体事件在前端汇总为独立的“领域本体治理”模块,不再写入或显示在“思考过程”中。模型草稿保持逐 token 流式展示;委员会仅在实际修订答案时发送一次 `content_replace`,前端原位替换正文,数据库只保存评审后的最终答案。`ontology_governance` 随助手消息持久化,刷新历史会话后仍可回显。
`usage` 是本轮所有模型调用的累计计费数据;上下文仪表使用的是
`context_usage`,即最近一次主模型调用的 `prompt_tokens + completion_tokens`,
@@ -233,9 +233,9 @@ data: [DONE]
引用让回答里的每个事实可溯源到具体工具结果。编号权收归后端唯一真源——模型只**复制**编号、不做任何计算,链路分四段:
-1. **发号回注(后端中间件)**:`core/llm/middlewares.py::CitationAnchorMiddleware` 挂在 AgentScope 2.0 的 `on_acting` 钩子上,在工具结果回给模型前调用 `orchestration/citation_anchor.py` 完成 提取 → 发号 → 回注:为每条可引用条目分配会话内单调唯一的锚点 id(`e1`、`e2`、…,跨工具、跨调用、跨轮不重复,新一轮从该会话历史消息的最大锚点续号),并把 `"cite_id": "e7"` 就地写进结果 JSON(纯文本结果在文末追加 `[cite_id: e7]` 行)。**发号器绑在 agent 实例上**(`attach_allocator()` / `resolve_allocator()`)——编排层与中间件由此共享同一个计数器;ContextVar 只作子智能体链路的兜底,因为 `astream_chat_workflow` 是 async generator,其上下文与 agent 实际执行所在的 task 并不互通。提取按四层降级:工具自声明 `__citations__` → 工具规格注册表(`TOOL_SPECS` 配置,列表路径 + 中英字段别名)→ 通用启发式(唯一字典数组字段)→ 整份结果 1 个锚点;操作型工具(写文件、pin 等,`SKIP_TOOLS`)直接放行。任何异常原样放行、绝不阻断对话。
+1. **发号回注(后端中间件)**:`core/llm/runtime/` 挂在 AgentScope 2.0 的 `on_acting` 钩子上,在工具结果回给模型前调用 `core/llm/citations/hook.py` 完成 提取 → 发号 → 回注:为每条可引用条目分配会话内单调唯一的锚点 id(`e1`、`e2`、…,跨工具、跨调用、跨轮不重复,新一轮从该会话历史消息的最大锚点续号),并把 `"cite_id": "e7"` 就地写进结果 JSON(纯文本结果在文末追加 `[cite_id: e7]` 行)。**发号器绑在 agent 实例上**(`attach_allocator()` / `resolve_allocator()`)——编排层与中间件由此共享同一个计数器;ContextVar 只作子智能体链路的兜底,因为 `astream_chat_workflow` 是 async generator,其上下文与 agent 实际执行所在的 task 并不互通。提取按四层降级:工具自声明 `__citations__` → 工具规格注册表(`TOOL_SPECS` 配置,列表路径 + 中英字段别名)→ 通用启发式(唯一字典数组字段)→ 整份结果 1 个锚点;操作型工具(写文件、pin 等,`SKIP_TOOLS`)直接放行。任何异常原样放行、绝不阻断对话。
2. **提示词约定**:系统提示词(`prompts/prompt_text/default/system/40_format.system.md` 的兜底版本,运行时以 DB 激活版本为准)只需一条与工具数量无关的通用规则:把结果里标注的 `cite_id` 原样复制进 `[锚文本](cite:e7)`(或句末 `[来源](cite:e7)`),禁止自行编号。
-3. **编排层消费**:每个 `tool_result` 事件经 `collect_citation_dicts()` 按 `tool_id` 从发号器注册表精确取 `CitationItem`(`id` / `tool_name` / `tool_id` / `title` / `url` / `snippet` / `source_type` / `item_index`);发号器缺位(旧对话回放等)时回退 `orchestration/citations.py` 的旧偏移提取。`source_type` 取值:CE 内置 `internet`、`knowledge_base`、`database`;`industry_news`、`ai_news`、`chain_info`、`company_profile` 等行业引用类型由商业版 EE 扩展。
+3. **编排层消费**:每个 `tool_result` 事件经 `collect_citation_dicts()` 按 `tool_id` 从发号器注册表精确取 `CitationItem`(`id` / `tool_name` / `tool_id` / `title` / `url` / `snippet` / `source_type` / `item_index`);发号器缺位(旧对话回放等)时回退 `core/llm/citations/` 的旧偏移提取。`source_type` 取值:CE 内置 `internet`、`knowledge_base`、`database`;`industry_news`、`ai_news`、`chain_info`、`company_profile` 等行业引用类型由商业版 EE 扩展。
4. **前端渲染**:citations 随 `tool_result` 与 `meta` 事件下发并随消息持久化(落库的就是注号后的结果,回放/分享与生成时编号一致)。`components/citation/CitationMarkdownBlock.tsx` 并行识别三种标记——`[锚文本](cite:eN)`(渲染为带悬浮出处卡片的文字链接)、`[[eN]]`(obsidian 双链容错)、旧格式 `[ref:工具名-序号]`(历史消息,渲染为角标);工具卡片条目上同步显示 `cite_id` 小徽章(`jx-tr-citeTag`),没有专属渲染器的工具由通用列表渲染器兜底成标准卡片。
**工具开发约定**:需要精确控制引用粒度的工具(自研或 MCP),在返回 JSON 里带 `__citations__` 字段——`[{"title": "...", "url": "...", "snippet": "...", "source_type": "..."}, …]`,条目顺序与结果正文对应;中间件优先采用并就地注入 `cite_id`。未自声明的工具按注册表配置或启发式提取,最差整份结果 1 个锚点——**任何工具默认可引用**。详见《[MCP 工具](mcp-tools.md)》的引用声明一节。
@@ -258,7 +258,7 @@ data: [DONE]
## 计划模式(Plan Mode)
-计划模式把复杂任务拆成「生成计划 → 用户确认/编辑 → 逐步执行」两阶段,实现在 `orchestration/subagents/plan_mode.py`:
+计划模式把复杂任务拆成「生成计划 → 用户确认/编辑 → 逐步执行」两阶段,实现在 `orchestration/plans/`:
- **生成**(`astream_generate_plan` / `POST /v1/plans/generate`):以 `disable_tools=True` 的"裸模型"产出结构化 JSON 计划。系统提示词解析顺序:版本池 `plan_mode` 激活版本 → 旧版 `system/90_plan_mode` 分段 → 文件兜底 `prompts/prompt_text/plan_mode/plan_mode.system.md` → 硬编码最小提示。
- **执行**(`astream_execute_plan` / `POST /v1/plans/{plan_id}/execute`):每个步骤独立建 agent 顺序执行,支持步骤级 MCP/技能/子智能体绑定、取消(`is_run_cancelled` 轮询);执行同样走 ChatRun + Redis Stream,可断线续播。
@@ -284,7 +284,7 @@ data: [DONE]
上下文和工作区是两个独立维度:探索员、审查员能检查当前文件,但不会被主对话或执行过程锚定;执行员则需要完整继承用户约束和既有决定。主智能体的动态路由表只展示本轮最终启用且角色策略允许下放的技能、MCP、知识库和基础工具;主智能体已关闭、未授权或被运行时过滤的能力既不会展示,也不会传给默认子智能体。三个角色都不能继续调用子智能体。默认列表不含 planner:普通主对话已用 `update_plan` 维护计划,避免形成“计划里的计划”。角色提示词在运行时仍来自版本池 `subagents` kind 的 `explorer / worker / reviewer` 三个独立 part,随包提示词 `extensions/agents/default//system.md` 只负责种子和故障回退;该 kind 保留在后端与迁移快照中,以兼容既有环境。
-除此之外,用户自建子智能体(`api/routes/v1/agents.py`,DB 表 `UserAgent`)可绑定独立的系统提示词、MCP / 技能 / 插件 / KB 集合与模型参数(provider / temperature / max_tokens / max_iters)。创建或编辑时,资源选择器支持以下来源:
+除此之外,用户自建子智能体(`edition_ee/routes/agents.py`,DB 表 `UserAgent`)可绑定独立的系统提示词、MCP / 技能 / 插件 / KB 集合与模型参数(provider / temperature / max_tokens / max_iters)。创建或编辑时,资源选择器支持以下来源:
- 已安装的技能与插件;
- 技能市场和插件市场,安装完成后自动绑定到当前子智能体;需要凭据的市场资源仍先走原有凭据配置与安装权限校验;
@@ -335,7 +335,7 @@ Enter / Tab 确认和 Escape 返回或关闭。个人在能力中心关闭的技
| 层次 | 实现 | 触发 |
|---|---|---|
-| 会话标题摘要 | `core/llm/summarizer.py::ConversationSummarizer`(`summarizer` 模型角色,`ENABLE_SUMMARY` 开关),`POST /v1/summary` | 新会话标题自动生成 |
+| 会话标题摘要 | `core/models/completions/summarizer.py::ConversationSummarizer`(`summarizer` 模型角色,`ENABLE_SUMMARY` 开关),`POST /v1/summary` | 新会话标题自动生成 |
| 统一上下文检查点 | `core/chat/compaction/service.py::run_compaction()`;轮前、轮内、轮末共用同一触发比例、交接提示词、替代历史结构和持久化 checkpoint | 上下文达到模型窗口的配置比例时 |
| 确定性溢出保护 | `ContextConfig.tool_result_limit` 先限制单条工具结果;统一压缩失败时保留原始上下文,最终请求超预算则保存断点并暂停 | 单条工具结果过大,或统一压缩暂时不可用时 |
@@ -374,29 +374,29 @@ framing 的后端估算。当前回合结束后若后台压缩已启动,`meta.
## 其它入口
-同一编排底座还服务:消息重新生成(`POST /v1/chats/{chat_id}/regenerate`)、编辑重发(`POST /v1/chats/{chat_id}/edit`)、非流式 `POST /v1/agents/responses`(`stream: false`)与定时自动化(`orchestration/schedulers/`)。
+同一编排底座还服务:消息重新生成(`POST /v1/chats/{chat_id}/regenerate`)、编辑重发(`POST /v1/chats/{chat_id}/edit`)、非流式 `POST /v1/agents/responses`(`stream: false`)与定时自动化(`orchestration/automations/`)。
## 相关源码
| 主题 | 路径 |
|---|---|
| 聊天路由 / SSE 出口 | `src/backend/api/routes/v1/chats/__init__.py` |
-| Run 解耦 / Redis Stream / 续播 | `src/backend/orchestration/chat_run_executor.py`,`api/routes/v1/chat_runs.py` |
-| 流式编排主流程 | `src/backend/orchestration/workflow.py` |
-| 事件映射(reply_stream → SSE) | `src/backend/orchestration/streaming.py` |
+| Run 解耦 / Redis Stream / 续播 | `src/backend/core/chat/runtime/`,`api/routes/v1/chat_runs.py` |
+| 流式编排主流程 | `src/backend/core/agent_runtime/runner.py` |
+| 事件映射(reply_stream → SSE) | `src/backend/core/llm/runtime/streaming.py` |
| 运行时上下文装配 | `src/backend/core/chat/context.py` |
| Agent 工厂 | `src/backend/core/llm/factory/build.py` |
-| 中间件 | `src/backend/core/llm/middlewares.py`(纯函数 helper 在 `core/llm/runtime/hooks.py`) |
-| 引用抽取 | `src/backend/orchestration/citations.py` |
+| 中间件 | `src/backend/core/llm/runtime/`(纯函数 helper 在 `core/harness/hooks.py`) |
+| 引用抽取 | `src/backend/core/llm/citations/` |
| 跨会话引用(名片 / 摘要 / 分页) | `src/backend/core/chat/references.py` |
| 跨会话历史工具 | `src/backend/core/llm/tools/chat_history_tool.py` |
| 引用前端渲染 | `src/frontend/src/utils/citations.ts`,`src/frontend/src/components/citation/` |
-| 计划模式 | `src/backend/orchestration/subagents/plan_mode.py`,`api/routes/v1/plans.py` |
-| 子智能体工具 | `src/backend/core/llm/subagents/tool.py`,`api/routes/v1/agents.py` |
-| 标题摘要 / 上下文压缩 / 窗口保护 | `src/backend/core/llm/summarizer.py`、`compaction.py`、`core/chat/compaction/service.py`、`context_manager.py` |
+| 计划模式 | `src/backend/orchestration/plans/`,`api/routes/v1/plans.py` |
+| 子智能体工具 | `src/backend/core/llm/subagents/tool.py`,`edition_ee/routes/agents.py` |
+| 标题摘要 / 上下文压缩 / 窗口保护 | `src/backend/core/models/completions/summarizer.py`、`compaction.py`、`core/chat/compaction/service.py`、`context_manager.py` |
| 超长结果 offload | `src/backend/core/llm/context/offloader.py` |
| 会话分享 | `src/backend/api/routes/v1/chat_shares.py` |
-| 追问生成 | `src/backend/orchestration/followups.py` |
+| 追问生成 | `src/backend/core/chat/followups.py` |
| 前端流式解析 / 追加消息 | `src/frontend/src/hooks/chatStream.ts`,`useStreaming.ts`,`components/chat/QueuedMessageCard.tsx` |
### 桌面端工具权限与操作确认
diff --git a/document/zh-CN/modules/knowledge-base-wiki.md b/document/zh-CN/modules/knowledge-base-wiki.md
index f936e071..f37a6cda 100644
--- a/document/zh-CN/modules/knowledge-base-wiki.md
+++ b/document/zh-CN/modules/knowledge-base-wiki.md
@@ -138,12 +138,12 @@ Wiki 是纯离线批处理,调用量随文档量线性增长,**建议单独
| 层 | 位置 |
|---|---|
-| 数据模型 | `src/backend/core/db/models/kb_wiki.py` |
-| 生成管线 | `src/backend/core/kb/wiki/`(`extract` / `cite` / `dedup` / `taxonomy` / `reduce` / `finalize` / `pipeline`) |
-| 提示词 | `src/backend/core/kb/wiki/prompts.py` |
-| 作业与 worker | `src/backend/core/kb/wiki/jobs.py`、`worker.py` |
-| 读取面 | `src/backend/core/kb/wiki/local_provider.py` |
-| 按库分发 | `src/backend/core/kb/wiki_router.py` |
-| 路由 | `src/backend/api/routes/v1/kb_wiki.py` |
+| 数据模型 | `src/backend/features/knowledge/models.py` |
+| 生成管线 | `src/backend/features/knowledge/wiki/`(`extract` / `cite` / `dedup` / `taxonomy` / `reduce` / `finalize` / `pipeline`) |
+| 提示词 | `src/backend/features/knowledge/wiki/prompts.py` |
+| 作业与 worker | `src/backend/features/knowledge/wiki/jobs.py`、`worker.py` |
+| 读取面 | `src/backend/features/knowledge/wiki/local_provider.py` |
+| 按库分发 | `src/backend/features/knowledge/wiki_router.py` |
+| 路由 | `src/backend/features/knowledge/api/kb_wiki.py` |
| 智能体工具 | `src/backend/extensions/mcp_servers/retrieve_dataset_content_mcp/wiki_impl.py` |
| 前端 | `src/frontend/src/components/kb/`(`WikiPanel` / `WikiTree` / `ConceptGraph` / `IndexModePicker`) |
diff --git a/document/zh-CN/modules/knowledge-base.md b/document/zh-CN/modules/knowledge-base.md
index 1351a810..5dd8ba6b 100644
--- a/document/zh-CN/modules/knowledge-base.md
+++ b/document/zh-CN/modules/knowledge-base.md
@@ -67,9 +67,9 @@ ORM 定义在 `src/backend/core/db/models/knowledge.py`:
| `kb_chunks` | **父块**原文(检索命中后返回给 LLM),含标签 `tags` 与关联问题 `questions` |
| `kb_assets` | 文档里的**媒体资产**(版面解析切出的图片、单独上传的图片):字节在对象存储,本表存归属分块、原文位置 `locator`、图注/OCR `text_content`、模型生成的描述 `caption` |
-Wiki 相关的三张表(`kb_wiki_pages` / `kb_wiki_folders` / `kb_wiki_jobs`)定义在 `core/db/models/kb_wiki.py`,见 [知识库 Wiki](./knowledge-base-wiki.md)。
+Wiki 相关的三张表(`kb_wiki_pages` / `kb_wiki_folders` / `kb_wiki_jobs`)定义在 `features/knowledge/models.py`,见 [知识库 Wiki](./knowledge-base-wiki.md)。
-子块不入关系库——向量化后写 Milvus collection `hugagent_kb_private`(`core/kb/kb_vector.py`),每行带 `user_id` / `kb_id` 字段做归属隔离,`row_type` 区分 chunk 行、question 行与 image 行。
+子块不入关系库——向量化后写 Milvus collection `hugagent_kb_private`(`features/knowledge/kb_vector.py`),每行带 `user_id` / `kb_id` 字段做归属隔离,`row_type` 区分 chunk 行、question 行与 image 行。
### 分块与索引
@@ -87,12 +87,12 @@ Wiki 相关的三张表(`kb_wiki_pages` / `kb_wiki_folders` / `kb_wiki_jobs`
### 多模态:图片检索
-文档里的图不再被丢弃。索引时走 `core/kb/kb_assets.py`:
+文档里的图不再被丢弃。索引时走 `features/knowledge/kb_assets.py`:
1. **抽图**——PDF 经外部解析服务的版面切分拿回图片本体、图注、页码与 bbox;单独上传的
图片自身即一个资产(此前会被当作纯文本硬解成乱码入库,现已修复)。字节存对象存储,
正文里原本指向解析服务临时路径的死链被改写成真实资产 URL。
-2. **图像理解**——复用[视觉桥](./model-providers.md#视觉桥让纯文本模型能读图)(`core/vision`),和对话里「纯文本主模型读图」
+2. **图像理解**——复用[视觉桥](./model-providers.md#视觉桥让纯文本模型能读图)(`core/models/vision`),和对话里「纯文本主模型读图」
走的是同一条通路、同一个 `vision` 模型角色,因此白得三样东西:按图片内容哈希的**证据缓存**
(重新索引同一篇文档不用再付一次钱)、三协议与结构化输出降级、以及「该角色只能指派多模态模型」
的硬约束。产出映射:`summary` → `caption`,`ocr.full_text` 并入 `text_content`(与原图注共存),
@@ -134,7 +134,7 @@ ASR 转写写进 `text_content`,其后的索引与检索完全复用。
### API 路由
-用户侧路由前缀 `/v1/catalog/kb`(`src/backend/api/routes/v1/kb.py`,请求模型在 `kb_models.py`):
+用户侧路由前缀 `/v1/catalog/kb`(`src/backend/features/knowledge/api/kb.py`,请求模型在 `kb_models.py`):
| 方法 | 路径 | 说明 |
|---|---|---|
@@ -150,7 +150,7 @@ ASR 转写写进 `text_content`,其后的索引与检索完全复用。
| GET / PATCH | `/v1/catalog/kb/{kb_id}/chunks[/{chunk_id}]` | 分块列表 / 编辑标签与问题 |
| GET | `/v1/catalog/kb/assets/{asset_id}` | 读取媒体资产原始字节(可见性跟随所属空间) |
-业务逻辑集中在 `core/services/kb_service.py::KBService`。
+业务逻辑集中在 `features/knowledge/service.py::KBService`。
### 系统托管知识库:我的空间同步
@@ -158,13 +158,13 @@ ASR 转写写进 `text_content`,其后的索引与检索完全复用。
### 管理员公共知识库(商业版 EE)
-`/v1/admin/kb/*` 管理台路由在 `src/backend/api/routes/v1/admin_kb.py`,挂 `content_admin` 能力位(EE 路由表见 `api/routes/v1/__init__.py`)。公共知识库由合成系统账号 `system_public_kb` 持有(`kb_service.py::SYSTEM_KB_OWNER_ID`),`visibility=public`,对全体用户可见可检索。管理端额外提供原始文件下载、Office 转 PDF 在线预览、分块内容编辑 / 删除等能力。
+`/v1/admin/kb/*` 管理台路由在 `src/backend/edition_ee/routes/admin_kb.py`,挂 `content_admin` 能力位(EE 路由表见 `api/routes/v1/__init__.py`)。公共知识库由合成系统账号 `system_public_kb` 持有(`kb_service.py::SYSTEM_KB_OWNER_ID`),`visibility=public`,对全体用户可见可检索。管理端额外提供原始文件下载、Office 转 PDF 在线预览、分块内容编辑 / 删除等能力。
公有知识库检索 `retrieve_dataset_content` 严格按平台配置选择一种来源:自建模式(`knowledge_base.provider=custom`)只检索当前用户有权访问的本地公有/共享库,排除个人私有库;外部模式只检索当前选择的 Dify、FastGPT 或 WeKnora 数据集,连接配置也按后端隔离。两种来源不合并,空结果或服务错误均不触发跨来源回退。公有知识库列表遵循相同的来源选择;私有知识库仍通过独立的 `retrieve_local_kb` 入口访问。
## Dify 外接知识库(商业版 EE)
-商业版客户端位于 `src/backend/edition_ee/kb/dify.py`,共享路由通过 `core/kb/external_provider.py` 接缝调用。CE 派生树把该接缝替换为禁用实现,且不包含 Dify 客户端。启用判定 `is_dify_enabled()` 的优先级:
+商业版客户端位于 `src/backend/edition_ee/kb/dify.py`,共享路由通过 `features/knowledge/extension.py` 接缝调用。CE 派生树把该接缝替换为禁用实现,且不包含 Dify 客户端。启用判定 `is_dify_enabled()` 的优先级:
1. DB 系统配置 `knowledge_base.provider == "dify"`(Config 管理台可改);
2. 环境变量 `KNOWLEDGE_BASE=dify`;
@@ -196,17 +196,17 @@ DIFY_API_KEY=dataset-... # 兼容别名 DIFY_AUTH_TOKEN
| 路径 | 职责 |
|---|---|
-| `src/backend/core/kb/kb_parser.py` | 文档解析 + 父子分块(5 种 chunk_method) |
-| `src/backend/core/kb/kb_vector.py` | Milvus collection、embedding、混合检索、重排 |
-| `src/backend/core/kb/kb_assets.py` | 媒体资产:落盘、图像理解(调用视觉桥)、检索行、结果附图 |
+| `src/backend/features/knowledge/kb_parser.py` | 文档解析 + 父子分块(5 种 chunk_method) |
+| `src/backend/features/knowledge/kb_vector.py` | Milvus collection、embedding、混合检索、重排 |
+| `src/backend/features/knowledge/kb_assets.py` | 媒体资产:落盘、图像理解(调用视觉桥)、检索行、结果附图 |
| `src/backend/edition_ee/kb/dify.py` | Dify datasets 客户端与启用判定(仅 EE) |
-| `src/backend/core/kb/external_provider.py` | 版本中立的外部知识库接缝;CE overlay 将其禁用 |
-| `src/backend/core/files/content/kb_processing.py` | 后台向量化任务、LLM 关键词 / 问题增强 |
+| `src/backend/features/knowledge/extension.py` | 版本中立的外部知识库接缝;CE overlay 将其禁用 |
+| `src/backend/features/knowledge/processing.py` | 后台向量化任务、LLM 关键词 / 问题增强 |
| `src/backend/core/files/content/file_validation.py` | 上传文件校验(扩展名 + magic bytes) |
| `src/backend/core/files/content/file_parser.py` | 通用文件解析器 |
-| `src/backend/core/services/kb_service.py` | 知识库业务逻辑(含系统托管同步库) |
-| `src/backend/api/routes/v1/kb.py` + `kb_models.py` | 用户侧 `/v1/catalog/kb` 路由 |
-| `src/backend/api/routes/v1/admin_kb.py` | 管理台公共知识库路由(商业版 EE) |
+| `src/backend/features/knowledge/service.py` | 知识库业务逻辑(含系统托管同步库) |
+| `src/backend/features/knowledge/api/kb.py` + `kb_models.py` | 用户侧 `/v1/catalog/kb` 路由 |
+| `src/backend/edition_ee/routes/admin_kb.py` | 管理台公共知识库路由(商业版 EE) |
| `src/backend/api/routes/v1/catalog.py` | 能力目录聚合(CE 仅私有库;EE 追加 Dify / 公共库) |
| `src/backend/extensions/mcp_servers/retrieve_dataset_content_mcp/` | 检索 MCP server(两个工具) |
| `src/backend/core/db/models/knowledge.py` | `KBSpace` / `KBDocument` / `KBChunk` / `KBAsset` ORM |
diff --git a/document/zh-CN/modules/mcp-tools.md b/document/zh-CN/modules/mcp-tools.md
index e1ca99da..b8706b50 100644
--- a/document/zh-CN/modules/mcp-tools.md
+++ b/document/zh-CN/modules/mcp-tools.md
@@ -26,7 +26,7 @@ HugAgentOS 的工具生态构建在 [MCP(Model Context Protocol)](https://mo
│ agent) │ │ │ :9103 产业知识中心统一 MCP(插件,EE) │
└─────────┘ │ │ :9104 保留(图表 MCP 已退役) │
│ │ │ :9105 保留(原报告导出 MCP) │
- MCPConnectionPool │ │ :9106 web_fetch(网页抓取) │
+ Native MCP Client │ │ :9106 web_fetch(网页抓取) │
mcp_pool.py) └───┬────┘ :9108 automation_task(自动化插件提供) │
│ 9109–9111 保留(原 excel/ppt/pdf MCP) │
│ :9112 skill_manager(技能管理) │
@@ -190,7 +190,7 @@ extensions/mcp_servers/_mcp/
- 中间件(`CitationAnchorMiddleware`)会**优先采用**该声明,就地为每条注入
`"cite_id": "eN"`,模型引用时原样复制。
2. **标准列表结构 → 加一行注册表配置**:结果形如 `{"items": [{"title": …, "content": …}]}`
- 的列表型工具,在 `orchestration/citation_anchor.py::TOOL_SPECS` 里登记
+ 的列表型工具,在 `core/llm/citations/hook.py::TOOL_SPECS` 里登记
`items_paths` + 字段别名即可逐条编号,不改工具代码。
3. **什么都不做 → 自动兜底**:通用启发式能识别顶层(或 `result` 下一层)唯一的
字典数组字段并逐条编号;彻底认不出时整份结果作为 1 个锚点。
@@ -201,21 +201,18 @@ extensions/mcp_servers/_mcp/
Server 同样适用(返回 JSON 即可)。回注发生在结果进模型上下文与落库之前,前端工具卡片
会把 `cite_id` 渲染成条目徽章,与正文 `[锚文本](cite:eN)` 引用一一对应。
-## 后端客户端:连接池与裸名还原
+## 后端客户端:原生 MCP 生命周期
-后端基于 AgentScope 2.0 的 `MCPClient` 连接 MCP Server,核心在两个文件:
+后端使用 MCP SDK 2.3 的 `Client(mode="auto")` 和 AgentScope `ToolBase`。`client.py` 处理传输授权,`lifetime.py` 管理 SDK 上下文并完整遍历分页。HTTP 在一次运行内复用客户端。STDIO 按执行用户、聊天和配置独占租用,成功结束后保留 60 秒;失败或取消则销毁。已删除稳定连接开关和启动连接池。
-- **`core/llm/mcp/pool.py` — `MCPConnectionPool`**(进程级单例):启动时 `warmup_mcp_tools()` 从 DB 读取全部启用的 Server 配置并预连接。注意 2.0 语义下的池化策略:
- - **stdio 且 `is_stable=true`** 的 Server 跨请求保持连接(省掉 1–7 秒子进程冷启动);
- - **HTTP Server 一律不池化**——2.0 的 stateful HTTP 客户端与 asyncio task 绑定,跨请求复用会触发 cancel-scope 崩溃,因此每请求用 `is_stateful=False` 新建连接;
- - per-request Server(知识库检索带用户头)每次现连,请求结束 `close_transient()` 关闭。
-- **`core/llm/mcp/manager.py` — `BareNameMCPClient`**:AgentScope 2.0 默认把工具名改写为 `mcp____`,该子类还原为服务器侧裸名(`internet_search` 而非 `mcp__internet_search__internet_search`),保证展示名映射(`core/config/display_names.py`)、[引用溯源](chat.md)的按工具名分发、前端图标渲染等 1.x 约定继续成立。
+`callbacks.py` 将隔离的文本 Sampling 与用户 Elicitation 绑定到当前运行。资源、URI 模板和提示词转换为受权限控制的工具。进度显示在现有工具卡片。结构化输出和资源链接保留在工具 metadata 与模型输出中。SDK 2.3 尚未提供新版 Tasks 扩展。
+
+MCP 工具的执行预算不包含等待用户回答的时间。有效提问会暂停当前调用的计时;回答或放弃后,从剩余预算继续。多次、重叠提问不会重置预算,其他调用继续独立计时。用户交互仍受现有等待期限约束,取消运行会清理待回答问题。SDK 请求和 HTTP 读取由调用预算约束,连接、写入和发现仍保留超时。
-`Toolkit` 在 2.0 是一次性构造,由 `core/llm/factory/build.py` 统一执行 `Toolkit(tools=[...], mcps=clients)`。
## 统一工具权限网关
-`core/llm/tool_permissions.py` 提供运行级 `ToolPermissionRegistry` 与
+`core/llm/tool_permissions/` 提供运行级 `ToolPermissionRegistry` 与
`ToolPermissionMiddleware`。Registry 是**受控工具清单**,不是全工具声明表:
- 工具名没有进入 Registry 时保持既有行为,直接放行,不要求新工具声明权限;
@@ -228,7 +225,7 @@ Server 同样适用(返回 JSON 即可)。回注发生在结果进模型上
一律拒绝,不会再以“记录审计后继续执行”降级;
- 渐进加载插件把新出现的 MCP 工具加入同一个运行级 Registry,因此热加载不会绕开网关。
-原生工具可以在 `ToolCollector.register_tool_function(..., permission=spec)` 注册规则;平台
+原生工具可以在 `ToolAssembly.append(function_tool(..., dispatch_spec=spec))` 注册规则;平台
内置的本机文件、Bash 与 MySpace 工具集中列在 `builtin_tool_permission()`。没有传
`permission` 且不在该表中的原生工具不受网关管理。
@@ -251,7 +248,7 @@ MCP 规则只接受平台/管理员持有的可信连接配置,不采信远端
## 配置注册:DB 驱动 + catalog 门控
-MCP Server 的配置真源是数据库表 `admin_mcp_servers`(ORM:`core/db/models.py::AdminMcpServer`),由 `core/capabilities/mcp/service.py::McpServerConfigService` 以 30 秒 TTL 缓存读出,格式与旧 `MCP_SERVERS` dict 兼容(`transport / command / args / env / url / headers / is_stable`)。`core/config/mcp_config.py` 保留为内置 Server 的 URL 构造器(首次部署种子)。
+MCP Server 的配置真源是数据库表 `admin_mcp_servers`(ORM:`core/db/models/`),由 `core/capabilities/mcp/service.py::McpServerConfigService` 以 30 秒 TTL 缓存读出,使用原生客户端配置(`transport / command / args / env / url / headers`)。`core/config/mcp_config.py` 保留为内置 Server 的 URL 构造器(首次部署种子)。
是否**对模型可见**还要过一道 [catalog](catalog.md) 门控:`core/config/catalog.json` 中 `mcp` 段的每项对应一个 server_id,`is_enabled(id, "mcp_server")` 为 false 的 Server 即使连接着也不会注册给智能体。
@@ -263,7 +260,7 @@ MCP Server 的配置真源是数据库表 `admin_mcp_servers`(ORM:`core/db/m
- **创建即试连**:`_probe_connectivity` 真实连一次,失败拒绝落库;
- **开关与排序**:`POST /{id}/toggle` 即时启停(联动刷新 catalog 与连接池);
- **密钥保护**:HTTP Header 值加密存储、接口统一以 `***` 返回;`env_vars` 中疑似密钥的值同样脱敏;
-- **测试与重载**:`POST /{id}/test` 单独试连;`POST /reload-pool` 热重建连接池;
+- **测试与重载**:`POST /{id}/test` 单独试连;`配置更新自动失效缓存` 更新后下一次运行重新发现;
- **移至 MCP 市场**:把现有远程 HTTP/SSE MCP 生成无凭据的市场快照,
并停用原全局实例;该服务随后从 MCP 服务管理列表移除,可在市场中复核、
编辑展示信息,再按需全局安装。原服务已配置的 Token 仍加密保留在服务端,
@@ -279,7 +276,7 @@ MCP Server 的配置真源是数据库表 `admin_mcp_servers`(ORM:`core/db/m
- `POST /v1/me/mcp-servers`:添加私有远程 MCP,支持公网 HTTP/HTTPS 上的 HTTP/SSE——用户入口禁止 stdio,避免在服务器执行任意命令;地址会经过 DNS/IP 检查以阻断 localhost、内网、链路本地与保留地址;创建即试连,连不上不落库。HTTP 为明文传输,生产环境仍建议使用 HTTPS;
- `DELETE /v1/me/mcp-servers/{id}`:删除自己的私有 MCP。
-实现上复用同一张 `admin_mcp_servers` 表:`owner_user_id` = 当前用户实现 owner 隔离,server_id 自动生成 `umcp_` 防冲突,`is_stable=False` 不进 warmup 池。HTTP Header 值使用 Fernet 加密后落库,运行时才解密。该功能由管理台的 per-user 权限位 `can_add_mcp` 控制(社区版单租户默认即可放开;商业版由组织管理员按用户授予,见[版本说明](../editions/overview.md))。
+实现上复用同一张 `admin_mcp_servers` 表:`owner_user_id` = 当前用户实现 owner 隔离,server_id 自动生成 `umcp_` 防冲突。HTTP Header 值使用 Fernet 加密后落库,运行时才解密。该功能由管理台的 per-user 权限位 `can_add_mcp` 控制(社区版单租户默认即可放开;商业版由组织管理员按用户授予,见[版本说明](../editions/overview.md))。
## MCP 市场
@@ -359,19 +356,19 @@ docker-compose up -d --build mcp
| `src/backend/extensions/mcp_servers/_serve.py` | stdio / streamable-http 双 transport 统一入口 |
| `src/backend/extensions/mcp_servers/registry.py` | 安装位置、暴露方式、包名和基础端口 |
| `src/backend/extensions/mcp_servers/_ports.py` | 实际部署端口映射 |
-| `src/backend/core/llm/mcp/pool.py` | MCP 连接池(stdio 池化 / HTTP per-request) |
+| `src/backend/core/llm/mcp/` | MCP 连接池(stdio 池化 / HTTP per-request) |
| `src/backend/core/llm/mcp/manager.py` | MCPClient 构造 + 工具裸名还原 |
-| `src/backend/core/llm/tool_permissions.py` | 受控工具 Registry、权限判定、确认路由与执行票据 |
+| `src/backend/core/llm/tool_permissions/` | 受控工具 Registry、权限判定、确认路由与执行票据 |
| `src/backend/core/capabilities/mcp/service.py` | DB 驱动的 Server 配置服务(30s 缓存) |
-| `src/backend/core/services/mcp_marketplace_service.py` | 市场发布、审核、安装、可见性与安全控制 |
+| `src/backend/features/marketplace/` | 市场发布、审核、安装、可见性与安全控制 |
| `src/backend/core/capabilities/mcp/oauth.py` | OAuth 2.1 登录、SDK 元数据发现、加密令牌存储与刷新 |
-| `src/backend/core/services/mcp_marketplace_monitor.py` | 远程工具快照定期复检与漂移监控 |
+| `src/backend/features/marketplace/` | 远程工具快照定期复检与漂移监控 |
| `src/backend/core/config/mcp_config.py` | 内置 Server URL 构造(http://mcp:NNNN/mcp/) |
| `src/backend/core/config/catalog.json` | 能力目录:MCP 启停门控种子 |
| `src/backend/api/routes/v1/admin_mcp_servers.py` | 管理员自定义 MCP API |
| `src/backend/api/routes/v1/me_capabilities.py` | 用户自助私有 MCP / 技能 API |
-| `src/backend/api/routes/v1/mcp_marketplace.py` | 用户端 MCP 市场 API |
-| `src/backend/api/routes/v1/admin_mcp_marketplace.py` | 管理端 MCP 市场与审核 API |
+| `src/backend/features/marketplace/api/mcp_marketplace.py` | 用户端 MCP 市场 API |
+| `src/backend/features/marketplace/api/admin_mcp_marketplace.py` | 管理端 MCP 市场与审核 API |
| `docker/Dockerfile.mcp` | mcp 容器镜像(MCP 运行时、绘图依赖与中文字体) |
相关文档:[能力目录](catalog.md) · [技能系统](agent-skills.md) · [知识库](knowledge-base.md) · [版本与许可](../editions/overview.md)
diff --git a/document/zh-CN/modules/memory.md b/document/zh-CN/modules/memory.md
index 98d7822f..c596185d 100644
--- a/document/zh-CN/modules/memory.md
+++ b/document/zh-CN/modules/memory.md
@@ -64,7 +64,7 @@ save_memories_background()
· sanitize 脱敏闸门 → 写 L1/L2/L3/Session → audit 旁路
```
-检索与注入的整合层在 `src/backend/orchestration/memory_integration.py`;mem0 的 LLM / Embedder / Milvus / Reranker 配置在 `src/backend/core/memory/service.py`,Neo4j 图谱读写在 `src/backend/core/memory/graph.py`。模型配置优先取 DB 中 `memory` / `embedding` 角色,缺省回落到环境变量。
+检索与注入的整合层在 `src/backend/core/agent_runtime/integrations/memory.py`;mem0 的 LLM / Embedder / Milvus / Reranker 配置在 `src/backend/core/memory/service.py`,Neo4j 图谱读写在 `src/backend/core/memory/graph.py`。模型配置优先取 DB 中 `memory` / `embedding` 角色,缺省回落到环境变量。
## 写入流水线与抽取器
@@ -136,7 +136,7 @@ save_memories_background()
| `memory_enabled` | 永久记忆**读取**:会话启动时是否注入冻结块 | `false` |
| `memory_write_enabled` | **写入**:对话结束后是否抽取并保存记忆 | `false` |
-项目对话有独立作用域:个人项目 / 默认空间用真实 `user_id`,团队项目用 `scope_user_id = "team:"`——同团队成员写入同一个 mem0 桶实现共享,真实作者保留在 `metadata.author_user_id`(见 `orchestration/memory_integration.py::save_memories_background` 与 `api/routes/v1/memories.py::list_memories`)。项目级开关存于 `projects.metadata`(`memory_enabled` / `memory_write_enabled`,项目内缺省 `true`),详见 [项目空间与我的空间](./projects-myspace.md)。
+项目对话有独立作用域:个人项目 / 默认空间用真实 `user_id`,团队项目用 `scope_user_id = "team:"`——同团队成员写入同一个 mem0 桶实现共享,真实作者保留在 `metadata.author_user_id`(见 `core/agent_runtime/integrations/memory.py::save_memories_background` 与 `api/routes/v1/memories.py::list_memories`)。项目级开关存于 `projects.metadata`(`memory_enabled` / `memory_write_enabled`,项目内缺省 `true`),详见 [项目空间与我的空间](./projects-myspace.md)。
## 前端记忆中心
@@ -229,8 +229,8 @@ RERANKER_API_KEY=...
| `src/backend/core/memory/sanitizer.py` | 脱敏闸门(硬编码规则 + DB 动态规则) |
| `src/backend/core/memory/audit.py` | 审计旁路(商业版 EE) |
| `src/backend/core/memory/context.py` | `MemoryContext` 与 workspace / 层级解析 |
-| `src/backend/orchestration/memory_integration.py` | 检索启动、冻结块组装与注入、保存转调 |
-| `src/backend/orchestration/workflow.py` | 主编排:记忆 hook 接线点 |
+| `src/backend/core/agent_runtime/integrations/memory.py` | 检索启动、冻结块组装与注入、保存转调 |
+| `src/backend/core/agent_runtime/runner.py` | 主编排:记忆 hook 接线点 |
| `src/backend/api/routes/v1/memories.py` | `/v1/memories` 管理 API |
| `src/backend/core/db/models/memory.py` | `ProfileMemory`、`MemoryOutbox`、`MemorySanitizerRule` ORM |
| `src/backend/edition_ee/db/models/memory.py` | `MemoryAudit` ORM(仅 EE) |
diff --git a/document/zh-CN/modules/model-providers.md b/document/zh-CN/modules/model-providers.md
index e2b4bb23..40e28d2a 100644
--- a/document/zh-CN/modules/model-providers.md
+++ b/document/zh-CN/modules/model-providers.md
@@ -9,7 +9,7 @@ HugAgentOS 通过 **OpenAI 兼容协议**接入任意大模型端点(vLLM、Ol
## 配置模型:供应商 + 角色
-两张表(`core/db/models.py`):`model_providers`(base_url / api_key / model_name / extra_config / is_active)与 `model_role_assignments`(role_key → provider_id)。角色定义在 `core/db/model_repository.py::ROLE_DEFINITIONS`:
+两张表(`core/db/models/`):`model_providers`(base_url / api_key / model_name / extra_config / is_active)与 `model_role_assignments`(role_key → provider_id)。角色定义在 `core/models/repository.py::ROLE_DEFINITIONS`:
| role_key | 用途 | 类型 |
|---|---|---|
@@ -36,6 +36,8 @@ HugAgentOS 通过 **OpenAI 兼容协议**接入任意大模型端点(vLLM、Ol
### 接口协议:默认 Responses,不支持才回退
+Responses 的思考原文和摘要事件由当前锁定的 AgentScope 原生解析。平台原样保留事件的 `item_id` 与分段索引,避免流式发送失败,以及不同思考条目或分段被错误合并。
+
OpenAI 兼容上游有两套接口:较老的 `POST /chat/completions` 和较新的 `POST /responses`。平台**默认走 Responses**,因为它把模型的思考做成独立的 `reasoning` 条目,下一轮可原样回传;`/chat/completions` 没有放思考的位置,只能把思考夹带进 `reasoning_content` 字段。只有确实没有 `/responses` 路由的上游(常见于只做转发的中转网关)才使用 Chat Completions。
走哪套由 `extra_config.api_protocol` 决定,取值 `responses` / `chat_completions`。该值不靠厂商名推断,而是保存供应商时向上游探测后写入。探测分两步,两步都过才判为 Responses 可用:
@@ -124,7 +126,7 @@ DeepSeek、GLM、Qwen 文本版这类主力模型看不见图片。视觉桥的
## 动态模型切换(chat_mode)
-前端每条消息可带 `chat_mode`(`turbo / fast / medium / high / max`),在 reply 开始前由 `core/llm/middlewares.py::DynamicModelMiddleware`(`on_reply`)热切 `agent.model`:
+前端每条消息可带 `chat_mode`(`turbo / fast / medium / high / max`),在 reply 开始前由 `core/llm/runtime/`(`on_reply`)热切 `agent.model`:
```
chat_mode → hooks._resolve_chat_mode(agent.state)
@@ -134,7 +136,7 @@ chat_mode → hooks._resolve_chat_mode(agent.state)
high/max → reasoning_effort=high/max(端点须声明支持)
```
-旧客户端只传 `enable_thinking` 时回退映射为 medium / fast。这就是 1.x「pre_reply 动态模型 hook」在 AgentScope 2.0 下的形态——hook 工厂已重构为中间件,`core/llm/runtime/hooks.py` 仅保留纯函数 helper。
+旧客户端只传 `enable_thinking` 时回退映射为 medium / fast。这就是 1.x「pre_reply 动态模型 hook」在 AgentScope 2.0 下的形态——hook 工厂已重构为中间件,`core/harness/hooks.py` 仅保留纯函数 helper。
## 环境变量与 MCP 子进程注入
@@ -157,7 +159,7 @@ chat_mode → hooks._resolve_chat_mode(agent.state)
## 计费与用量
-token 用量在流式收尾的 `meta.usage` 中统计(`orchestration/streaming.py` 从 `ModelCallEndEvent` 累计)并随消息持久化,之上有两组管理台报表(`CONFIG_TOKEN`):
+token 用量在流式收尾的 `meta.usage` 中统计(`core/llm/runtime/streaming.py` 从 `ModelCallEndEvent` 累计)并随消息持久化,之上有两组管理台报表(`CONFIG_TOKEN`):
- **用量日志** `api/routes/v1/admin_usage_logs.py`:`GET /v1/admin/usage-logs`(明细)、`/summary`(汇总)、`/models`(去重模型名)。
- **计费报表** `api/routes/v1/admin_billing.py`(**商业版 EE**——完整管理台能力):`GET /v1/admin/billing/summary`(按用户/模型聚合成本)、模型定价 CRUD(`/pricing`,输入/输出单价、币种)、`GET /v1/admin/billing/export`(CSV 成本导出)。
@@ -166,21 +168,21 @@ token 用量在流式收尾的 `meta.usage` 中统计(`orchestration/streaming
## 路由策略(ROUTER_STRATEGY)
-`orchestration/strategy.py`:`ROUTER_STRATEGY=main_only`(默认)恒路由到主智能体;`llm_router` 为预留占位,当前实现同样回落 `MainOnlyStrategy`(safe-by-default)。实际的多智能体分流由 `@提及` + `call_subagent` 工具完成,见 [对话与智能体编排](chat.md)。
+`core/agent_runtime/strategy.py`:`ROUTER_STRATEGY=main_only`(默认)恒路由到主智能体;`llm_router` 为预留占位,当前实现同样回落 `MainOnlyStrategy`(safe-by-default)。实际的多智能体分流由 `@提及` + `call_subagent` 工具完成,见 [对话与智能体编排](chat.md)。
## 相关源码
| 主题 | 路径 |
|---|---|
-| 模型工厂 / JxOpenAIChatModel | `src/backend/core/llm/chat_models.py` |
-| 角色解析服务(DB + 缓存) | `src/backend/core/services/model_config.py` |
-| 角色定义 / 供应商仓储 | `src/backend/core/db/model_repository.py` |
+| 模型工厂 / JxOpenAIChatModel | `src/backend/core/models/chat_models.py` |
+| 角色解析服务(DB + 缓存) | `src/backend/core/models/config.py` |
+| 角色定义 / 供应商仓储 | `src/backend/core/models/repository.py` |
| 管理 API | `src/backend/api/routes/v1/models.py` |
-| 动态模型中间件 | `src/backend/core/llm/middlewares.py::DynamicModelMiddleware`,helper 在 `core/llm/runtime/hooks.py` |
-| MCP env 注入 | `src/backend/core/services/model_config.py::get_mcp_env_overlay`,`core/config/runtime_env.py` |
+| 动态模型中间件 | `src/backend/core/llm/runtime/`,helper 在 `core/harness/hooks.py` |
+| MCP env 注入 | `src/backend/core/models/config.py::get_mcp_env_overlay`,`core/config/runtime_env.py` |
| 个人 API-Key | `src/backend/api/routes/v1/api_keys.py`,`core/auth/api_keys.py`,`core/auth/backend.py` |
| 用量日志 / 计费 | `src/backend/api/routes/v1/admin_usage_logs.py`,`api/routes/v1/admin_billing.py` |
-| 路由策略 | `src/backend/orchestration/strategy.py` |
+| 路由策略 | `src/backend/core/agent_runtime/strategy.py` |
| 主模型缺失快速失败 | `src/backend/api/routes/v1/chats/__init__.py::_ensure_main_model_configured` |
## 模型专属思考档位
diff --git a/document/zh-CN/modules/personal-agent.md b/document/zh-CN/modules/personal-agent.md
index d26c3ac1..ec8bce3a 100644
--- a/document/zh-CN/modules/personal-agent.md
+++ b/document/zh-CN/modules/personal-agent.md
@@ -219,24 +219,18 @@ Linux 桌面运行环境未提供输入驱动时,连接只包含会话管理
先按项目部署文档准备企业版、PostgreSQL、Redis 和 OpenSandbox 镜像。
-1. 构建专用电脑镜像:`docker build -f docker/Dockerfile.personal-agent -t hugagent-personal-agent:v5 .`。不要覆盖正在运行的电脑使用的标签。
+1. 构建专用电脑镜像:`docker build -f docker/personal-agent/Dockerfile.personal-agent -t hugagent-personal-agent:v5 .`。不要覆盖正在运行的电脑使用的标签。
2. 设置 `HOST_STORAGE_PATH` 为宿主机绝对路径。
3. 为 `PERSONAL_AGENT_OPENSANDBOX_API_KEY` 设置至少 32 个字符的独立随机密钥。
4. 设置 `PERSONAL_AGENT_OPENSANDBOX_DOMAIN=personal-agent-opensandbox:8080`。
5. 设置 `PERSONAL_AGENT_IMAGE=hugagent-personal-agent:v5`。已有电脑在下次重建时使用新镜像。
-6. 使用根 Compose 文件和附加文件:`docker compose -f docker-compose.yml -f docker/compose.personal-agent.yml --profile personal-agent up -d`。
+6. 使用根 Compose 文件和附加文件:`docker compose -f docker-compose.yml -f docker/personal-agent/compose.personal-agent.yml --profile personal-agent up -d`。
7. 按项目现有迁移流程升级数据库。依次应用 `personalagent01` 至 `personalagent05`。这些迁移创建模块表、回填消息关联、建立独立任务会话,并增加跨设备会话操作表。
附加文件创建独立控制器、专用网络和私有配置卷。控制器需要访问 Docker socket。密钥不得提交到 Git。现有数据库密钥用于加密沙盒和邮箱凭据。
配置缺失时,Personal Agent 不能连接普通控制器作为替代。后台服务每分钟检查永久电脑。用户暂停任务队列时,电脑仍保留。
-## 验证范围
-
-自动化测试覆盖单一身份、并发任务、租约、审批、设备撤销、历史分页和普通聊天入口限制。隔离环境还验证了实际模型、真实云端截图和输入、文件持久化、SMTP/IMAP 附件和前后端流程。
-
-Ethereal 测试邮箱捕获邮件。它不能证明向普通互联网邮箱投递成功。Mac 和 Windows 原生桌面测试必须在对应系统完成。具体已通过项和待验项见内部验收记录。
-
回滚 personalagent02 前,必须等待所有消息回复完成。回滚把任务关联及独立指令归档到原消息元数据中。重新升级会恢复这些关联和指令。
## 状态栏
diff --git a/document/zh-CN/modules/projects-myspace.md b/document/zh-CN/modules/projects-myspace.md
index 9e94ac8c..d79a3b0e 100644
--- a/document/zh-CN/modules/projects-myspace.md
+++ b/document/zh-CN/modules/projects-myspace.md
@@ -35,7 +35,7 @@ teams ─────────┬── team_members(role: owner/admin/memb
## 项目(Projects)
-路由:`src/backend/api/routes/v1/projects.py`(CE 路由表),业务在 `core/projects/service.py` 与 `project_file_service.py`。
+路由:`src/backend/api/routes/v1/projects.py`(CE 路由表),业务在 `core/projects/` 与 `project_file_service.py`。
| 方法 | 路径 | 说明 |
|---|---|---|
@@ -76,11 +76,11 @@ teams ─────────┬── team_members(role: owner/admin/memb
1. 读取项目元信息——`project_name`、`project_instructions`、挂钩文件夹名和工作目录;项目指令仍读取 `AGENTS.md`,不递归枚举云端或本机项目文件;
2. `core/llm/factory/build.py` 把这些经 `_build_project_section` 注入 system prompt(`build_system_prompt(cfg, ctx=...)`);
3. 项目级记忆作用域随之生效:workspace 为 `project:`,团队项目的 mem0 桶为 `team:`(成员共享记忆),项目自身的 `metadata.memory_enabled` / `memory_write_enabled` 覆盖用户级开关(项目内缺省开启)——详见 [记忆系统](./memory.md);
-4. 沙箱侧路径作用域:项目对话中 agent 的 `/myspace/...` 文件操作被重定向到挂钩文件夹之下(`core/llm/tools/myspace_vfs/__init__.py` 的 `ProjectScope` 显式传参机制)。
+4. 沙箱侧路径作用域:项目对话中 agent 的 `/myspace/...` 文件操作被重定向到挂钩文件夹之下(`core/files/spaces/` 的 `ProjectScope` 显式传参机制)。
项目模式自定义提示词模板使用项目名称、目录范围和项目指令变量。升级已有部署前,须从已保存的模板中移除旧文件清单及文件总数占位符;运行时不再提供这些变量。
-团队项目权限沿用团队角色:owner/admin 恒为管理权限,member 按 `file_permission`(editor/viewer)二级控制(`core/auth/permissions_iface.py::require_project_access`)。
+团队项目权限沿用团队角色:owner/admin 恒为管理权限,member 按 `file_permission`(editor/viewer)二级控制(`core/auth/personal_permissions_iface.py::require_project_access`)。
### 项目侧栏交互
@@ -158,7 +158,7 @@ teams ─────────┬── team_members(role: owner/admin/memb
三条路径,互为补充:
-1. **附件注入(hooks)**:前端请求的 `attachments[]` 只携带 `file_id`、`name`、`mime_type`,不携带文件正文。`core/llm/runtime/hooks.py` 按 `file_id` 延迟下载、解析并缓存 `parsed_text` 与摘要;首轮只注入文件元数据和最多 5,000 字符的有界预览,xlsx 额外提供总行列数、表头和少量数据行。模型需要预览外的正文时通过 `read_artifact` 按需读取;图片仍走多模态注入。所有读取都校验 `user_id` 归属,防止伪造 `file_id` 跨用户访问;
+1. **附件注入(hooks)**:前端请求的 `attachments[]` 只携带 `file_id`、`name`、`mime_type`,不携带文件正文。`core/harness/hooks.py` 按 `file_id` 延迟下载、解析并缓存 `parsed_text` 与摘要;首轮只注入文件元数据和最多 5,000 字符的有界预览,xlsx 额外提供总行列数、表头和少量数据行。模型需要预览外的正文时通过 `read_artifact` 按需读取;图片仍走多模态注入。所有读取都校验 `user_id` 归属,防止伪造 `file_id` 跨用户访问;
2. **项目文件访问**:项目对话的 system prompt 提供项目指令和目录范围,不自动携带文件列表或文件总数。agent 按需使用目录、搜索和读取工具获取文件信息;
3. **沙箱虚拟文件系统**:开启代码执行时,`/myspace/...` 路径把我的空间映射进沙箱(懒加载 + 文件系统实时登记),团队项目映射团队文件夹——见 [沙箱](./sandbox.md)。
@@ -167,8 +167,8 @@ teams ─────────┬── team_members(role: owner/admin/memb
| 路径 | 职责 |
|---|---|
| `src/backend/api/routes/v1/projects.py` | 项目 API |
-| `src/backend/core/projects/service.py` / `project_file_service.py` | 项目业务逻辑 |
-| `src/backend/core/projects/scope.py` | `ProjectScope`(沙箱路径作用域) |
+| `src/backend/core/projects/` / `project_file_service.py` | 项目业务逻辑 |
+| `src/backend/core/projects/personal_scope.py` | `ProjectScope`(沙箱路径作用域) |
| `src/backend/api/routes/v1/myspace_folders.py` | 个人文件夹 API |
| `src/backend/api/routes/v1/artifacts.py` | 资产列表 / 会话收藏 / 加入知识库 |
| `src/backend/edition_ee/routes/team_files.py` | 团队文件夹与文件 API(商业版 EE) |
@@ -177,9 +177,9 @@ teams ─────────┬── team_members(role: owner/admin/memb
| `src/backend/core/db/models/identity.py` | `UserFolder` 等共享身份 ORM |
| `src/backend/edition_ee/db/models/identity.py` | `Team` / `TeamMember` / `TeamFolder` ORM(仅 EE) |
| `src/backend/core/db/models/artifact.py` | `Artifact` ORM |
-| `src/backend/core/llm/runtime/hooks.py` | 附件上下文注入(`_build_file_context` 等) |
+| `src/backend/core/harness/hooks.py` | 附件上下文注入(`_build_file_context` 等) |
| `src/backend/core/llm/factory/build.py` | 项目 section 注入 system prompt |
-| `src/backend/core/llm/tools/myspace_vfs/__init__.py` | 我的空间 ↔ 沙箱映射层 |
+| `src/backend/core/files/spaces/` | 我的空间 ↔ 沙箱映射层 |
| `src/frontend/src/components/projects/` | 项目前端组件 |
| `src/frontend/src/components/sidebar/Sidebar.tsx` | 侧栏项目分组、快捷新建与项目菜单 |
| `src/frontend/src/components/myspace/` | 我的空间前端组件 |
@@ -256,13 +256,13 @@ Config「团队管理」新增成员或调整成员权限时,可选「所有
### 空间文件工具
-空间文件工具统一由 `core/llm/tools/space_tools.py` 注册,调用名为 `space_list_myspace_files`、`space_stage_myspace_file`、`space_create_folder`、`space_move`、`space_delete`;商业版另提供 `space_list_team_files`。通用 Read/Write/Edit/Glob/Grep 名称保持不变。
+空间文件工具统一由 `core/files/spaces/` 注册,调用名为 `space_list_myspace_files`、`space_stage_myspace_file`、`space_create_folder`、`space_move`、`space_delete`;商业版另提供 `space_list_team_files`。通用 Read/Write/Edit/Glob/Grep 名称保持不变。
收藏会话工具已移除;历史会话仍可通过 `list_related_chats` 和 `read_chat` 访问。
### 挂载目录统一同步
-个人空间监听实现位于 `core/space_sync/`,团队适配位于 `edition_ee/services/space_sync/`。监听回调只入内存队列,后台批量写入挂载目录之外的 SQLite 日志,再执行带权限、版本检查的数据库及对象存储更新。原生移动保留文件和文件夹 ID;空目录、删除、重启恢复及解析缓存失效使用同一同步流程。数据库提交也会刷新已注册的挂载目录,回滚不刷新。冲突保留本地内容并使同步屏障报告失败。网络下载、上传和人工审批时间不属于回调延迟,完整同步延迟须按实际环境测量。
+个人空间监听实现位于 `core/files/spaces/`,团队适配位于 `edition_ee/services/workspace_adapter.py`。监听回调只入内存队列,后台批量写入挂载目录之外的 SQLite 日志,再执行带权限、版本检查的数据库及对象存储更新。原生移动保留文件和文件夹 ID;空目录、删除、重启恢复及解析缓存失效使用同一同步流程。数据库提交也会刷新已注册的挂载目录,回滚不刷新。冲突保留本地内容并使同步屏障报告失败。网络下载、上传和人工审批时间不属于回调延迟,完整同步延迟须按实际环境测量。
### 项目环境
diff --git a/document/zh-CN/modules/prompts.md b/document/zh-CN/modules/prompts.md
index c5018a0e..31e4a81c 100644
--- a/document/zh-CN/modules/prompts.md
+++ b/document/zh-CN/modules/prompts.md
@@ -48,7 +48,7 @@ src/backend/prompts/prompt_text/default/system/
### 执行 Manifest 与运行时绑定
-Agent 真正开始执行前,`core/llm/runtime/execution_manifest.py::PromptManifestBuilder` 会记录最终请求面。每个有序 PromptSection 都带 `id`、来源、信任级、优先级、缓存类别、预算、token 估算、内容哈希和版本;每个最终 ToolDefinition 同样对名称、完整描述、canonical 参数 schema、权限策略和恢复策略生成稳定哈希。工具定义按稳定 id 排序,提示词分段则保持刻意设计的前缀顺序。
+Agent 真正开始执行前,`core/harness/execution_manifest.py::PromptManifestBuilder` 会记录最终请求面。每个有序 PromptSection 都带 `id`、来源、信任级、优先级、缓存类别、预算、token 估算、内容哈希和版本;每个最终 ToolDefinition 同样对名称、完整描述、canonical 参数 schema、权限策略和恢复策略生成稳定哈希。工具定义按稳定 id 排序,提示词分段则保持刻意设计的前缀顺序。
Builder 最终产出 `prompt_hash`、`prompt_manifest_hash`、`tool_manifest_hash`、`context_hash` 和统一的 `aggregate_hash`。Manifest 构建与 AgentScope 模型请求共用同一份 run-scoped 工具/技能冻结快照,因此“记录的请求面”和“实际发送的请求面”不会错位;渐进式插件激活会让旧快照失效,并在下一次模型调用前发布明确递增的 `surface_generation`。
@@ -94,8 +94,8 @@ Runtime Binder 把这份脱敏 Manifest 挂到本轮递归不可变的 Asset Bun
| kind | 运行时消费方 | 解析顺序 |
|---|---|---|
| `code_exec` | `factory/build.py` 在 `CODE_CAPABILITY_ENABLED=true` 时把该段(代码能力提示词)拼到 system prompt 尾部;单一真源为 `prompt_version_service.render_code_capability_segment()`,管理台 preview 同源 | DB 激活版本 → `prompts/prompt_text/code_exec/system/*.system.md` |
-| `distillation` | 技能蒸馏(`core/llm/skill_distiller.py`,把对话轨迹蒸馏为可复用技能) | DB 激活版本 → `prompts/prompt_text/distillation/skill_distiller.system.md` |
-| `plan_mode` | 计划模式生成子智能体(`orchestration/subagents/plan_mode.py::_load_plan_prompt`) | DB `plan_mode` 激活版本 → 旧版 system 版本的 `system/90_plan_mode` 分段 → `prompts/prompt_text/plan_mode/plan_mode.system.md` → 硬编码兜底 |
+| `distillation` | 技能蒸馏(`features/distillation/`,把对话轨迹蒸馏为可复用技能) | DB 激活版本 → `prompts/prompt_text/distillation/skill_distiller.system.md` |
+| `plan_mode` | 计划模式生成子智能体(`orchestration/plans/`) | DB `plan_mode` 激活版本 → 旧版 system 版本的 `system/90_plan_mode` 分段 → `prompts/prompt_text/plan_mode/plan_mode.system.md` → 硬编码兜底 |
| `turbo` | 极速模式(对话模式选「极速」时,`factory/build.py` 以该提示词**整体替换**主系统提示词)。工具集由 Config「系统配置 → 极速模式」动态配置(`turbo.mcp_server_ids`,默认联网搜索/网页抓取/知识库检索三件套,**独立于能力目录的启停**);用户显式呼唤的技能 / @子智能体 / 插件可临时入场(`turbo.manual_invoke_enable`);迭代上限可配(`turbo.max_iters`,默认 4)。单一真源为 `prompt_version_service.render_turbo_system_prompt()` | DB `turbo` 激活版本 → `prompts/prompt_text/turbo/turbo.system.md` → 硬编码兜底 |
子智能体不走版本池整版拼装:`prompt_runtime.py::build_subagent_system_prompt()` 以用户自定义 `system_prompt` 为核心,复用激活版本(或文件)的 `20_tools_policy` / `65_citations` / `60_format` 分段组装,详见 [对话与智能体编排](chat.md)。
diff --git a/document/zh-CN/modules/sandbox.md b/document/zh-CN/modules/sandbox.md
index cabf8e96..d2db66e6 100644
--- a/document/zh-CN/modules/sandbox.md
+++ b/document/zh-CN/modules/sandbox.md
@@ -63,11 +63,11 @@ Cube 的设计取舍(远端节点版的代价):所有语言统一走"写
框架保留最多 64 个进程记录,仅回收已完成记录;新增输出有界,省略内容通过 `output_omitted_chars` 明示。本地及 script_runner 的完整输出存于当前会话工作区的 `.__process_*/stdout.log` 和 `stderr.log`,通过 `output_files` 返回真实路径;单流超过 64 MiB 时停止命令,已写日志仍可读。OpenSandbox 后台日志合并 stdout/stderr;Cube 总输出超过 64 MiB 时停止。运行期间定期调用沙箱保活:OpenSandbox 可续租,Cube 的兼容服务可能不支持延长服务端 TTL,返回 `lifetime_note` 说明这一边界。普通会话的命令账本保存任务状态与有界输出。OpenSandbox 使用原执行标识和日志游标恢复;不重跑命令。其他 provider 正常退出时停止进程;异常重启后标记 unknown。显式删除会话会停止任务。团队项目在命令终态返回后同步源码;必须等待完成,不能把 running 当成已经保存。
-后台执行由服务端采集状态、保存日志并保持沙箱活动。模型可以执行独立工作;没有其他工作时,模型结束当前运行。完成事件在下一次推理前进入活动会话,或使用现有会话运行入口唤醒空闲模型。write_stdin 用于按需读取新增输出、短时等待和取消,不用于反复轮询。认证日志 API 的读取不消耗模型输出游标。聊天界面不展示独立的后台命令栏。页面预先订阅 /v1/commands/events 长连接,后端在唤醒运行就绪后立即推送运行标识,页面随即接入现有输出流,不等待任务轮询。重连时补发已就绪的运行;已结束的短回复从历史加载。实现边界见 [后台 Bash ADR](../../internal/adr/20261018-background-bash-completion.md)。
+后台执行由服务端采集状态、保存日志并保持沙箱活动。模型可以执行独立工作;没有其他工作时,模型结束当前运行。完成事件在下一次推理前进入活动会话,或使用现有会话运行入口唤醒空闲模型。write_stdin 用于按需读取新增输出、短时等待和取消,不用于反复轮询。认证日志 API 的读取不消耗模型输出游标。聊天界面不展示独立的后台命令栏。页面预先订阅 /v1/commands/events 长连接,后端在唤醒运行就绪后立即推送运行标识,页面随即接入现有输出流,不等待任务轮询。重连时补发已就绪的运行;已结束的短回复从历史加载。
沙箱会话标识由 `resolve_sandbox_session(sandbox_session_id, chat_id)` 解析:非空显式 ID 优先,未传或传空值时统一回落到 `chat_id`。主智能体、计划执行、批量项和所有子智能体因此使用同一个会话沙箱;子智能体结束时不会销毁它。
-**MySpace 实时登记**:写在 `/myspace` 下的文件**就是**用户「我的空间」里的文件,两侧任何时刻看到的都应是同一份状态。登记由**文件系统本身**驱动(`core/myspace/watcher.py`):镜像目录 `myspace_cache/{uid}/` 里发生任何写入或删除,都被登记回 artifact 账本,**不区分是谁写的**。
+**MySpace 实时登记**:写在 `/myspace` 下的文件**就是**用户「我的空间」里的文件,两侧任何时刻看到的都应是同一份状态。登记由**文件系统本身**驱动(`core/files/spaces/`):镜像目录 `myspace_cache/{uid}/` 里发生任何写入或删除,都被登记回 artifact 账本,**不区分是谁写的**。
早先的做法是让会写文件的工具各自登记(write / edit / 文件增删改 / `Bash` 前后拍目录快照)。这套写法的前提是"改动只可能从这几个口子进来",而这个前提不成立:`nohup` 起的后台进程在命令返回之后才写文件,子智能体和批量任务在另一条协程里写,技能里的 CLI 直接写,MCP 服务端也写。每多一个入口就要多补一处登记,漏掉的那一处的表现是——文件躺在用户网盘的磁盘上,界面上看不见也删不掉,而每个新建沙箱都会把这份目录挂进来,成了"上个会话的残留中间文件"。
@@ -88,9 +88,7 @@ Cube 的设计取舍(远端节点版的代价):所有语言统一走"写
- **判定基准是 artifact 记录,不是镜像缓存**:开了 bind mount 后沙箱的 `/workspace/myspace/{uid}` 就是后端 `myspace_cache/{uid}` 本身,"沙箱文件与镜像缓存一致"恒真、不能再当作"已登记"。这条拓扑差异曾让回写恒为空转。
- **"磁盘比账本新"是唯一依据**:后端自己往镜像目录写文件(界面上传的物化、否决后的还原)时会把 mtime 对齐到登记时间,于是监听器不会把后端自己的写入当成沙箱的新改动转头再登记一次——不必另外维护一张"这是我自己写的"的表。
- **每个 worker 都监听,认领去重**:确认条活在跑这个会话的那个进程里,所以监听器必须每个 worker 都跑(代价是 inotify watch 按「worker 数 × 目录数」占内核配额 `fs.inotify.max_user_watches`,目录极多的部署要相应调大,超限会**静默丢事件**);同一次改动由 `core.infra.ephemeral` 的认领保证只处理一次,且优先交给托管着该用户会话的进程(它才弹得出确认条)。
-- **沙箱目录不在本机时**(`script_runner` / `cube`:会话工作区在容器内、或整个沙箱在远端)由 `core/myspace/sandbox_sync.py` 在命令结束后把沙箱现状搬进镜像目录,之后的判定与登记完全同路。
-
-历史欠账(本次改造上线前积压的未登记文件 / 该删没删的残留)是**一次性数据迁移**,用 `scripts/reconcile_myspace_mirror.py` 手工跑一次即可(`--dry-run` 先看规模,残留副本的清理加 `--prune-stale`)。它不做成每次启动都执行的扫描:那既是白跑,也会掩盖实时这条路上真正的漏洞。
+- **沙箱目录不在本机时**(`script_runner` / `cube`:会话工作区在容器内、或整个沙箱在远端)由 `core/files/spaces/sandbox.py` 在命令结束后把沙箱现状搬进镜像目录,之后的判定与登记完全同路。
### 大文件产物交付
@@ -175,8 +173,8 @@ artifact 送入沙盒、`Bash` 执行后 `/myspace` 写回同步;backend 与 s
## 管理员沙箱管理
-- **只读监控(安全管理台)**:`api/routes/v1/config_security.py` 暴露 `/v1/config/security/sandbox/*`——总览、实例列表、单实例详情、快照列表、重建历史、生效配置;底层走 provider 的 `admin_*` 接口,按 `admin_capabilities()` 声明裁剪 UI(script_runner 无法枚举实例即置灰对应列)。
-- **依赖重建(商业版 EE)**:`api/routes/v1/admin_sandbox.py`(`/v1/admin/sandbox/*`)聚合所有技能声明的 pip/apt 依赖(`core/capabilities/skills/deps/aggregator.py`),管理员一键触发沙箱镜像重建:`script-runner` / `opensandbox` 目标走本地 `docker compose build`,`cube` 目标走 SSH 到远端节点重建模板并热切(`core/services/sandbox_rebuild_service.py` + `cube_template_builder`),可查看每次 run 的状态与日志。技能新依赖从此"烤入"镜像,无需手工改 Dockerfile。
+- **只读监控(安全管理台)**:`features/security_console/api/config_security.py` 暴露 `/v1/config/security/sandbox/*`——总览、实例列表、单实例详情、快照列表、重建历史、生效配置;底层走 provider 的 `admin_*` 接口,按 `admin_capabilities()` 声明裁剪 UI(script_runner 无法枚举实例即置灰对应列)。
+- **依赖重建(商业版 EE)**:`features/sandbox_ops/api/admin_sandbox.py`(`/v1/admin/sandbox/*`)聚合所有技能声明的 pip/apt 依赖(`core/capabilities/skills/deps/aggregator.py`),管理员一键触发沙箱镜像重建:`script-runner` / `opensandbox` 目标走本地 `docker compose build`,`cube` 目标走 SSH 到远端节点重建模板并热切(`features/sandbox_ops/rebuild.py` + `cube_template_builder`),可查看每次 run 的状态与日志。技能新依赖从此"烤入"镜像,无需手工改 Dockerfile。
## 关键环境变量
@@ -210,17 +208,17 @@ artifact 送入沙盒、`Bash` 执行后 `/myspace` 写回同步;backend 与 s
| `src/backend/core/sandbox/factory.py` | provider 显式选择;初始化错误直接上报 |
| `src/backend/core/sandbox/providers/script_runner/provider.py` | 轻量沙箱(CE) |
| `src/backend/core/sandbox/providers/opensandbox/provider.py` | OpenSandbox 共享 provider 主体 |
-| `src/backend/core/sandbox/providers/opensandbox/session.py` | 会话/快照/park-restore worker(EE) |
+| `src/backend/core/sandbox/providers/opensandbox/` | 会话/快照/park-restore worker(EE) |
| `src/backend/core/sandbox/providers/opensandbox/execution.py` | 执行路径 + idle reaper(EE) |
-| `src/backend/core/sandbox/providers/opensandbox/internals.py` | volume 构造、metadata、user pool(EE) |
+| `src/backend/core/sandbox/providers/opensandbox/` | volume 构造、metadata、user pool(EE) |
| `src/backend/core/sandbox/providers/opensandbox/pool.py` | 双桶预热池 |
| `src/backend/core/sandbox/providers/cube/provider.py` | Cube 远端 MicroVM provider(EE) |
| `src/backend/core/llm/tools/sandbox_tool.py` | Bash / write_stdin / sandbox_put_artifact / sandbox_get_artifact |
| `src/backend/core/llm/context/offloader.py` | 超长结果落盘 /workspace/.offload |
-| `src/backend/api/routes/v1/admin_sandbox.py` | 依赖重建管理 API(EE) |
-| `src/backend/api/routes/v1/config_security.py` | 安全管理台沙箱只读视图 |
-| `src/backend/core/services/sandbox_rebuild_service.py` | 镜像/模板重建编排(EE) |
-| `docker/Dockerfile.script-runner` / `docker/Dockerfile.opensandbox` / `docker/Dockerfile.cube-sandbox` | 三种沙箱镜像 |
+| `src/backend/features/sandbox_ops/api/admin_sandbox.py` | 依赖重建管理 API(EE) |
+| `src/backend/features/security_console/api/config_security.py` | 安全管理台沙箱只读视图 |
+| `src/backend/features/sandbox_ops/rebuild.py` | 镜像/模板重建编排(EE) |
+| `docker/sandbox/Dockerfile.script-runner` / `docker/sandbox/Dockerfile.opensandbox` / `docker/sandbox/Dockerfile.cube-sandbox` | 三种沙箱镜像 |
相关文档:[技能系统](agent-skills.md) · [MCP 工具系统](mcp-tools.md) · [个人空间](projects-myspace.md) · [版本与许可](../editions/overview.md)
@@ -245,3 +243,24 @@ OpenSandbox 的实例列表与实例计数读取控制器当前运行清单,
控制器查询失败时,计数不可用;不能把查询失败报告成零实例。
连接池诊断保留 scope=worker 与 worker_pid,表示当前后端进程的本地池,
不代表全部进程的用户配额。该修复不把本地池上限改成跨进程全局配额。
+
+## 共享容量与维护所有权
+
+OPENSANDBOX_POOL_MAX_TOTAL 现在由控制器依据 Docker 实例库存统一执行,
+覆盖所有 backend worker、预热实例、会话恢复和计算机实例。各进程池统计仅用于本地诊断。
+控制器在容器创建时持有共享数据卷上的文件锁,不把挂载初始化放入临界区;
+库存达到上限或另一创建正在占用准入锁时返回 429,调用方可稍后重试。
+控制器的 opensandbox_data 数据卷应在同一控制器的进程间共享。
+
+领导进程从共享会话登记发现空闲会话,即使会话由其他 worker 创建或经历过重启,
+也进入快照回收流程。失效登记按身份清理,不阻断其他会话。
+快照任务随领导权停止;GC 只删除本次处理的 snapshot_id 对应记录。
+
+输出读取不再触发挂载准备或对象存储目录写入。目录初始化发生在创建路径,
+执行和文件访问仍核验权限。挂载探测超时或返回不确定错误时保留现有 FUSE helper,
+只有确认缺失或断开时重建。命令观察失败先有限重试,再交给持久恢复;
+没有可恢复引用的进程须确认停止,不能仅因观察失败就丢弃控制句柄。
+
+此机制需要同时更新 backend 和 OpenSandbox 控制器镜像;只更新 backend 无法获得全局容量约束。
+
+技能挂载必须配置 HOST_STORAGE_PATH,指向与后端 STORAGE_PATH 对应的宿主存储目录。缺失配置会明确失败,不再回退到源码目录。重启接管只识别显式归属标签,不根据旧启动命令推断归属。
diff --git a/document/zh-CN/modules/sites.md b/document/zh-CN/modules/sites.md
index 90d351e6..185fb08a 100644
--- a/document/zh-CN/modules/sites.md
+++ b/document/zh-CN/modules/sites.md
@@ -66,13 +66,13 @@
| 环节 | 位置 |
|---|---|
| 发布与查询工具 | `src/backend/extensions/mcp_servers/site_publish_mcp/`(`publish_site` / `list_sites`,由「站点」插件提供) |
-| 内部接口 | `src/backend/api/routes/v1/internal_sites.py`(`/v1/internal/sites/publish`、`/v1/internal/sites/list`) |
-| 站点查询服务 | `src/backend/core/services/site_listing.py`(本机与云端统一记录格式) |
-| 业务服务 | `src/backend/core/services/site_service.py` |
-| 公开托管路由 | `src/backend/api/routes/sites_serve.py`(`GET /site/{slug}/{path}`) |
-| 管理接口 | `src/backend/api/routes/v1/sites.py`(`/v1/sites`) |
-| 访问密码 | `src/backend/core/services/site_password.py`(哈希 + 凭据)、`src/backend/api/routes/site_gate.py`(统一验证页) |
-| 数据表 | `sites`(`core/db/models/site.py`) |
+| 内部接口 | `src/backend/features/sites/api/internal_sites.py`(`/v1/internal/sites/publish`、`/v1/internal/sites/list`) |
+| 站点查询服务 | `src/backend/features/sites/listing.py`(本机与云端统一记录格式) |
+| 业务服务 | `src/backend/features/sites/management.py` |
+| 公开托管路由 | `src/backend/features/sites/api/sites_serve.py`(`GET /site/{slug}/{path}`) |
+| 管理接口 | `src/backend/features/sites/api/sites.py`(`/v1/sites`) |
+| 访问密码 | `src/backend/features/sites/`(哈希 + 凭据)、`src/backend/features/sites/api/site_gate.py`(统一验证页) |
+| 数据表 | `sites`(`features/sites/models.py`) |
| 前端管理面板 | `src/frontend/src/components/sites/SitesPanel.tsx`(实验室 → 站点)、`SitePasswordField.tsx`(密码管理) |
| nginx 转发 | `src/frontend/default.conf.template` 的 `location /site/` |
diff --git a/document/zh-CN/modules/storage.md b/document/zh-CN/modules/storage.md
index 64bfe481..0adcf949 100644
--- a/document/zh-CN/modules/storage.md
+++ b/document/zh-CN/modules/storage.md
@@ -4,7 +4,7 @@ CE 和 EE 的云端 Docker 部署默认使用 S3 兼容存储。桌面离线本
设置 STORAGE_TYPE=s3,并配置 S3_ENDPOINT、S3_BUCKET、S3_REGION、S3_ACCESS_KEY 和 S3_SECRET_KEY。后端和沙盒控制器均须能够访问该服务地址。
-可选的 docker/compose.object-storage.yml 启动独立 S3 服务。它使用独立数据卷,不发布宿主端口,也不共用 Milvus 存储。
+可选的 docker/object-storage/compose.object-storage.yml 启动独立 S3 服务。它使用独立数据卷,不发布宿主端口,也不共用 Milvus 存储。
在新的 CE checkout 中运行 python3 scripts/configure-ce-docker.py。初始化工具生成独立随机访问密钥和秘密密钥,并拒绝覆盖已有 .env。
diff --git a/document/zh-CN/modules/workflow-jobs.md b/document/zh-CN/modules/workflow-jobs.md
index 64a41ab8..53fb7f1b 100644
--- a/document/zh-CN/modules/workflow-jobs.md
+++ b/document/zh-CN/modules/workflow-jobs.md
@@ -23,11 +23,3 @@ run_job 将脚本交给沙箱的受管理进程接口执行。提交时先准备
派生实例采用主智能体的轮数策略,不套用轻量执行器或子智能体的固定轮数上限。完整任务只执行一次;输出格式错误、执行失败或回调结果不确定时,不自动重跑已可能产生副作用的任务。检查已有操作后再显式续跑。
Windows 本机作业使用 `.job/<完整 attempt ID>/`,减少一层目录,避免随包 Python 在深层数据目录导入作业 SDK 时超过传统路径长度限制。每次执行仍保留完整的唯一 attempt ID;续跑和取消使用已保存的实际目录及精确进程引用。
-
-原生桌面端到端验收脚本为 `src/backend/tests/integration/job_agent_local_smoke.py`。使用随包私有 Python 和新建隔离输出目录运行;验收真实 HTTP、作业进程、并发派生主智能体、子智能体、Bash 和文件结果。仅模型决策由本地脚本服务控制,不使用真实账号或数据,不替代安装包和 WebView2 窗口验收。
-
-## 验证
-
-相关单元与回归测试位于 src/backend/tests/orchestration/test_job*.py。显式真实沙箱测试位于 src/backend/tests/integration/job_managed_smoke.py,需指定现有作业作为沙箱上下文。它创建合成作业和临时回调端口,验证并发台账、线程退出、精确取消、跨进程接管、续跑和启动失败,不调用模型,不重新执行原始业务脚本。
-
-部署前须确认容器真正挂载的源码目录。工作区改动与运行容器的源码可能来自不同 checkout;仅重启错误挂载的容器不能使修改生效。
diff --git a/examples/customer-chat/.env.example b/examples/customer-chat/.env.example
deleted file mode 100644
index 0c63ba12..00000000
--- a/examples/customer-chat/.env.example
+++ /dev/null
@@ -1,6 +0,0 @@
-# Optional. Leave empty for offline host preview.
-# Backend must be an origin. Do not append /api.
-HUGAGENT_BACKEND_URL=http://127.0.0.1:3001
-HUGAGENT_DEV_PORT=4310
-# Change only when the backend uses another session cookie name.
-HUGAGENT_SESSION_COOKIE=jx_session
diff --git a/examples/customer-chat/.gitignore b/examples/customer-chat/.gitignore
deleted file mode 100644
index dbec1f16..00000000
--- a/examples/customer-chat/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-node_modules/
-dist/
-standalone-dist/
-*.tgz
-web-dist/
-.env
-.env.local
-vendor/
diff --git a/examples/customer-chat/README.md b/examples/customer-chat/README.md
deleted file mode 100644
index 058c830a..00000000
--- a/examples/customer-chat/README.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Customer chat example
-
-Two independent consumers are included: a scoped host UI package and a standalone browser application.
-Both import only the published SDK. The current file dependency is a monorepo convenience; replace it with a pinned SDK tarball/version when copying this directory to a customer repository.
-
-```sh
-npm install /path/to/hugagent-chat-sdk-1.0.0.tgz
-npm run build
-npm pack
-```
-
-Install the resulting customer package in src/frontend and build the platform with HUGAGENT_CHAT_CUSTOMIZATION=hugagent-customer-chat-example.
-For this repository's source example:
-
-```sh
-cd src/frontend
-HUGAGENT_CHAT_CUSTOMIZATION=../../examples/customer-chat/src/customization.tsx npm run build
-```
-
-Unset that variable to build the official default interface.
-A customer package must use the same SDK major and React major as the host. The host deduplicates React and the SDK.
-
-The example wraps default welcome/composer/message components. Customer source, CSS and lockfile live independently.
-For a replacement component, use useChatRuntime for draft, messages, attachments and actions.
-Preserve children for rich platform controls that the replacement does not implement.
-
-`npm run standalone` produces standalone-dist, served at /customer/ by the acceptance environment.
-Set VITE_CUSTOMER_API_BASE (default /api) and VITE_CUSTOMER_LOGIN_URL (default /login?redirect=/session.html) when building for your platform.
-The default login link opens the platform's normal login page. It does not depend on mock SSO.
-Use a same-origin API gateway and an authenticated platform session. Do not embed shared API keys in a public build.
-The standalone example renders plain-text replies, paginated history, attachment upload, cancellation, replay, and tool/question gates.
-It intentionally does not implement the entire platform's rich artifact, citation, plan and ontology UI; use default host children for those features.
-
-Customer UI images and bundles must be deployed separately from backend images. Pin both versions and test the desired combination before upgrade.
-The Docker example's customer source is inside the build context; external customer repositories require their own trusted build-stage COPY/install steps.
-
-For the complete no-source developer kit, run node scripts/export-customer-devkit.mjs from the repository root. See document/zh-CN/development/customer-devkit.md. The kit provides npm ci, npm run dev, offline slot preview, backend login, fixed API proxy, API debug and npm run test:package. Component builds bundle imported helpers, CSS and assets.
diff --git a/examples/customer-chat/debug.html b/examples/customer-chat/debug.html
deleted file mode 100644
index 04157231..00000000
--- a/examples/customer-chat/debug.html
+++ /dev/null
@@ -1 +0,0 @@
-API debug Host preview · Live chat · API debug API debug Sign in to backend Sign out Checking session
Requests use the fixed backend configured by your developer. Open browser Network tools to inspect SSE and uploads. No request body or cookies are logged by this kit.
GET endpoint/v1/me /v1/chats?page=1&page_size=10 /ready Send GET
\ No newline at end of file
diff --git a/examples/customer-chat/index.html b/examples/customer-chat/index.html
deleted file mode 100644
index db66376c..00000000
--- a/examples/customer-chat/index.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
-Independent Customer Chat
-Host preview · Live chat · API debug Independent Customer Chat Sign in
-Ready
-
-
-Cancel run Resume
-New chat Attach
-
diff --git a/examples/customer-chat/package-lock.json b/examples/customer-chat/package-lock.json
deleted file mode 100644
index 4735d4f8..00000000
--- a/examples/customer-chat/package-lock.json
+++ /dev/null
@@ -1,1224 +0,0 @@
-{
- "name": "hugagent-customer-chat-example",
- "version": "1.0.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "hugagent-customer-chat-example",
- "version": "1.0.0",
- "dependencies": {
- "react": "^19.2.0",
- "react-dom": "^19.2.0"
- },
- "devDependencies": {
- "@hugagent/chat-sdk": "file:../../packages/chat-sdk",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.0",
- "typescript": "~5.9.3",
- "vite": "^7.3.1"
- },
- "peerDependencies": {
- "@hugagent/chat-sdk": "^1.0.0"
- }
- },
- "../../packages/chat-sdk": {
- "name": "@hugagent/chat-sdk",
- "version": "1.0.0",
- "dev": true,
- "license": "UNLICENSED",
- "devDependencies": {
- "@types/react": "^19.2.7",
- "react": "^19.2.0",
- "typescript": "~5.9.3"
- },
- "peerDependencies": {
- "react": ">=19 <20"
- },
- "peerDependenciesMeta": {
- "react": {
- "optional": true
- }
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
- "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
- "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
- "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
- "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
- "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
- "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
- "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
- "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
- "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
- "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
- "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
- "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
- "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
- "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
- "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
- "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
- "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
- "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
- "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
- "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
- "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
- "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
- "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
- "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
- "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
- "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@hugagent/chat-sdk": {
- "resolved": "../../packages/chat-sdk",
- "link": true
- },
- "node_modules/@napi-rs/lzma-linux-x64-gnu": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
- "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^22.20 || ^24.12 || >=25"
- }
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.7.tgz",
- "integrity": "sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.7.tgz",
- "integrity": "sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.7.tgz",
- "integrity": "sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.7.tgz",
- "integrity": "sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.7.tgz",
- "integrity": "sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.7.tgz",
- "integrity": "sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.7.tgz",
- "integrity": "sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.7.tgz",
- "integrity": "sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.7.tgz",
- "integrity": "sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.7.tgz",
- "integrity": "sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.7.tgz",
- "integrity": "sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.7.tgz",
- "integrity": "sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.7.tgz",
- "integrity": "sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.7.tgz",
- "integrity": "sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.7.tgz",
- "integrity": "sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.7.tgz",
- "integrity": "sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.7.tgz",
- "integrity": "sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.7.tgz",
- "integrity": "sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.7.tgz",
- "integrity": "sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.7.tgz",
- "integrity": "sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.7.tgz",
- "integrity": "sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.7.tgz",
- "integrity": "sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.7.tgz",
- "integrity": "sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.7.tgz",
- "integrity": "sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.7.tgz",
- "integrity": "sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/react": {
- "version": "19.3.0",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
- "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "19.3.0",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
- "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^19.3.0"
- }
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/esbuild": {
- "version": "0.28.2",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
- "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.28.2",
- "@esbuild/android-arm": "0.28.2",
- "@esbuild/android-arm64": "0.28.2",
- "@esbuild/android-x64": "0.28.2",
- "@esbuild/darwin-arm64": "0.28.2",
- "@esbuild/darwin-x64": "0.28.2",
- "@esbuild/freebsd-arm64": "0.28.2",
- "@esbuild/freebsd-x64": "0.28.2",
- "@esbuild/linux-arm": "0.28.2",
- "@esbuild/linux-arm64": "0.28.2",
- "@esbuild/linux-ia32": "0.28.2",
- "@esbuild/linux-loong64": "0.28.2",
- "@esbuild/linux-mips64el": "0.28.2",
- "@esbuild/linux-ppc64": "0.28.2",
- "@esbuild/linux-riscv64": "0.28.2",
- "@esbuild/linux-s390x": "0.28.2",
- "@esbuild/linux-x64": "0.28.2",
- "@esbuild/netbsd-arm64": "0.28.2",
- "@esbuild/netbsd-x64": "0.28.2",
- "@esbuild/openbsd-arm64": "0.28.2",
- "@esbuild/openbsd-x64": "0.28.2",
- "@esbuild/openharmony-arm64": "0.28.2",
- "@esbuild/sunos-x64": "0.28.2",
- "@esbuild/win32-arm64": "0.28.2",
- "@esbuild/win32-ia32": "0.28.2",
- "@esbuild/win32-x64": "0.28.2"
- }
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/nanoid": {
- "version": "3.3.20",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
- "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
- "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.30",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
- "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.20",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.2"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/react": {
- "version": "19.3.1",
- "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
- "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "19.3.1",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
- "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
- "license": "MIT",
- "dependencies": {
- "scheduler": "^0.28.0"
- },
- "peerDependencies": {
- "react": "^19.3.1"
- }
- },
- "node_modules/rollup": {
- "version": "4.64.7",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.7.tgz",
- "integrity": "sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.9"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
- "@rollup/rollup-android-arm-eabi": "4.64.7",
- "@rollup/rollup-android-arm64": "4.64.7",
- "@rollup/rollup-darwin-arm64": "4.64.7",
- "@rollup/rollup-darwin-x64": "4.64.7",
- "@rollup/rollup-freebsd-arm64": "4.64.7",
- "@rollup/rollup-freebsd-x64": "4.64.7",
- "@rollup/rollup-linux-arm-gnueabihf": "4.64.7",
- "@rollup/rollup-linux-arm-musleabihf": "4.64.7",
- "@rollup/rollup-linux-arm64-gnu": "4.64.7",
- "@rollup/rollup-linux-arm64-musl": "4.64.7",
- "@rollup/rollup-linux-loong64-gnu": "4.64.7",
- "@rollup/rollup-linux-loong64-musl": "4.64.7",
- "@rollup/rollup-linux-ppc64-gnu": "4.64.7",
- "@rollup/rollup-linux-ppc64-musl": "4.64.7",
- "@rollup/rollup-linux-riscv64-gnu": "4.64.7",
- "@rollup/rollup-linux-riscv64-musl": "4.64.7",
- "@rollup/rollup-linux-s390x-gnu": "4.64.7",
- "@rollup/rollup-linux-x64-gnu": "4.64.7",
- "@rollup/rollup-linux-x64-musl": "4.64.7",
- "@rollup/rollup-openbsd-x64": "4.64.7",
- "@rollup/rollup-openharmony-arm64": "4.64.7",
- "@rollup/rollup-win32-arm64-msvc": "4.64.7",
- "@rollup/rollup-win32-ia32-msvc": "4.64.7",
- "@rollup/rollup-win32-x64-gnu": "4.64.7",
- "@rollup/rollup-win32-x64-msvc": "4.64.7",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/scheduler": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
- "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
- "license": "MIT"
- },
- "node_modules/source-map-js": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
- "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/vite": {
- "version": "7.3.7",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz",
- "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "esbuild": "^0.27.0 || ^0.28.0",
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.6",
- "rollup": "^4.43.0",
- "tinyglobby": "^0.2.15"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "lightningcss": "^1.21.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- }
- }
-}
diff --git a/examples/customer-chat/package.json b/examples/customer-chat/package.json
deleted file mode 100644
index 0838c053..00000000
--- a/examples/customer-chat/package.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "name": "hugagent-customer-chat-example",
- "version": "1.0.0",
- "private": true,
- "type": "module",
- "scripts": {
- "build": "tsc --noEmit && node scripts/build-component.mjs && tsc --emitDeclarationOnly",
- "standalone": "vite build --base /customer/ --outDir standalone-dist",
- "dev": "vite --open /preview.html",
- "dev:headless": "vite",
- "build:web": "vite build",
- "preview:web": "vite preview",
- "prepack": "npm run build",
- "test": "node scripts/test-proxy.mjs",
- "test:package": "node scripts/test-package.mjs"
- },
- "dependencies": {
- "react": "^19.2.0",
- "react-dom": "^19.2.0"
- },
- "devDependencies": {
- "@types/react": "^19.2.7",
- "typescript": "~5.9.3",
- "vite": "^7.3.1",
- "@types/react-dom": "^19.2.0",
- "@hugagent/chat-sdk": "file:../../packages/chat-sdk"
- },
- "exports": {
- ".": {
- "types": "./dist/customization.d.ts",
- "import": "./dist/customization.js"
- }
- },
- "files": [
- "dist",
- "README.md"
- ],
- "peerDependencies": {
- "@hugagent/chat-sdk": "^1.0.0"
- }
-}
diff --git a/examples/customer-chat/preview.html b/examples/customer-chat/preview.html
deleted file mode 100644
index dea4dac0..00000000
--- a/examples/customer-chat/preview.html
+++ /dev/null
@@ -1 +0,0 @@
-Customer host preview Host preview · Live chat · API debug Customer host preview This offline host tests the public slot contract. Use Live chat for backend tests.
\ No newline at end of file
diff --git a/examples/customer-chat/scripts/build-component.mjs b/examples/customer-chat/scripts/build-component.mjs
deleted file mode 100644
index 8e93c500..00000000
--- a/examples/customer-chat/scripts/build-component.mjs
+++ /dev/null
@@ -1,14 +0,0 @@
-import { readFileSync, writeFileSync, existsSync } from 'node:fs';
-import { build } from 'vite';
-await build({
- configFile: false,
- build: {
- outDir: 'dist', emptyOutDir: true, sourcemap: false,
- lib: { entry: 'src/customization.tsx', formats: ['es'], fileName: 'customization', cssFileName: 'theme' },
- rollupOptions: { external: id => id === 'react' || id.startsWith('react/') ||
- id === 'react-dom' || id.startsWith('react-dom/') || id === '@hugagent/chat-sdk' || id.startsWith('@hugagent/chat-sdk/') },
- },
-});
-
-// Vite extracts library CSS. Keep the package entry self-contained for the host.
-if (existsSync('dist/theme.css')) writeFileSync('dist/customization.js', "import './theme.css';\n" + readFileSync('dist/customization.js', 'utf8'));
diff --git a/examples/customer-chat/scripts/developer-proxy.mjs b/examples/customer-chat/scripts/developer-proxy.mjs
deleted file mode 100644
index a4ced863..00000000
--- a/examples/customer-chat/scripts/developer-proxy.mjs
+++ /dev/null
@@ -1,43 +0,0 @@
-import { createHash } from 'node:crypto';
-
-/** A fixed upstream gateway; no client-supplied destination is accepted. */
-export function createDeveloperProxy(env) {
- const raw = env.HUGAGENT_BACKEND_URL;
- if (!raw) return {};
- const target = new URL(raw);
- if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password ||
- target.search || target.hash || target.pathname !== '/') {
- throw new Error('HUGAGENT_BACKEND_URL must be an HTTP(S) origin, without a path, credentials, query or fragment.');
- }
- const upstreamCookie = env.HUGAGENT_SESSION_COOKIE || 'jx_session';
- if (!/^[A-Za-z0-9_-]+$/.test(upstreamCookie)) throw new Error('Invalid session cookie name');
- const localCookie = 'hugagent_dev_' + createHash('sha256').update(target.origin).digest('hex').slice(0, 16);
- const configure = proxy => {
- proxy.on('proxyReq', (outgoing, incoming) => {
- // Cookies are scoped to this upstream; never forward unrelated localhost cookies.
- const session = (incoming.headers.cookie || '').split(';').map(v => v.trim()).find(v => v.startsWith(localCookie + '='));
- outgoing.removeHeader('cookie');
- outgoing.removeHeader('authorization');
- if (session) outgoing.setHeader('cookie', upstreamCookie + '=' + session.slice(localCookie.length + 1));
- outgoing.setHeader('x-forwarded-host', incoming.headers.host || 'localhost');
- outgoing.setHeader('x-forwarded-proto', 'http');
- });
- proxy.on('proxyRes', response => {
- // Keep Secure, HttpOnly, SameSite and expiry. Only rewrite the development cookie name/domain.
- response.headers['set-cookie'] = (response.headers['set-cookie'] || [])
- .filter(cookie => cookie.startsWith(upstreamCookie + '='))
- .map(cookie => {
- const rewritten = cookie.replace(upstreamCookie + '=', localCookie + '=').replace(/;\s*Domain=[^;]*/gi, '');
- return /;\s*HttpOnly(?:;|$)/i.test(rewritten) ? rewritten : rewritten + '; HttpOnly';
- });
- delete response.headers['access-control-allow-origin'];
- delete response.headers['access-control-allow-credentials'];
- });
- };
- const options = { target: target.origin, changeOrigin: true, secure: true, xfwd: false, configure,
- proxyTimeout: 180000, timeout: 180000 };
- return {
- '^/api(?:/|$)': { ...options, rewrite: path => path.replace(/^\/api(?=\/|$)/, '') || '/' },
- '^/login(?:[?]|$)': options,
- };
-}
diff --git a/examples/customer-chat/scripts/test-package.mjs b/examples/customer-chat/scripts/test-package.mjs
deleted file mode 100644
index 1bc62ada..00000000
--- a/examples/customer-chat/scripts/test-package.mjs
+++ /dev/null
@@ -1,30 +0,0 @@
-import { execFileSync } from 'node:child_process';
-import { mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join, resolve } from 'node:path';
-import assert from 'node:assert/strict';
-const pkg = JSON.parse(readFileSync('package.json'));
-const sdkFile = pkg.devDependencies['@hugagent/chat-sdk'].replace(/^file:/, '');
-if (!sdkFile.endsWith('.tgz')) throw new Error('Run this check in the exported developer kit');
-const component = resolve(pkg.name.replaceAll('@', '').replaceAll('/', '-') + '-' + pkg.version + '.tgz');
-if (!existsSync(component)) throw new Error('Run npm pack first');
-const host = mkdtempSync(join(tmpdir(), 'hugagent-customer-host-'));
-const react = JSON.parse(readFileSync('node_modules/react/package.json')).version;
-const vite = JSON.parse(readFileSync('node_modules/vite/package.json')).version;
-writeFileSync(join(host, 'package.json'), JSON.stringify({ private: true, type: 'module', dependencies: {
- '@hugagent/chat-sdk': 'file:' + resolve(sdkFile), [pkg.name]: 'file:' + component, react, vite,
-} }, null, 2));
-execFileSync('npm', ['install', '--prefer-offline', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: host, stdio: 'pipe' });
-const installed = JSON.parse(readFileSync(join(host, 'node_modules', pkg.name, 'package.json')));
-assert.equal(installed.dependencies?.['@hugagent/chat-sdk'], undefined);
-assert.ok(installed.peerDependencies['@hugagent/chat-sdk']);
-writeFileSync(join(host, 'entry.js'), "export { default } from " + JSON.stringify(pkg.name) + ";");
-writeFileSync(join(host, 'build.mjs'), "import{build}from'vite';await build({configFile:false,logLevel:'error',build:{lib:{entry:'entry.js',formats:['es'],fileName:'customer',cssFileName:'customer'}}});");
-execFileSync(process.execPath, ['build.mjs'], { cwd: host, stdio: 'pipe' });
-assert.ok(readFileSync(join(host, 'dist/customer.js'), 'utf8').includes('Customer Portal'));
-assert.ok(existsSync(join(host, 'dist/customer.css')));
-if (process.env.HUGAGENT_EXPECT_HELPER) {
- assert.ok(readFileSync(join(host, 'dist/customer.js'), 'utf8').includes('Customer helper included'));
- assert.ok(readFileSync(join(host, 'dist/customer.css'), 'utf8').includes('customer-extra-badge'));
-}
-console.log('PASS customer tarball installs and builds in a fresh host: ' + host);
diff --git a/examples/customer-chat/scripts/test-proxy.mjs b/examples/customer-chat/scripts/test-proxy.mjs
deleted file mode 100644
index 54e2a9f1..00000000
--- a/examples/customer-chat/scripts/test-proxy.mjs
+++ /dev/null
@@ -1,26 +0,0 @@
-import assert from 'node:assert/strict';
-import { EventEmitter } from 'node:events';
-import { createDeveloperProxy } from './developer-proxy.mjs';
-for (const url of ['file:///tmp', 'http://user:password@host', 'https://host/api', 'https://host/?target=other']) {
- assert.throws(() => createDeveloperProxy({ HUGAGENT_BACKEND_URL: url }));
-}
-assert.deepEqual(createDeveloperProxy({}), {});
-const a = createDeveloperProxy({ HUGAGENT_BACKEND_URL: 'https://backend.example' });
-const b = createDeveloperProxy({ HUGAGENT_BACKEND_URL: 'https://other.example' });
-function cookie(proxyConfig) {
- const proxy = new EventEmitter(); proxyConfig['^/api(?:/|$)'].configure(proxy);
- const response = { headers: { 'set-cookie': ['jx_session=secret; Domain=backend.example; Secure; SameSite=Lax', 'unrelated=deny'] } };
- proxy.emit('proxyRes', response);
- const cookies = response.headers['set-cookie'];
- assert.equal(cookies.length, 1); assert.ok(!cookies[0].includes('Domain='));
- assert.ok(cookies[0].includes('; HttpOnly')); assert.ok(cookies[0].includes('Secure; SameSite=Lax'));
- return { proxy, value: cookies[0].split(';')[0] };
-}
-const first = cookie(a), second = cookie(b); assert.notEqual(first.value, second.value);
-const headers = new Map([['authorization', 'secret'], ['cookie', 'unrelated=x']]);
-const request = { removeHeader: name => headers.delete(name), setHeader: (name,value) => headers.set(name,value) };
-first.proxy.emit('proxyReq', request, { headers: { host: 'localhost:4310', cookie: 'unrelated=x; ' + second.value } });
-assert.equal(headers.has('cookie'), false); assert.equal(headers.has('authorization'), false);
-first.proxy.emit('proxyReq', request, { headers: { host: 'localhost:4310', cookie: 'unrelated=x; ' + first.value } });
-assert.equal(headers.get('cookie'), 'jx_session=secret');
-console.log('PASS fixed target validation, Secure/HttpOnly retention, cross-target cookie isolation and authorization filtering');
diff --git a/examples/customer-chat/session.html b/examples/customer-chat/session.html
deleted file mode 100644
index d62e4fb2..00000000
--- a/examples/customer-chat/session.html
+++ /dev/null
@@ -1 +0,0 @@
-Session callback Completing login
Retry login
\ No newline at end of file
diff --git a/examples/customer-chat/src/controls.ts b/examples/customer-chat/src/controls.ts
deleted file mode 100644
index c2ab7600..00000000
--- a/examples/customer-chat/src/controls.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { ChatClient, type ChatEvent, type QuestionAnswer } from '@hugagent/chat-sdk';
-
-/** Deliberately uses DOM textContent: server/model text must never become executable HTML. */
-export class Controls {
- private readonly pending = new Map();
- constructor(private readonly client: ChatClient, private readonly root: HTMLElement,
- private readonly chat: () => string, private readonly error: (message: string) => void) {}
- clear() { this.pending.clear(); this.root.replaceChildren(); }
- private remove(id: string) { this.pending.get(id)?.remove(); this.pending.delete(id); }
- private panel(id: string, title: string) {
- if (this.pending.has(id)) return null;
- const panel = document.createElement('section'); panel.dataset.request = id;
- const heading = document.createElement('p'); heading.textContent = title; panel.append(heading);
- this.pending.set(id, panel); this.root.append(panel); return panel;
- }
- private button(panel: HTMLElement, label: string, action: () => Promise) {
- const button = document.createElement('button'); button.type = 'button'; button.textContent = label;
- button.addEventListener('click', () => {
- const buttons = [...panel.querySelectorAll('button')]; buttons.forEach(item => { item.disabled = true; });
- void action().catch(error => {
- this.error(String(error)); buttons.forEach(item => { item.disabled = false; });
- });
- }); panel.append(button);
- }
- consume(event: ChatEvent) {
- if (event.type === 'file_confirm' || event.type === 'design_pick') {
- const id = String(event.confirm_id || ''); if (!id) return;
- if (event.expired || event.resolved) return this.remove(id);
- const panel = this.panel(id, String(event.message || event.question || event.logical_path || event.op || 'Confirm operation'));
- if (!panel) return;
- const decide = async (decision: 'allow' | 'deny' | 'choice' | 'skip', optionId?: string) => {
- const result = await this.client.confirmFile(this.chat(), id, decision, optionId);
- if (!result.ok && !result.stale) throw new Error(result.message || 'Decision was not accepted');
- this.remove(id);
- if (result.stale) this.error(result.message || 'This confirmation has expired');
- };
- if (event.type === 'design_pick') {
- for (const option of Array.isArray(event.options) ? event.options as Record[] : []) {
- this.button(panel, String(option.title || option.id), () => decide('choice', String(option.id)));
- }
- this.button(panel, 'Skip', () => decide('skip'));
- } else {
- this.button(panel, 'Allow', () => decide('allow')); this.button(panel, 'Deny', () => decide('deny'));
- }
- }
- if (event.type === 'user_question_resolved') this.remove(String(event.request_id || ''));
- if (event.type !== 'user_question') return;
- const id = String(event.request_id || ''); if (!id) return;
- const panel = this.panel(id, 'Assistant needs your answer'); if (!panel) return;
- const answers: (() => QuestionAnswer)[] = [];
- for (const question of Array.isArray(event.questions) ? event.questions as Record[] : []) {
- const fieldset = document.createElement('fieldset'), legend = document.createElement('legend');
- legend.textContent = String(question.question || ''); fieldset.append(legend);
- const selections: HTMLInputElement[] = [];
- for (const option of Array.isArray(question.options) ? question.options as Record[] : []) {
- const label = document.createElement('label'), input = document.createElement('input');
- input.type = question.multi_select ? 'checkbox' : 'radio'; input.name = id + ':' + String(question.id);
- input.value = String(option.id); label.append(input, document.createTextNode(String(option.label))); fieldset.append(label); selections.push(input);
- }
- const custom = document.createElement('input'); custom.setAttribute('aria-label', 'Custom answer'); fieldset.append(custom);
- panel.append(fieldset);
- answers.push(() => ({ id: String(question.id), selected: selections.filter(item => item.checked).map(item => item.value), custom: custom.value }));
- }
- this.button(panel, 'Submit answers', async () => {
- const result = await this.client.answerQuestion(this.chat(), id, answers.map(read => read()));
- if (!result.ok && !result.stale) throw new Error(result.message || 'Answer was not accepted');
- this.remove(id); // A successful POST atomically claims and ends the pending request.
- if (result.stale) this.error(result.message || 'This question has expired');
- });
- }
- async recover() {
- const [confirms, questions] = await Promise.all([this.client.pendingConfirms(this.chat()), this.client.pendingQuestions(this.chat())]);
- for (const event of confirms.pendings || []) this.consume({ ...event, type: event.kind === 'design_pick' ? 'design_pick' : 'file_confirm' });
- for (const event of questions.requests || []) this.consume({ ...event, type: 'user_question' });
- }
-}
diff --git a/examples/customer-chat/src/css.d.ts b/examples/customer-chat/src/css.d.ts
deleted file mode 100644
index 35306c6f..00000000
--- a/examples/customer-chat/src/css.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-declare module '*.css';
diff --git a/examples/customer-chat/src/customization.tsx b/examples/customer-chat/src/customization.tsx
deleted file mode 100644
index 3be05a17..00000000
--- a/examples/customer-chat/src/customization.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { defineChatCustomization, useChatRuntime, type ChatSlotProps, type MessageSlotProps } from '@hugagent/chat-sdk/react';
-import './theme.css';
-
-function Welcome({ children }: ChatSlotProps) {
- const { view, actions } = useChatRuntime();
- return
- Customer Portal {view.productName}
- actions.setDraft('Please summarize this document.')}>Use a template
-
- {children}
- ;
-}
-function Message({ message, children }: MessageSlotProps) {
- return
- {message.role === 'assistant' ? 'Portal Assistant' : 'You'}
- {children}
-
;
-}
-function Composer({ children }: ChatSlotProps) {
- const { view } = useChatRuntime();
- return
- {view.conversation.sending ? 'Working' : 'Ready'}
- {children}
- ;
-}
-export default defineChatCustomization({
- apiVersion: 1, id: 'customer-portal',
- slots: { welcome: Welcome, message: Message, composer: Composer },
-});
diff --git a/examples/customer-chat/src/debug.ts b/examples/customer-chat/src/debug.ts
deleted file mode 100644
index a1cb48b2..00000000
--- a/examples/customer-chat/src/debug.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import './login.js';
-import './developer.css';
-const status = document.getElementById('session')!;
-const result = document.getElementById('result')!;
-async function session() {
- try {
- const response = await fetch('/api/v1/me', { credentials: 'same-origin' });
- if (response.status === 401) { status.textContent = 'Signed out'; return; }
- const body = await response.json();
- status.textContent = response.ok && body.code >= 10000 && body.code < 20000 && body.data?.user_id
- ? 'Signed in' : 'Backend error: ' + response.status;
- } catch { status.textContent = 'Backend unavailable. Configure HUGAGENT_BACKEND_URL and restart npm run dev.'; }
-}
-document.getElementById('request')!.addEventListener('click', () => {
- void (async () => {
- try {
- const path = (document.getElementById('endpoint') as HTMLSelectElement).value;
- const response = await fetch('/api' + path, { credentials: 'same-origin' });
- result.textContent = 'HTTP ' + response.status + '\n' + await response.text();
- } catch (error) { result.textContent = String(error); }
- })();
-});
-document.getElementById('logout')!.addEventListener('click', () => {
- void (async () => {
- try {
- const response = await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'same-origin' });
- if (!response.ok) throw new Error('Logout failed: ' + response.status);
- await session();
- } catch (error) { status.textContent = String(error); }
- })();
-});
-void session();
diff --git a/examples/customer-chat/src/developer.css b/examples/customer-chat/src/developer.css
deleted file mode 100644
index afec40f6..00000000
--- a/examples/customer-chat/src/developer.css
+++ /dev/null
@@ -1,3 +0,0 @@
-body{font-family:system-ui,sans-serif;max-width:1100px;margin:24px auto;padding:16px;color:#172b4d;background:#f5f8fc}nav{display:flex;gap:12px;flex-wrap:wrap}button,input,textarea,select{font:inherit;margin:6px;padding:8px}textarea{display:block;width:90%;min-height:90px}button{cursor:pointer}button:disabled{cursor:default}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:white;padding:16px}.preview-layout{display:grid;grid-template-columns:220px minmax(0,1fr);gap:24px}aside button{display:block}article,section.customer-composer{padding:12px;background:white;border-radius:10px;margin:10px 0}@media(max-width:650px){.preview-layout{grid-template-columns:1fr}body{margin:0}}
-
-*{box-sizing:border-box}[data-chat-customization] .preview-layout .customer-welcome{height:auto}[data-chat-customization]{--color-primary:#1554ad;--color-text:#172b4d;--color-text-secondary:#526581;--color-bg-container:white;--color-border:#c9d5e6;--radius-sm:6px}.preview-layout>section{min-width:0}
diff --git a/examples/customer-chat/src/login.ts b/examples/customer-chat/src/login.ts
deleted file mode 100644
index 5f03566d..00000000
--- a/examples/customer-chat/src/login.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-// Bind a login callback to the tab that started it; this value is not a session credential.
-for (const link of document.querySelectorAll('a[data-login], a#login')) {
- link.addEventListener('click', event => {
- event.preventDefault();
- const state = crypto.randomUUID();
- sessionStorage.setItem('hugagent-login', JSON.stringify({ state, started: Date.now() }));
- const redirect = '/session.html?state=' + encodeURIComponent(state);
- location.assign('/login?redirect=' + encodeURIComponent(redirect));
- });
-}
-export {};
diff --git a/examples/customer-chat/src/preview.tsx b/examples/customer-chat/src/preview.tsx
deleted file mode 100644
index 5f946843..00000000
--- a/examples/customer-chat/src/preview.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createRoot } from 'react-dom/client';
-import { PreviewHost } from '@hugagent/chat-sdk/preview';
-import customization from './customization.js';
-import './developer.css';
-createRoot(document.getElementById('root')!).render( );
diff --git a/examples/customer-chat/src/session.ts b/examples/customer-chat/src/session.ts
deleted file mode 100644
index 0df0bc11..00000000
--- a/examples/customer-chat/src/session.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-// Remove one-time credentials before any API call or further navigation.
-const url = new URL(location.href);
-const state = url.searchParams.get('state');
-const started = sessionStorage.getItem('hugagent-login');
-sessionStorage.removeItem('hugagent-login');
-const code = url.searchParams.get('code') || url.searchParams.get('ticket');
-history.replaceState(null, '', '/session.html');
-void (async () => {
- try {
- const expected = started ? JSON.parse(started) : null;
- if (!state || expected?.state !== state || Date.now() - expected.started > 300000) throw new Error('Login state missing or expired. Start login from API debug.');
- if (!code) throw new Error('No login credential. Use the login link.');
- const response = await fetch('/api/v1/auth/ticket/exchange', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
- const body = await response.json();
- if (!response.ok || body.code < 10000 || body.code >= 20000) throw new Error(body.message || 'Login exchange failed');
- location.replace('/debug.html');
- } catch (error) { document.getElementById('session')!.textContent = String(error); }
-})();
-export {};
diff --git a/examples/customer-chat/src/standalone.css b/examples/customer-chat/src/standalone.css
deleted file mode 100644
index f61c6e4d..00000000
--- a/examples/customer-chat/src/standalone.css
+++ /dev/null
@@ -1,6 +0,0 @@
-/* Independent consumer styles; no host selectors or assets. */
-:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
-main { max-width: 840px; margin: 32px auto; padding: 16px; }
-textarea { display: block; width: 100%; min-height: 100px; box-sizing: border-box; }
-button { padding: 8px 16px; margin: 8px 8px 8px 0; }
-[data-role="assistant"] { border-inline-start: 3px solid currentColor; padding-inline-start: 16px; white-space: pre-wrap; }
diff --git a/examples/customer-chat/src/standalone.ts b/examples/customer-chat/src/standalone.ts
deleted file mode 100644
index 346e522c..00000000
--- a/examples/customer-chat/src/standalone.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import './login.js';
-import { Controls } from './controls.js';
-import { ChatClient, type ChatEvent, type ChatHistoryMessage } from '@hugagent/chat-sdk';
-import './standalone.css';
-
-// Serve this independent application behind the platform's same-origin API gateway.
-const client = new ChatClient({ baseUrl: import.meta.env.VITE_CUSTOMER_API_BASE || '/api' });
-const element = (id: string) => document.getElementById(id) as T;
-const status = element('status');
-element('login').href = import.meta.env.VITE_CUSTOMER_LOGIN_URL || '/login?redirect=/session.html';
-const input = element('message');
-const send = element('send');
-const cancel = element('cancel');
-const resume = element('resume');
-const messages = element('messages');
-const controls = new Controls(client, element('controls'), () => chatId, text => { status.textContent = text; });
-let chatId = new URL(location.href).searchParams.get('chat') || '';
-let runId = '', offset = 0, assistant: HTMLElement | null = null;
-let running = false;
-let attachment: { file_id: string; name: string; mime_type: string; download_url: string } | null = null;
-let controller: AbortController | undefined;
-function row(role: string, content: string) {
- const item = document.createElement('p'); item.dataset.role = role; item.textContent = content;
- messages.append(item); return item;
-}
-function busy(value: boolean) {
- running = value; send.disabled = value; cancel.disabled = !value || !runId;
- element('new').disabled = value;
- element('file').disabled = value;
- resume.disabled = value || !runId;
-}
-function rememberChat(id: string) {
- chatId = id; const url = new URL(location.href); url.searchParams.set('chat', id); history.replaceState(null, '', url);
-}
-async function historyMessages() {
- if (!chatId) return;
- const historyChatId = chatId;
- const history = { items: [] as ChatHistoryMessage[] };
- for (let page = 1; page <= 10000; page++) {
- const batch = await client.messages(historyChatId, page, 100);
- history.items.push(...batch.items);
- if (batch.items.length < 100) break;
- if (page === 10000) throw new Error('History limit reached');
- }
- if (chatId !== historyChatId) return;
- messages.replaceChildren();
- [...history.items].sort((a: ChatHistoryMessage, b: ChatHistoryMessage) => (a.chat_seq || 0) - (b.chat_seq || 0))
- .forEach(item => row(item.role, item.content));
-}
-function consume(event: ChatEvent) {
- controls.consume(event);
- if (event.type === 'run_snapshot' && event.state && typeof event.state === 'object') {
- const snapshot = event.state as Record;
- const blocks = Array.isArray(snapshot.blocks) ? snapshot.blocks as Record[] : [];
- let content = '';
- for (const block of blocks) {
- if (block.kind === 'answer') content = String(block.content || '');
- else if (block.kind === 'text') content += String(block.content || '');
- }
- assistant ??= messages.lastElementChild?.getAttribute('data-role') === 'assistant'
- ? messages.lastElementChild as HTMLElement : row('assistant', '');
- assistant.textContent = content;
- const signals = snapshot.signals && typeof snapshot.signals === 'object' ? Object.values(snapshot.signals) : [];
- for (const signal of signals) controls.consume(signal as ChatEvent);
- }
- if (event.type === 'run_started') { runId = String(event.run_id); cancel.disabled = false; }
- if (typeof event.event_offset === 'number') {
- if (event.event_offset <= offset) return;
- offset = event.event_offset;
- }
- if (event.type === 'content' || event.type === 'ai_message') {
- assistant ??= row('assistant', '');
- assistant.textContent += String(event.delta || event.content || '');
- }
- if (event.type === 'content_replace') {
- assistant ??= row('assistant', ''); assistant.textContent = String(event.content || event.text || '');
- }
- if (event.type === 'error') throw new Error(String(event.message || event.error || 'Run failed'));
-}
-async function follow(events: AsyncIterable) {
- busy(true); status.textContent = 'Working';
- try {
- for await (const event of events) consume(event);
- status.textContent = 'Complete'; runId = ''; await historyMessages();
- } catch (error) {
- if (error instanceof DOMException && error.name === 'AbortError') { status.textContent = 'Cancelled'; runId = ''; }
- else status.textContent = String(error);
- }
- finally { busy(false); }
-}
-element('chat-form').addEventListener('submit', event => {
- event.preventDefault(); if (running) return;
- const text = input.value.trim(); if (!text) return;
- void (async () => {
- busy(true);
- try {
- if (!chatId) rememberChat((await client.createChat()).chat_id);
- input.value = ''; row('user', text); assistant = null; offset = 0; controller = new AbortController();
- const files = attachment ? [attachment] : [];
- attachment = null; element('attachment').textContent = '';
- await follow(client.send({ chat_id: chatId, message: text, ...(files.length ? { attachments: files } : {}) }, controller.signal));
- } catch (error) { status.textContent = String(error); busy(false); }
- })();
-});
-cancel.addEventListener('click', () => {
- void (async () => {
- try { await client.cancel(runId); controller?.abort(); status.textContent = 'Cancelled'; }
- catch (error) { status.textContent = String(error); }
- })();
-});
-resume.addEventListener('click', () => { controller = new AbortController(); void follow(client.resume(runId, offset, controller.signal)); });
-element('new').addEventListener('click', () => {
- if (running) return; rememberChat(''); runId = ''; offset = 0; attachment = null;
- element('attachment').textContent = ''; input.value = '';
- (element('file')).value = '';
- messages.replaceChildren(); controls.clear(); assistant = null; status.textContent = 'Ready'; busy(false);
-});
-element('file').addEventListener('change', event => {
- const file = (event.target as HTMLInputElement).files?.[0]; if (!file || running) return;
- busy(true);
- void (async () => {
- try {
- if (!chatId) rememberChat((await client.createChat()).chat_id);
- attachment = await client.upload(file, chatId); element('attachment').textContent = attachment.name;
- } catch (error) { status.textContent = String(error); }
- finally { busy(false); }
- })();
-});
-void (async () => {
- busy(true);
- try {
- await historyMessages();
- if (!chatId) return;
- await controls.recover();
- const active = await client.activeRun(chatId);
- if (active?.run_id && !['completed', 'failed', 'cancelled'].includes(String(active.status))) {
- runId = String(active.run_id); controller = new AbortController();
- await follow(client.subscribe(runId, controller.signal));
- }
- } catch (error) { status.textContent = String(error); }
- finally { busy(false); }
-})();
diff --git a/examples/customer-chat/src/theme.css b/examples/customer-chat/src/theme.css
deleted file mode 100644
index f708d6b3..00000000
--- a/examples/customer-chat/src/theme.css
+++ /dev/null
@@ -1,19 +0,0 @@
-[data-chat-customization="customer-portal"] .customer-welcome {
- display: flex; flex-direction: column; height: 100%; min-height: 0;
-}
-[data-chat-customization="customer-portal"] .customer-welcome > header {
- display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 16px;
- color: var(--color-text); background: var(--color-bg-container); border-bottom: 1px solid var(--color-border);
-}
-[data-chat-customization="customer-portal"] button {
- color: var(--color-primary); background: var(--color-bg-container); border: 1px solid var(--color-border);
- border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer;
-}
-[data-chat-customization="customer-portal"] .customer-message { min-width: 0; }
-[data-chat-customization="customer-portal"] .customer-message-label {
- display: block; margin: 8px 20px 0; color: var(--color-text-secondary); font-size: 12px;
-}
-[data-chat-customization="customer-portal"] .customer-composer { width: 100%; min-width: 0; }
-[data-chat-customization="customer-portal"] .customer-composer-status {
- display: block; text-align: right; color: var(--color-text-secondary); font-size: 12px; padding: 0 16px;
-}
diff --git a/examples/customer-chat/tsconfig.json b/examples/customer-chat/tsconfig.json
deleted file mode 100644
index 19a17e5e..00000000
--- a/examples/customer-chat/tsconfig.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2022",
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
- "lib": [
- "ES2022",
- "DOM",
- "DOM.Iterable"
- ],
- "strict": true,
- "declaration": true,
- "outDir": "dist",
- "rootDir": "src",
- "jsx": "react-jsx",
- "verbatimModuleSyntax": true,
- "skipLibCheck": true,
- "types": [
- "vite/client"
- ]
- },
- "include": [
- "src"
- ]
-}
diff --git a/examples/customer-chat/vite.config.mjs b/examples/customer-chat/vite.config.mjs
deleted file mode 100644
index 9fbfbe95..00000000
--- a/examples/customer-chat/vite.config.mjs
+++ /dev/null
@@ -1,30 +0,0 @@
-import { defineConfig, loadEnv } from 'vite';
-import { resolve } from 'node:path';
-import { createDeveloperProxy } from './scripts/developer-proxy.mjs';
-
-export default defineConfig(({ mode }) => {
- const env = { ...loadEnv(mode, process.cwd(), 'HUGAGENT_'), ...process.env };
- const host = env.HUGAGENT_DEV_HOST || '127.0.0.1';
- const settings = { host, port: Number(env.HUGAGENT_DEV_PORT || 4310), strictPort: true,
- allowedHosts: ['localhost'], cors: false, fs: { strict: true, allow: [process.cwd()] } };
- const originGuard = { name: 'developer-origin-guard', configureServer: guard, configurePreviewServer: guard };
- function guard(server) {
- server.middlewares.use((req, res, next) => {
- if (!/^\/(api(?:\/|$)|login(?:[?]|$))/.test(req.url || '')) return next();
- const origin = req.headers.origin;
- if (req.headers['sec-fetch-site'] === 'cross-site' || (origin && origin !== 'http://' + req.headers.host)) {
- res.statusCode = 403; res.end('Cross-origin developer API request refused'); return;
- }
- next();
- });
- }
- return {
- plugins: [originGuard],
- server: { ...settings, proxy: createDeveloperProxy(env) },
- preview: { host, port: Number(env.HUGAGENT_DEV_PORT || 4310), strictPort: true, proxy: createDeveloperProxy(env) },
- resolve: { dedupe: ['react', 'react-dom', '@hugagent/chat-sdk'] },
- build: { outDir: 'web-dist', sourcemap: false, rollupOptions: { input: {
- chat: resolve('index.html'), preview: resolve('preview.html'), debug: resolve('debug.html'), session: resolve('session.html'),
- } } },
- };
-});
diff --git a/install.sh b/install.sh
index b1333327..94ebc093 100755
--- a/install.sh
+++ b/install.sh
@@ -339,7 +339,7 @@ pip_install --upgrade \
"pymilvus==2.5.18" \
"milvus-lite==3.1.0"
info "Installing Agent Skills Python dependencies"
-pip_install -r docker/requirements-script-runner.txt
+pip_install -r docker/sandbox/requirements-script-runner.txt
pip_install --no-deps -e .
SKILL_NODE_DIR="${HUGAGENT_DATA_DIR}/node"
diff --git a/packages/cli/DESIGN.md b/packages/cli/DESIGN.md
new file mode 100644
index 00000000..2abda9cf
--- /dev/null
+++ b/packages/cli/DESIGN.md
@@ -0,0 +1,164 @@
+# hugagent 终端客户端设计(对标 Codex CLI / pi)
+
+状态:草案,未实现。已定取舍:TUI 用 TypeScript + `@mariozechner/pi-tui`;local 模式复用现有后端运行时。
+
+## 1. 目标
+
+- 交互体验对标 pi:内联差分渲染(保留终端原生回滚,不占全屏),多行编辑器,流式 Markdown,工具调用一行一条,运行中可插话。
+- 能力对标 Codex CLI:审批与沙箱档位、会话恢复、非交互 `exec`、JSONL 事件输出、MCP、`/review` `/diff`。
+- `/mode` 切换执行位置:
+ - `local`:全部是本机进程,agent 直接操作当前目录。
+ - `cloud`:连接远端 HugAgentOS 服务。
+ - `device:`:远程运维另一台已配对的电脑。
+- 两种模式用同一套界面、同一套事件协议,切换后体验一致。
+
+不做:在 TypeScript 里重写 agent 循环。agent、工具、沙箱、压缩、技能、记忆全部由后端负责。
+
+## 2. 架构
+
+```
+┌──────────────── hugagent(Node,packages/cli)───────────────┐
+│ 入口:interactive │ -p/exec │ --output json │ --output rpc │
+│ TUI:pi-tui(Editor / Markdown / SelectList / Loader) │
+│ Engine 接口 ── send / subscribe / steer / cancel / 审批 / 会话 │
+│ ├─ LocalEngine → http://127.0.0.1:<随机端口>(本机子进程) │
+│ └─ RemoteEngine → https://(cloud / device:) │
+└──────────────────────────────┬───────────────────────────────┘
+ │ 同一套 HTTP + SSE(@hugagent/chat-sdk)
+┌──────────────────────────────┴───────────────────────────────┐
+│ Python 后端(现有):agent 运行时 · 工具 · OS 沙箱 · 压缩 · 技能 · MCP │
+│ local:hugagent-server engine(SQLite,~/.hugagent,绑定 loopback)│
+└──────────────────────────────────────────────────────────────┘
+```
+
+### 为什么本机引擎走 loopback HTTP,而不是 stdio RPC
+
+调整说明:讨论取舍时写的是 stdio JSON-RPC,这里改成 loopback HTTP。理由如下:
+
+- 后端已有完整的 SSE 事件协议,包括 `run_snapshot` 断线续播、`run.steer` 插话,以及 `tool-approval`、`file-confirm`、`user-questions` 带外审批。`@hugagent/chat-sdk` 已经封装了 `send / resume / subscribe`。走 HTTP,local 和 cloud 之间只差 base URL 和凭据。
+- 如果改成 stdio RPC,就要为同一套能力再定义第二种协议,两边会慢慢分叉。
+- 桌面端已经在 32101 端口跑着本机后端。CLI 检测到它(并且数据目录相同)时可以直接接入,和桌面端共享会话、不再重复启动。
+- pi 风格的 `--output rpc` 仍然保留,但它是 CLI **对外**提供的嵌入协议,不是 CLI 和后端之间的协议。
+
+### LocalEngine 生命周期
+
+1. 先找可复用的后端:读 `~/.hugagent/engine.json`(端口、令牌、PID),健康检查通过就直接接入。
+2. 找不到就启动 `hugagent-server engine --port 0 --token-file …`:
+ - 无头运行,不开浏览器、不托管前端静态文件;
+ - 只绑定 127.0.0.1;
+ - 就绪后把端口写进就绪文件。
+3. 鉴权:一次性随机令牌,复用桌面桥的机制(`HUGAGENT_DESKTOP_BRIDGE_SECRET` 这一类);不需要本机账号密码。
+4. 引擎空闲 N 分钟后自动退出;CLI 退出不强制关闭它,下次启动就是热启动。
+5. 当前目录映射:按 realpath 查找或创建 `kind=local` 的项目,挂载到 `/workspace/local/`(`core/sandbox/workspace/mounts.py`)。
+ - 新会话都建在这个项目里,根目录的 `AGENTS.md` 由现有逻辑读取(`core/projects/instructions.py`)。
+ - 命令执行用现有 OS 沙箱:Seatbelt / bwrap / Windows 低权限令牌。
+6. 首次使用时没有模型配置:TUI 内引导完成(复用 `cli.py` 的 `configure_model` 逻辑),不跳到网页。
+
+### RemoteEngine
+
+- `cloud`:用 `/login` 获取个人 API Key 并存进系统钥匙串,回退到 `~/.hugagent/cli.json`(权限 0600)。默认要求 HTTPS,只有 loopback 和显式允许的内网地址例外。
+- 云端 agent **碰不到本机当前目录**。状态栏必须常驻显示 `cloud · 无本地文件访问`,`@文件` 只能以附件上传的方式提供。
+- `device:`:沿用现有 session-manager 操作队列(`hugagent_cli/contract.py`)。这台设备上的对话由那台机器执行,TUI 只能轮询结果,不能实时流式显示(设备链路目前没有 SSE)。
+
+## 3. `/mode` 语义
+
+| 操作 | 行为 |
+|---|---|
+| `/mode` | 弹出选择列表:local / cloud / 在线设备,并标出当前项 |
+| `/mode local\|cloud\|device:` | 直接切换。当前会话留在原来那一侧,目标侧开新会话;`/resume` 只列目标侧、当前项目的会话 |
+| `--mode` 启动参数 / 配置文件 `defaultMode` | 启动时的默认值;不指定时,如果当前目录是 git 仓库就用 local,否则用上次的选择 |
+| 状态栏 | `local · ~/repo · deepseek-chat · 审批:按需 · ctx 42%` |
+
+- 第一版不支持把会话从一侧搬到另一侧。以后可以加 `/handoff`:把摘要和关键文件带到另一侧,开一个新会话。
+- 命名提醒:pi 的 `--mode` 表示输出格式。我们的 `--mode` 表示执行位置,输出格式统一改用 `--output text|json|rpc`。
+
+## 4. 功能对标矩阵
+
+Codex / pi 两列按公开版本归纳,细节以官方文档为准。"后端"列表示后端现在是否已经具备这项能力。
+
+| 能力 | Codex | pi | 我们的做法 | 后端 | 阶段 |
+|---|---|---|---|---|---|
+| 内联差分渲染、原生回滚 | 全屏 TUI | ✓ | pi-tui | — | P1 |
+| 多行编辑器、历史、快捷键 | ✓ | ✓ | pi-tui Editor | — | P1 |
+| 流式 Markdown、思考折叠 | ✓ | ✓ | 渲染 `content` / `thinking` 事件 | ✓ | P1 |
+| 工具调用一行显示、可展开 | ✓ | ✓ | `tool_call_*` / `tool_result` / `subagent_event` | ✓ | P1 |
+| Esc 中断 | ✓ | ✓ | `run.cancel` | ✓ | P1 |
+| 运行中插话 / 排到下一条 | 排队 | Enter 插话 / Alt+Enter 排队 | `run.steer`,`delivery_mode=steer\|followUp\|nextRun` | ✓ | P1 |
+| 工具与命令审批 | approval policy | 默认不拦 | 终端内回应 `tool-approval` / `file-confirm` | ✓ | P1 |
+| 向用户提问 | — | — | `user_question` 内联选择 | ✓ | P1 |
+| 沙箱档位 `/permissions` | read-only / workspace-write / full | — | 映射现有权限预设 + OS 沙箱 | ✓ | P2 |
+| `/model`、思考档位 | ✓ | ✓(Ctrl+P / Shift+Tab) | `ChatRequest.model_name / model_provider_id`,思考档位走偏好设置 | ✓ | P1 |
+| 会话恢复 `-c` / `-r` | `resume` | ✓ | 服务端对话按项目(当前目录)过滤 | ✓ | P1 |
+| 分支 `/fork`、`/tree`、回到旧消息重写 | Esc Esc 回退 | ✓ | `POST /chats/{id}/fork` | ✓ | P2 |
+| 手动 `/compact` | ✓ | ✓ | 后端只有自动压缩,**需新增接口** | 部分 | P2 |
+| 上下文用量显示 | ✓ | ✓ | `context_usage` 事件 | ✓ | P1 |
+| AGENTS.md | 逐级向上 + 全局 | 逐级向上 + 全局 | 只读项目根目录,**需补逐级合并和全局文件** | 部分 | P2 |
+| `@文件` 模糊引用 | ✓ | ✓ | local:路径交给 agent;cloud:作为附件上传 | 部分 | P2 |
+| `!命令` 直接执行 | ✓ | ✓ | local 在本机沙箱执行,把输出注入上下文 | — | P2 |
+| 粘贴图片 | ✓ | ✓ | 附件 + 视觉桥 | ✓ | P2 |
+| `/diff`、`/review` | ✓ | 扩展实现 | local 由客户端读 git;review 作为一种对话模式 | — | P2 |
+| 非交互 `exec` / `-p` | ✓ | ✓ | `hugagent -p "…"`,退出码反映运行结果 | ✓ | P0 |
+| JSONL 事件流 | `--json` | `--mode json` | `--output json`,原样输出 SSE 事件 | ✓ | P0 |
+| 嵌入协议 | app-server | `--mode rpc` | `--output rpc`(stdin/stdout JSON) | — | P2 |
+| MCP | ✓ | 不内置 | 后端插件市场;提供 `hugagent mcp list/add` | ✓ | P3 |
+| 技能 / 提示模板 / 扩展 | ✓ | ✓ | 后端技能 + 插件;TUI 侧扩展暂不做 | ✓ | P3 |
+| 登录 | ChatGPT / API Key | 订阅 / API Key | `/login`(cloud);local 的模型凭据存在本机库 | 部分 | P1 |
+| 主题、按键绑定 | 部分 | ✓ | `~/.hugagent/cli.json` | — | P3 |
+| 导出 / 分享 | — | HTML / gist | `/export` Markdown 或 HTML | — | P3 |
+| 远程运维 | — | — | `/mode device:`、`/sessions`、`/runs`、`/devices` | ✓ | P3 |
+| 子 agent、计划、自主循环 | 部分 | 扩展 | 渲染 `subagent_event` / `plan_update`(我们的优势) | ✓ | P2 |
+| 分发、升级 | npm / brew | npm | npm 全局安装;local 引擎复用桌面端自带的 Python 运行时,或 `pip install hugagent` | — | P3 |
+
+## 5. 后端需要补的东西
+
+1. `hugagent-server engine` 子命令:无头运行、`--port 0`、就绪文件、loopback 令牌鉴权、空闲退出、父进程监护。
+2. 按路径查找或创建本地项目的接口(目前只有桌面端授权文件夹的流程)。
+3. 手动压缩接口,对应 `/compact`。
+4. `ChatRequest.message` 的长度上限 10000 字符,对粘贴代码太小。附件可以绕开,但 `-p` 和管道输入需要更大的上限,或者自动转成附件。
+5. AGENTS.md 逐级向上合并 + 全局文件 `~/.hugagent/AGENTS.md`。
+6. 统一命名:
+ - Python 的 console script 改名 `hugagent-server`(负责 onboard / serve / doctor / engine);
+ - npm 包占用 `hugagent`;`hugagent serve` 等命令转发给 `hugagent-server`;
+ - 云电脑镜像里的零依赖会话客户端 `hugagent_cli` 保持不变,因为 Personal Agent 依赖它。
+ - 上次审查发现的入口路由、错误详情丢失、空响应、操作表清理等问题,在这一步一起修。
+
+## 6. 包结构
+
+```
+packages/cli/
+ src/
+ main.ts 参数解析,分发到各模式
+ config.ts ~/.hugagent/cli.json、钥匙串
+ engine/
+ engine.ts Engine 接口 + 统一事件类型(复用 chat-sdk 类型)
+ local.ts 发现 / 启动 / 接入本机引擎,当前目录 → 本地项目
+ remote.ts cloud 与 device:
+ modes/
+ interactive.ts TUI 主循环
+ print.ts -p / exec
+ json.ts --output json
+ rpc.ts --output rpc
+ tui/
+ transcript.ts 事件 → 组件(正文 / 思考 / 工具行 / 计划 / 子 agent)
+ footer.ts 状态栏
+ prompts/ 审批、提问、选择列表
+ commands/ /mode /model /resume /new /compact /permissions /fork /diff …
+ test/
+```
+
+## 7. 分期与验收
+
+- **P0 骨架**:包结构、两种 Engine、`-p` 和 `--output json`。
+ - 验收:在任意仓库目录执行 `hugagent -p "列出这个目录的文件"`,local 模式由本机引擎在当前目录完成;`--mode cloud` 走远端服务;两边的 JSONL 事件一致。
+- **P1 交互 TUI**:编辑器、流式渲染、工具行、状态栏、Esc 中断、插话 / 排队、审批与提问内联处理、`/mode` `/model` `/new` `/resume` `/login` `/quit`。
+ - 验收:一次完整的"改代码 → 审批 → 跑测试"闭环不离开终端。
+- **P2 对标补齐**:`@文件`、`!命令`、图片、`/compact`、`/permissions`、`/fork` `/tree`、`/diff` `/review`、AGENTS.md 层级、`--output rpc`。
+- **P3 生态与运维**:`device:`、`/sessions` `/runs` `/devices`、MCP / 技能命令、主题按键、导出、npm 发布与升级。
+
+## 8. 风险
+
+- **Python 引擎冷启动慢**:完整 lifespan 要数秒。先实测;用"接入已运行的后端 + 空闲保活"缓解;engine 子命令只启动 agent 运行需要的步骤。
+- **两套运行时**:只用 cloud 的用户只需要 Node;local 需要 Python 运行时,优先复用桌面端已安装的那份。
+- **pi-tui 还是 0.x**:锁定精确版本,用自己的组件层隔离它的 API。
+- **终端兼容**:中文输入法和宽字符(pi-tui 依赖 get-east-asian-width),Windows Terminal,SSH 下的剪贴板。
+- **cloud 不能访问本机目录**:必须在界面上一直说清楚,否则用户会以为云端改了本地代码。
diff --git a/requirements.txt b/requirements.txt
index f38daea0..0eaea501 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,10 @@
# Core Web Framework
fastapi==0.115.12
uvicorn>=0.27.0
-pydantic>=2.6.0
+pydantic>=2.12.0
# Async & Utilities
-anyio>=4.2.0
+anyio>=4.9.0
python-dotenv>=1.0.0
requests>=2.31.0
httpx>=0.27.0
@@ -13,11 +13,11 @@ httpx>=0.27.0
jsonschema>=4.20.0
# AgentScope (replaces LangChain ecosystem)
-# 已迁移到 2.0:Agent/Toolkit/Middleware/MCP 全面重写,固定 ==2.0.0(GitHub main 已与 2.0.0 分叉,不兼容)
-agentscope==2.0.0
+# Pin the reviewed MCP 2.x implementation for reproducible builds.
+agentscope @ https://github.com/agentscope-ai/agentscope/archive/ed357feb78a45ff02df155bb5610eab9bf543832.tar.gz
# 多厂商模型渠道所需 SDK(均为惰性 import,未装只影响对应渠道,不影响其余功能):
-# - anthropic / dashscope / openai 已随 agentscope==2.0.0 内置,无需在此重复声明
+# - anthropic / dashscope / openai 已由 AgentScope 声明依赖,无需在此重复声明
# - 以下为 agentscope 未捆绑、需显式安装的渠道 SDK:
# litellm —— 非 OpenAI 协议且 AgentScope 无原生类的厂商(如 AWS Bedrock)
litellm>=1.50.0
@@ -27,7 +27,9 @@ google-genai>=0.3.0
ollama>=0.3.0
# MCP Integration
-mcp>=1.0.0,<2.0.0
+mcp==2.3.0
+# Keep the SSE dependency within FastAPI 0.115 Starlette constraints.
+sse-starlette==3.0.3
# Additional Tools
tavily-python>=0.5.0
@@ -67,9 +69,8 @@ botocore>=1.34.0
werkzeug>=3.0.0
python-multipart>=0.0.9
-# Logging & Rate Limiting
+# Logging
structlog>=23.2.0
-slowapi>=0.1.9
# Security
safety>=3.0.0
diff --git a/src/backend/alembic/versions/ce_0032_automation_queue.py b/src/backend/alembic/versions/ce_0032_automation_queue.py
new file mode 100644
index 00000000..80d7ef2c
--- /dev/null
+++ b/src/backend/alembic/versions/ce_0032_automation_queue.py
@@ -0,0 +1,25 @@
+"""Durable queued automation invocations."""
+from alembic import op
+
+revision = "ce_0032"
+down_revision = "ce_0031"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ from features.automation.schema import upgrade_queue
+ upgrade_queue(op.get_bind())
+
+
+def downgrade():
+ # Refuse to erase queued work while downgrading the status contract.
+ from sqlalchemy import text
+ if op.get_bind().execute(text(
+ "SELECT 1 FROM scheduled_task_runs WHERE status = 'pending' LIMIT 1"
+ )).first():
+ raise RuntimeError("Drain pending automation runs before downgrade")
+ with op.batch_alter_table("scheduled_task_runs") as batch:
+ batch.drop_constraint("scheduled_task_runs_status_check", type_="check")
+ batch.create_check_constraint("scheduled_task_runs_status_check",
+ "status IN ('running', 'success', 'failed')")
diff --git a/src/backend/alembic/versions/ce_0033_automation_targets.py b/src/backend/alembic/versions/ce_0033_automation_targets.py
new file mode 100644
index 00000000..e4b38e6d
--- /dev/null
+++ b/src/backend/alembic/versions/ce_0033_automation_targets.py
@@ -0,0 +1,17 @@
+"""Normalize stored automation delivery destinations."""
+from alembic import op
+
+revision = "ce_0033"
+down_revision = "ce_0032"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ from features.automation.schema import upgrade_delivery_targets
+ upgrade_delivery_targets(op.get_bind())
+
+
+def downgrade():
+ # The earlier schema already reads delivery_targets; no lossy reverse conversion.
+ pass
diff --git a/src/backend/api/__init__.py b/src/backend/api/__init__.py
index aa95844f..ea4958f5 100644
--- a/src/backend/api/__init__.py
+++ b/src/backend/api/__init__.py
@@ -1,5 +1,20 @@
-"""API layer for HugAgentOS."""
+"""API layer for HugAgentOS.
-from .app import app
+``app`` is resolved on first access: importing a submodule such as
+``api.deps`` from a route module must not compose the whole application,
+which would import that same, still half-initialised route module.
+"""
__all__ = ["app"]
+
+
+def __getattr__(name):
+ if name == "app":
+ import importlib
+
+ # Importing the submodule binds ``api.app`` to the module; rebind it to
+ # the FastAPI instance, as the former eager ``from .app import app`` did.
+ instance = importlib.import_module("api.app").app
+ globals()["app"] = instance
+ return instance
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/src/backend/api/deps.py b/src/backend/api/deps.py
index c5f2e9b6..237b2882 100644
--- a/src/backend/api/deps.py
+++ b/src/backend/api/deps.py
@@ -5,9 +5,9 @@
from core.auth.backend import UserContext, require_auth
from core.config.settings import settings
-from core.db.engine import get_db
from core.db.models import UserShadow
from core.db.repository import AuditLogRepository
+from core.db.threads import run_in_session
from fastapi import Depends, Header, HTTPException, Request
from sqlalchemy.orm import Session
@@ -30,14 +30,15 @@ def _require_token_or_flag(
``action_base`` (e.g. ``"admin"`` / ``"config"``) derives the paired audit
actions ``{base}.access_granted`` / ``{base}.access_denied``, so the two
action names are not string-derived from each other (fragile coupling).
+
+ Like every dependency here, it resolves the session (Redis) on the event
+ loop and does all of its database work in one ``run_in_session`` call.
"""
granted_action = f"{action_base}.access_granted"
denied_action = f"{action_base}.access_denied"
- async def dependency(
- request: Request,
- authorization: Optional[str] = Header(None),
- db: Session = Depends(get_db),
+ def authorize(
+ db: Session, request: Request, authorization: Optional[str], user_id: Optional[str]
) -> None:
token = os.getenv(env_var) or getattr(settings.auth, settings_attr, "")
# ① Static token match
@@ -45,19 +46,19 @@ async def dependency(
_audit_grant(db, request, granted_action, user_id=None, via="token")
return
# ② Session user carries the corresponding capability flag (or super_admin)
- user_id = await _resolve_session_user_id(request)
if user_id and _user_has_meta_flag(db, user_id, meta_flag):
_audit_grant(db, request, granted_action, user_id=user_id, via="session")
return
- # ②b 桌面桥接(混合架构):桌面壳孵化的本机后端上,壳注入的云端身份携带
- # 对应管理能力标志时放行——config 控制台在桌面双模式下可读本机数据。
- # 云端部署不设桥接 env,此分支恒短路(零行为变化)。
- from features.desktop.auth.bridge import resolve_bridge_user
-
- bridge_user = resolve_bridge_user(request, db)
- if bridge_user is not None and _user_has_meta_flag(db, bridge_user.user_id, meta_flag):
+ # ②b Host identity (desktop bridge): the shell-injected cloud identity is
+ # accepted when it carries the same flag. Deployments without a host
+ # resolver, or requests it does not claim, skip this without any I/O.
+ from core.harness.registry.authentication import host_user_resolver
+
+ resolver = host_user_resolver(request)
+ host_user = resolver(request, db) if resolver is not None else None
+ if host_user is not None and _user_has_meta_flag(db, host_user.user_id, meta_flag):
_audit_grant(
- db, request, granted_action, user_id=bridge_user.user_id, via="desktop_bridge"
+ db, request, granted_action, user_id=host_user.user_id, via="desktop_bridge"
)
return
# ③ Token not configured at all and no session grant → report explicitly that it is not configured
@@ -79,6 +80,16 @@ async def dependency(
)
raise HTTPException(status_code=401, detail="Unauthorized")
+ async def dependency(
+ request: Request,
+ authorization: Optional[str] = Header(None),
+ ) -> None:
+ token = os.getenv(env_var) or getattr(settings.auth, settings_attr, "")
+ user_id = None
+ if not (token and authorization == f"Bearer {token}"):
+ user_id = await _resolve_session_user_id(request)
+ await run_in_session(authorize, request, authorization, user_id)
+
return dependency
@@ -131,7 +142,6 @@ def _audit_grant(
async def require_admin_or_config(
request: Request,
authorization: Optional[str] = Header(None),
- db: Session = Depends(get_db),
) -> None:
"""Accept either ADMIN_TOKEN or CONFIG_TOKEN.
@@ -151,14 +161,16 @@ async def require_admin_or_config(
return
if config_token and authorization == f"Bearer {config_token}":
return
- AuditLogRepository(db).log_denial(
- user_id=None,
- action="prompt_snapshot.access_denied",
- reason="invalid_token",
- required="admin_or_config_token",
- actual="mismatch",
- resource_type="token",
- request=request,
+ await run_in_session(
+ lambda db: AuditLogRepository(db).log_denial(
+ user_id=None,
+ action="prompt_snapshot.access_denied",
+ reason="invalid_token",
+ required="admin_or_config_token",
+ actual="mismatch",
+ resource_type="token",
+ request=request,
+ )
)
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -194,6 +206,15 @@ async def _resolve_session_user_id(request: Request) -> Optional[str]:
return None
+async def console_actor(request: Request) -> str:
+ """Who is acting on a console endpoint: the session user, else the static token.
+
+ A dependency, so the console routes that record it can stay plain ``def``
+ routes and do their database work in FastAPI's worker threads.
+ """
+ return await _resolve_session_user_id(request) or "config_token"
+
+
def _is_super_admin(db: Session, user_id: str) -> bool:
shadow = db.query(UserShadow).filter(UserShadow.user_id == user_id).first()
return bool(shadow and (shadow.extra_data or {}).get("role") == "super_admin")
@@ -248,97 +269,98 @@ def user_can_manage_ontology_governance(db: Session, user_id: Optional[str]) ->
) or user_can_manage_system_settings(db, user_id)
-async def require_system_settings(
+def _authorize_delegated(
+ db: Session,
request: Request,
- db: Session = Depends(get_db),
- user: Optional[UserContext] = Depends(require_auth(False)),
+ user: Optional[UserContext],
+ *,
+ token: str,
+ token_grant: str,
+ allowed,
+ action: str,
+ required: str,
+ forbidden: str,
) -> str:
- """System-settings seam (the CE delegated version of ``require_config``): any of three paths grants access.
+ """Shared body of the delegated console gates (runs in a worker thread).
- 1. Static CONFIG_TOKEN (script / ops fallback, equivalent to ``require_config``);
- 2. Session/mock user with the ``can_system_config`` capability flag (implied by super_admin);
- 3. CE + mock single trust domain (see ``user_can_manage_system_settings``).
-
- Key difference from ``require_config``: the user is resolved via the full
- ``get_current_user`` chain — the CE derived tree has no
- ``core/auth/session.py``, so ``require_config``'s cookie-validation path
- always fails on CE; this gate can resolve a user in mock / local-session /
- API-Key modes alike.
+ A matching static token wins; otherwise the resolved user must pass
+ ``allowed(db, user_id)``. Grants are audited for writes, denials whenever a
+ credential or user was presented.
"""
- token = os.getenv("CONFIG_TOKEN") or getattr(settings.auth, "config_token", "")
authorization = request.headers.get("authorization")
if token and authorization == f"Bearer {token}":
- _audit_grant(db, request, "system_settings.access_granted", user_id=None, via="token")
- return "config_token"
- if user is not None and user_can_manage_system_settings(db, user.user_id):
- _audit_grant(
- db, request, "system_settings.access_granted", user_id=user.user_id, via="session"
- )
+ _audit_grant(db, request, f"{action}.access_granted", user_id=None, via="token")
+ return token_grant
+ if user is not None and allowed(db, user.user_id):
+ _audit_grant(db, request, f"{action}.access_granted", user_id=user.user_id, via="session")
return user.user_id
if authorization or user is not None:
AuditLogRepository(db).log_denial(
user_id=user.user_id if user else None,
- action="system_settings.access_denied",
+ action=f"{action}.access_denied",
reason="insufficient_permission",
- required="can_system_config",
+ required=required,
actual="regular_user" if user else "anonymous",
resource_type="backend_console",
request=request,
)
if user is None:
raise HTTPException(status_code=401, detail="未登录")
- raise HTTPException(status_code=403, detail="需要系统配置权限")
+ raise HTTPException(status_code=403, detail=forbidden)
-async def require_ontology_governance(
+async def require_system_settings(
request: Request,
- db: Session = Depends(get_db),
user: Optional[UserContext] = Depends(require_auth(False)),
) -> str:
- """Authorize the CE settings-page ontology governance surface.
+ """System-settings seam (the CE delegated version of ``require_config``): any of three paths grants access.
- ``ADMIN_TOKEN`` remains an operational fallback. Browser access is limited
- to users accepted by :func:`user_can_manage_ontology_governance`.
+ 1. Static CONFIG_TOKEN (script / ops fallback, equivalent to ``require_config``);
+ 2. Session/mock user with the ``can_system_config`` capability flag (implied by super_admin);
+ 3. CE + mock single trust domain (see ``user_can_manage_system_settings``).
+
+ Key difference from ``require_config``: the user is resolved via the full
+ ``get_current_user`` chain — the CE derived tree has no
+ ``core/auth/session.py``, so ``require_config``'s cookie-validation path
+ always fails on CE; this gate can resolve a user in mock / local-session /
+ API-Key modes alike.
"""
- token = os.getenv("ADMIN_TOKEN") or getattr(settings.auth, "admin_token", "")
- authorization = request.headers.get("authorization")
- if token and authorization == f"Bearer {token}":
- _audit_grant(db, request, "ontology_governance.access_granted", user_id=None, via="token")
- return "admin_token"
- if user is not None and user_can_manage_ontology_governance(db, user.user_id):
- _audit_grant(
- db,
- request,
- "ontology_governance.access_granted",
- user_id=user.user_id,
- via="session",
- )
- return user.user_id
- if authorization or user is not None:
- AuditLogRepository(db).log_denial(
- user_id=user.user_id if user else None,
- action="ontology_governance.access_denied",
- reason="insufficient_permission",
- required="ce_ontology_governance",
- actual="regular_user" if user else "anonymous",
- resource_type="backend_console",
- request=request,
- )
- if user is None:
- raise HTTPException(status_code=401, detail="未登录")
- raise HTTPException(status_code=403, detail="需要 CE 本体治理权限")
+ return await run_in_session(
+ _authorize_delegated,
+ request,
+ user,
+ token=os.getenv("CONFIG_TOKEN") or getattr(settings.auth, "config_token", ""),
+ token_grant="config_token",
+ allowed=user_can_manage_system_settings,
+ action="system_settings",
+ required="can_system_config",
+ forbidden="需要系统配置权限",
+ )
-async def require_super_admin(
+async def require_ontology_governance(
request: Request,
- db: Session = Depends(get_db),
+ user: Optional[UserContext] = Depends(require_auth(False)),
) -> str:
- """Require the current session user to be super_admin; a valid ADMIN_TOKEN serves as fallback."""
- admin_token = os.getenv("ADMIN_TOKEN") or settings.auth.admin_token
- authorization = request.headers.get("authorization")
- if admin_token and authorization == f"Bearer {admin_token}":
- return "admin_token"
+ """Authorize the CE settings-page ontology governance surface.
+ ``ADMIN_TOKEN`` remains an operational fallback. Browser access is limited
+ to users accepted by :func:`user_can_manage_ontology_governance`.
+ """
+ return await run_in_session(
+ _authorize_delegated,
+ request,
+ user,
+ token=os.getenv("ADMIN_TOKEN") or getattr(settings.auth, "admin_token", ""),
+ token_grant="admin_token",
+ allowed=user_can_manage_ontology_governance,
+ action="ontology_governance",
+ required="ce_ontology_governance",
+ forbidden="需要 CE 本体治理权限",
+ )
+
+
+def _authorize_super_admin(db: Session, request: Request) -> str:
user_id = _resolve_current_user_id(request)
if not user_id:
AuditLogRepository(db).log_denial(
@@ -361,3 +383,12 @@ async def require_super_admin(
)
raise HTTPException(status_code=403, detail="需要 super_admin 权限")
return user_id
+
+
+async def require_super_admin(request: Request) -> str:
+ """Require the current session user to be super_admin; a valid ADMIN_TOKEN serves as fallback."""
+ admin_token = os.getenv("ADMIN_TOKEN") or settings.auth.admin_token
+ authorization = request.headers.get("authorization")
+ if admin_token and authorization == f"Bearer {admin_token}":
+ return "admin_token"
+ return await run_in_session(_authorize_super_admin, request)
diff --git a/src/backend/api/health.py b/src/backend/api/health.py
index dce87d8f..ebcefcb0 100644
--- a/src/backend/api/health.py
+++ b/src/backend/api/health.py
@@ -3,14 +3,13 @@
from datetime import datetime
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter
from fastapi.responses import JSONResponse
-from sqlalchemy.orm import Session
from sqlalchemy import text
from core.chat.schemas import HealthResponse
from core.config.settings import settings
-from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.logging import get_logger
from core.files.storage import get_storage
@@ -35,7 +34,7 @@ async def health_check():
@router.get("/ready", summary="就绪探针")
-async def readiness_check(db: Session = Depends(get_db)):
+async def readiness_check():
"""
就绪检查(包含依赖检查)
@@ -46,7 +45,7 @@ async def readiness_check(db: Session = Depends(get_db)):
# Check database
try:
- db.execute(text("SELECT 1"))
+ await run_in_session(lambda db: db.execute(text("SELECT 1")))
checks["database"] = True
except Exception as e:
logger.error("database_health_check_failed", error=str(e))
diff --git a/src/backend/api/lifecycle.py b/src/backend/api/lifecycle.py
index 7ca0e824..b6c37b3f 100644
--- a/src/backend/api/lifecycle.py
+++ b/src/backend/api/lifecycle.py
@@ -9,7 +9,6 @@
from api.startup.workers import (
_env_flag,
_startup_preload_enabled,
- _mcp_warmup_enabled,
_shutdown_automation_scheduler,
_shutdown_idle_session_reaper,
_startup_idle_session_reaper,
@@ -32,6 +31,7 @@
_startup_seed_roles,
)
from api.startup.runtime import (
+ _startup_thread_budget,
_startup_watch_capability_changes,
_startup_recover_chat_runs,
_startup_resume_loops,
@@ -42,6 +42,8 @@
_shutdown_orphan_job_reaper,
_shutdown_channel_manager,
_startup_warm_sandbox_pool,
+ _shutdown_sandbox_maintenance,
+ _shutdown_sandbox_provider,
_startup_local_sidecars,
_shutdown_local_sidecars,
_startup_device_link,
@@ -121,6 +123,7 @@ def _startup_steps():
"""
return (
# (step, stop, gate, roles, scope)
+ (_startup_thread_budget, None, True, _ALL_ROLES, _PER_WORKER),
(_startup_ensure_tables, None, True, _ALL_ROLES, _SINGLETON),
(_startup_watch_capability_changes, None, True, _ALL_ROLES, _PER_WORKER),
(_startup_desktop_observability, None, True, _ALL_ROLES, _PER_WORKER),
@@ -148,7 +151,7 @@ def _startup_steps():
(_startup_recover_jobs, None, False, _ALL_ROLES, _SINGLETON),
(_startup_orphan_job_reaper, _shutdown_orphan_job_reaper, False, _ALL_ROLES, _SINGLETON),
(_startup_stale_run_reaper, _shutdown_stale_run_reaper, False, _ALL_ROLES, _SINGLETON),
- (_startup_warm_sandbox_pool, None, False, _ALL_ROLES, _SINGLETON),
+ (_startup_warm_sandbox_pool, _shutdown_sandbox_maintenance, False, _ALL_ROLES, _SINGLETON),
(
_startup_idle_session_reaper,
_shutdown_idle_session_reaper,
@@ -214,13 +217,17 @@ async def _stop_singleton_work() -> None:
the role. The handlers come from the registry, so this cannot stop a
different set than the one the leader started.
"""
- for _step, stop, _gate, _roles, scope in _startup_steps():
+ failures = []
+ for _step, stop, _gate, _roles, scope in reversed(_startup_steps()):
if scope is not _SINGLETON or stop is None:
continue
try:
await stop()
except Exception as exc: # noqa: BLE001 - one failure must not skip the rest
logger.warning("leader_stop_failed", step=stop.__name__, error=str(exc))
+ failures.append(exc)
+ if failures:
+ raise ExceptionGroup("Singleton work did not stop", failures)
async def _run_startup_steps(steps) -> None:
@@ -308,4 +315,5 @@ async def lifespan(app: FastAPI):
await _shutdown_datasource_sidecar_recovery()
await _shutdown_mcp_market_monitor()
await _shutdown_local_sidecars()
+ await _shutdown_sandbox_provider()
await _shutdown_pools()
diff --git a/src/backend/api/personal_agents.py b/src/backend/api/personal_agents.py
index 4c9c098b..ebc1e61e 100644
--- a/src/backend/api/personal_agents.py
+++ b/src/backend/api/personal_agents.py
@@ -71,7 +71,7 @@ class AgentUpdateRequest(BaseModel):
@router.get("", summary="列出当前用户可见的所有子智能体")
-async def list_agents(
+def list_agents(
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
@@ -87,7 +87,7 @@ async def list_agents(
@router.get("/available-resources", summary="可绑定到子智能体的资源列表")
-async def available_resources(
+def available_resources(
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
@@ -117,7 +117,7 @@ async def available_resources(
@router.post("/import", summary="导入子智能体(markdown / zip)")
-async def import_agents(
+def import_agents(
file: UploadFile = File(...),
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -128,7 +128,7 @@ async def import_agents(
JSON 数组文件。需 ``can_add_agent`` 权限。
"""
_require_can_add_agent(str(user.user_id), db)
- raw = await file.read()
+ raw = file.file.read()
try:
items = parse_agents_upload(file.filename or "", raw)
except ValueError as exc:
@@ -154,7 +154,7 @@ async def import_agents(
@router.get("/{agent_id}/export", summary="导出子智能体为 markdown")
-async def export_agent(
+def export_agent(
agent_id: str,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -193,7 +193,7 @@ async def export_agent(
@router.get("/{agent_id}", summary="子智能体详情")
-async def get_agent(
+def get_agent(
agent_id: str,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -208,7 +208,7 @@ async def get_agent(
@router.post("", summary="创建个人子智能体")
-async def create_agent(
+def create_agent(
body: AgentCreateRequest,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -229,7 +229,7 @@ async def create_agent(
@router.put("/{agent_id}", summary="更新个人子智能体")
-async def update_agent(
+def update_agent(
agent_id: str,
body: AgentUpdateRequest,
user: UserContext = Depends(get_current_user),
@@ -260,7 +260,7 @@ async def update_agent(
@router.delete("/{agent_id}", summary="删除个人子智能体")
-async def delete_agent(
+def delete_agent(
agent_id: str,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
diff --git a/src/backend/api/routes/__init__.py b/src/backend/api/routes/__init__.py
index 95baf397..01e58b53 100644
--- a/src/backend/api/routes/__init__.py
+++ b/src/backend/api/routes/__init__.py
@@ -1,9 +1,17 @@
"""API routes.
v1 routes are registered via the edition registry in ``api.routes.v1`` (seam C1);
-this package only keeps the re-export of non-v1 file download routes.
+this package only keeps the re-export of non-v1 file download routes, resolved
+lazily so that importing a sibling module (``api.routes.file_location``) does not
+load ``files`` and the feature routers it mounts.
"""
-from .files import router as files_router
-
__all__ = ["files_router"]
+
+
+def __getattr__(name):
+ if name == "files_router":
+ from api.routes.files import router
+
+ return router
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/src/backend/api/routes/v1/agent_api_keys.py b/src/backend/api/routes/v1/agent_api_keys.py
index 314ab6e6..8790488f 100644
--- a/src/backend/api/routes/v1/agent_api_keys.py
+++ b/src/backend/api/routes/v1/agent_api_keys.py
@@ -9,6 +9,7 @@
from core.auth.backend import UserContext, get_current_user
from core.auth.capabilities import resolve_user_capabilities
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import created_response, paginated_response, success_response
from core.auth.agent_api import list_agent_api_calls, require_agent_manager
from core.auth.api_keys import ApiKeyService
@@ -134,15 +135,16 @@ async def toggle_key(
key_id: str,
body: ToggleAgentApiKeyRequest,
user: UserContext = Depends(_manager),
- db: Session = Depends(get_db),
):
- _find(db, user, agent_id, key_id)
- row = ApiKeyService(db).set_enabled(user.user_id, key_id, body.enabled, agent_id)
- data = _dto(row)
+ def toggle(db):
+ _find(db, user, agent_id, key_id)
+ return _dto(ApiKeyService(db).set_enabled(user.user_id, key_id, body.enabled, agent_id))
+
+ data = await run_in_session(toggle)
if not body.enabled:
from core.auth.api_key_runs import stop_cancelled_key_workers
- await stop_cancelled_key_workers(db, key_id, user.user_id)
+ await stop_cancelled_key_workers(key_id, user.user_id)
return success_response(data=data)
@@ -151,13 +153,15 @@ async def revoke_key(
agent_id: str,
key_id: str,
user: UserContext = Depends(_manager),
- db: Session = Depends(get_db),
):
- _find(db, user, agent_id, key_id)
- ApiKeyService(db).revoke_key(user.user_id, key_id, agent_id)
+ def revoke(db):
+ _find(db, user, agent_id, key_id)
+ ApiKeyService(db).revoke_key(user.user_id, key_id, agent_id)
+
+ await run_in_session(revoke)
from core.auth.api_key_runs import stop_cancelled_key_workers
- await stop_cancelled_key_workers(db, key_id, user.user_id)
+ await stop_cancelled_key_workers(key_id, user.user_id)
return success_response(data={"id": key_id, "revoked": True})
diff --git a/src/backend/api/routes/v1/agent_responses.py b/src/backend/api/routes/v1/agent_responses.py
index 1eda2369..c6080a25 100644
--- a/src/backend/api/routes/v1/agent_responses.py
+++ b/src/backend/api/routes/v1/agent_responses.py
@@ -4,15 +4,14 @@
import api.transports.run_sse as run_sse
import core.chat.context as chat_context
-import core.db.engine as db_engine
import core.runs.runtime as runtime
import core.chat.service as chat_services_ChatService_module
from core.auth.backend import UserContext, get_current_user
from core.chat.schemas import ChatRequest, ChatResponse
-from core.db.engine import get_db
from core.infra.logging import get_logger
from core.infra.responses import sse_response
from core.chat.operations.admission import start_response_run as _start_response_run
+from core.db.threads import run_in_session
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
@@ -35,21 +34,24 @@ async def _wait_response(run_id: str, *, chat_id: str, message_id: str) -> ChatR
RuntimeError(terminal.error_message or "chat run failed")
),
)
- with db_engine.SessionLocal() as db:
- assistant = chat_services_ChatService_module.ChatService(db).get_message_by_id(message_id)
- if assistant is None:
- raise HTTPException(status_code=500, detail="已完成的任务缺少回复")
- meta = assistant.extra_data or {}
- return ChatResponse(
- chat_id=chat_id,
- response=assistant.content,
- timestamp=chat_context.now_iso(),
- is_markdown=bool(meta.get("is_markdown", False)),
- route=meta.get("route", "main"),
- sources=meta.get("sources", []),
- artifacts=meta.get("artifacts", []),
- warnings=meta.get("warnings", []),
- )
+ return await run_in_session(_completed_response, chat_id, message_id)
+
+
+def _completed_response(db: Session, chat_id: str, message_id: str) -> ChatResponse:
+ assistant = chat_services_ChatService_module.ChatService(db).get_message_by_id(message_id)
+ if assistant is None:
+ raise HTTPException(status_code=500, detail="已完成的任务缺少回复")
+ meta = assistant.extra_data or {}
+ return ChatResponse(
+ chat_id=chat_id,
+ response=assistant.content,
+ timestamp=chat_context.now_iso(),
+ is_markdown=bool(meta.get("is_markdown", False)),
+ route=meta.get("route", "main"),
+ sources=meta.get("sources", []),
+ artifacts=meta.get("artifacts", []),
+ warnings=meta.get("warnings", []),
+ )
@router.post(
@@ -66,14 +68,13 @@ async def _wait_response(run_id: str, *, chat_id: str, message_id: str) -> ChatR
async def agent_response(
request: ChatRequest,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
"""同一请求通过 stream 选择完整 JSON 或 SSE;两者共享后台 Run 和权限校验。
子智能体请求须显式传入 agent_id;专属 API Key 只能调用绑定的目标和 API 会话。SSE 断线可通过
GET /v1/chats/stream/{run_id}?from=N 续播;HTTP 断开不会取消后台任务。
"""
- run = await _start_response_run(request, user, db)
+ run = await _start_response_run(request, user)
if request.stream:
response = sse_response(run_sse.follow_run_as_sse(run.run_id, chat_id=run.chat_id))
# This is the user row committed by admission, available before any
diff --git a/src/backend/api/routes/v1/api_keys.py b/src/backend/api/routes/v1/api_keys.py
index b3547d96..2bc690f1 100644
--- a/src/backend/api/routes/v1/api_keys.py
+++ b/src/backend/api/routes/v1/api_keys.py
@@ -59,7 +59,7 @@ def _key_to_dict(row, *, plaintext: Optional[str] = None) -> dict:
@router.get("", summary="API-Key 列表")
-async def list_api_keys(
+def list_api_keys(
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
@@ -69,7 +69,7 @@ async def list_api_keys(
@router.post("", status_code=status.HTTP_201_CREATED, summary="新建 API-Key")
-async def create_api_key(
+def create_api_key(
body: CreateApiKeyRequest,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -90,7 +90,7 @@ async def create_api_key(
@router.get("/{key_id}/reveal", summary="再次取回 API-Key 明文")
-async def reveal_api_key(
+def reveal_api_key(
key_id: str,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -117,7 +117,7 @@ async def reveal_api_key(
@router.patch("/{key_id}", summary="启用/禁用 API-Key")
-async def toggle_api_key(
+def toggle_api_key(
key_id: str,
body: ToggleApiKeyRequest,
user: UserContext = Depends(get_current_user),
@@ -131,7 +131,7 @@ async def toggle_api_key(
@router.delete("/{key_id}", summary="撤销 API-Key")
-async def revoke_api_key(
+def revoke_api_key(
key_id: str,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
diff --git a/src/backend/api/routes/v1/artifacts.py b/src/backend/api/routes/v1/artifacts.py
index ec0b7472..44e9f5e5 100644
--- a/src/backend/api/routes/v1/artifacts.py
+++ b/src/backend/api/routes/v1/artifacts.py
@@ -16,6 +16,7 @@
from core.auth.backend import UserContext, get_current_user
from features.knowledge.processing import vectorise_document_background
from core.db.engine import SessionLocal, get_db
+from core.db.threads import run_in_session
from core.db.models import Artifact, ChatMessage, ChatSession, UserShadow
from features.knowledge.models import KBDocument, KBSpace
from core.db.paging import DEFAULT_PAGE_SIZE
@@ -259,36 +260,18 @@ def list_favorite_chats(
)
-@router.get("", summary="用户文件/图片列表")
-async def list_user_artifacts(
- type: Optional[str] = Query(None, description="document | image"),
- source_kind: Optional[str] = Query(None, description="user_upload | ai_generated"),
- keyword: Optional[str] = Query(None, description="文件名搜索"),
- scope: str = Depends(artifact_list_scope),
- folder_id: Optional[str] = Query(
- None,
- description="仅 personal scope 生效:__root__=个人根目录,=该个人文件夹直接子文件,省略=全部个人文件(向后兼容)",
- ),
- page: int = Query(1, ge=1),
- page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, description="每页条数,无上限"),
- user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
+def _artifact_page(
+ db: Session,
+ uid: str,
+ *,
+ type: Optional[str],
+ source_kind: Optional[str],
+ keyword: Optional[str],
+ scope: str,
+ folder_id: Optional[str],
+ page: int,
+ page_size: int,
):
- """获取当前用户有权查看的文件与图片。"""
- uid = str(user.user_id)
-
- # One-time backfill for historical data. It scans every message the account ever
- # wrote, so it never runs inline — doing that on the event loop froze the whole
- # backend for minutes. It also must not go through ``BackgroundTasks``: those run
- # on the shared request threadpool, which every endpoint needs via ``get_db``
- # (see the postmortem at the top of core/kb/index_queue.py). Detached to the
- # default executor instead. The rows it adds are
- # historical, so the next listing picks them up.
- if uid not in _backfilled_users:
- task = asyncio.create_task(asyncio.to_thread(_run_backfill_once, uid))
- _backfill_tasks.add(task)
- task.add_done_callback(_backfill_tasks.discard)
-
repo = ArtifactRepository(db)
mime_prefix = None
if type == "image":
@@ -341,6 +324,49 @@ async def list_user_artifacts(
}
items.append(extend_artifact_item(artifact, item))
+ return items, total
+
+
+@router.get("", summary="用户文件/图片列表")
+async def list_user_artifacts(
+ type: Optional[str] = Query(None, description="document | image"),
+ source_kind: Optional[str] = Query(None, description="user_upload | ai_generated"),
+ keyword: Optional[str] = Query(None, description="文件名搜索"),
+ scope: str = Depends(artifact_list_scope),
+ folder_id: Optional[str] = Query(
+ None,
+ description="仅 personal scope 生效:__root__=个人根目录,=该个人文件夹直接子文件,省略=全部个人文件(向后兼容)",
+ ),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, description="每页条数,无上限"),
+ user: UserContext = Depends(get_current_user),
+):
+ """获取当前用户有权查看的文件与图片。"""
+ uid = str(user.user_id)
+
+ # One-time backfill for historical data. It scans every message the account ever
+ # wrote, so it never runs inline — doing that on the event loop froze the whole
+ # backend for minutes. It also must not go through ``BackgroundTasks``: those run
+ # on the shared request threadpool, which every endpoint needs via ``get_db``
+ # (see the postmortem at the top of core/kb/index_queue.py). Detached to the
+ # default executor instead. The rows it adds are
+ # historical, so the next listing picks them up.
+ if uid not in _backfilled_users:
+ task = asyncio.create_task(asyncio.to_thread(_run_backfill_once, uid))
+ _backfill_tasks.add(task)
+ task.add_done_callback(_backfill_tasks.discard)
+
+ items, total = await run_in_session(
+ _artifact_page,
+ uid,
+ type=type,
+ source_kind=source_kind,
+ keyword=keyword,
+ scope=scope,
+ folder_id=folder_id,
+ page=page,
+ page_size=page_size,
+ )
return success_response(
data={
"items": items,
diff --git a/src/backend/api/routes/v1/audio.py b/src/backend/api/routes/v1/audio.py
index ac4d2f02..33a4c641 100644
--- a/src/backend/api/routes/v1/audio.py
+++ b/src/backend/api/routes/v1/audio.py
@@ -9,6 +9,7 @@
from core.auth.capabilities import resolve_user_capabilities
from core.auth.backend import UserContext, require_auth
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import success_response
from core.chat.audio_transcription import MAX_BYTES, MAX_SECONDS, resolve_audio_model, transcribe, validate_wav
@@ -47,11 +48,13 @@ async def _until_disconnect(request: Request):
@router.post("/transcriptions")
-async def audio_transcription(request: Request,
- user: UserContext = Depends(require_auth()), db: Session = Depends(get_db)):
- if not _can_use_voice(user, db):
- raise HTTPException(403, "未开通语音输入权限,请联系管理员")
- model = resolve_audio_model(db)
+async def audio_transcription(request: Request, user: UserContext = Depends(require_auth())):
+ def admit(db: Session):
+ if not _can_use_voice(user, db):
+ raise HTTPException(403, "未开通语音输入权限,请联系管理员")
+ return resolve_audio_model(db)
+
+ model = await run_in_session(admit)
if model is None:
raise HTTPException(503, "尚未配置音频转写模型,请管理员在模型管理中配置")
user_key = f"{user.user_id}:{user.public_chat_visitor or ''}"
diff --git a/src/backend/api/routes/v1/auth.py b/src/backend/api/routes/v1/auth.py
index 72d4eb3e..97d63acf 100644
--- a/src/backend/api/routes/v1/auth.py
+++ b/src/backend/api/routes/v1/auth.py
@@ -20,11 +20,11 @@
validate_session,
)
from core.config.settings import settings
-from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.logging import get_logger
from core.infra.responses import success_response
from core.auth.users import UserService
-from fastapi import APIRouter, Depends, HTTPException, Request, Response
+from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -80,32 +80,14 @@ def _serialize_user(db: Session, user_data: dict, ttl_seconds: Optional[int] = N
}
-@router.post("/ticket/exchange", summary="本地登录票据换取会话")
-async def ticket_exchange(
- body: TicketExchangeRequest,
- response: Response,
- db: Session = Depends(get_db),
-):
- credential = (body.code or "").strip()
- user_info = await consume_ticket(credential) if credential else None
- if user_info is None:
- raise HTTPException(
- status_code=401,
- detail={
- "code": 30002,
- "message": "Invalid or expired ticket",
- "data": {"login_url": _login_url()},
- },
- )
-
+def _bind_shadow(db: Session, user_info: dict) -> dict:
shadow = UserService(db).get_or_create_user_shadow(
user_center_id=user_info["user_center_id"],
username=user_info["username"],
email=user_info.get("email"),
avatar_url=user_info.get("avatar_url"),
)
- ttl_seconds = remember_ttl_seconds() if user_info.get("remember") else None
- session_data = {
+ return {
"user_id": shadow.user_id,
"user_center_id": shadow.user_center_id,
"username": shadow.username,
@@ -114,10 +96,28 @@ async def ticket_exchange(
"nickname": user_info.get("nickname"),
"real_name": user_info.get("real_name"),
}
+
+
+@router.post("/ticket/exchange", summary="本地登录票据换取会话")
+async def ticket_exchange(body: TicketExchangeRequest, response: Response):
+ credential = (body.code or "").strip()
+ user_info = await consume_ticket(credential) if credential else None
+ if user_info is None:
+ raise HTTPException(
+ status_code=401,
+ detail={
+ "code": 30002,
+ "message": "Invalid or expired ticket",
+ "data": {"login_url": _login_url()},
+ },
+ )
+
+ session_data = await run_in_session(_bind_shadow, user_info)
+ ttl_seconds = remember_ttl_seconds() if user_info.get("remember") else None
token = await create_session(session_data, ttl_seconds=ttl_seconds)
_set_session_cookie(response, token, ttl_seconds)
return success_response(
- data=_serialize_user(db, session_data, ttl_seconds),
+ data=await run_in_session(_serialize_user, session_data, ttl_seconds),
message="Login successful",
)
@@ -128,7 +128,7 @@ async def sso_authorize_url():
@router.get("/session/check", summary="检查当前会话状态")
-async def session_check(request: Request, db: Session = Depends(get_db)):
+async def session_check(request: Request):
token = request.cookies.get(settings.session.cookie_name)
user_data = await validate_session(token) if token else None
if user_data is None:
@@ -140,7 +140,9 @@ async def session_check(request: Request, db: Session = Depends(get_db)):
"data": {"login_url": _login_url()},
},
)
- return success_response(data=_serialize_user(db, user_data), message="Session valid")
+ return success_response(
+ data=await run_in_session(_serialize_user, user_data), message="Session valid"
+ )
@router.post("/desktop/handoff", summary="桌面端签发一次性会话交接票据")
diff --git a/src/backend/api/routes/v1/catalog.py b/src/backend/api/routes/v1/catalog.py
index 40408173..ed0ecbcb 100644
--- a/src/backend/api/routes/v1/catalog.py
+++ b/src/backend/api/routes/v1/catalog.py
@@ -10,7 +10,7 @@
from core.auth.backend import UserContext, get_current_user
from core.capabilities.catalog.runtime import get_runtime_catalog
from core.config.settings import settings
-from core.db.engine import get_db
+from core.db.threads import run_in_session
from features.knowledge.repository import KBRepository
from core.infra.exceptions import BadRequestError
from core.infra.responses import success_response
@@ -459,11 +459,11 @@ def merge_items(base_items: List[Dict], override_items: List[Dict]) -> List[Dict
@router.get("", summary="获取能力目录")
async def get_catalog_items(
- user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)
+ user: UserContext = Depends(get_current_user),
):
"""Build the synchronous catalog outside the Uvicorn event loop."""
await _ensure_external_cache_ready()
- return await run_in_threadpool(_get_catalog_items_sync, user, db)
+ return await run_in_session(lambda db: _get_catalog_items_sync(user, db))
diff --git a/src/backend/api/routes/v1/chats/confirms.py b/src/backend/api/routes/v1/chats/confirms.py
index 6705a825..d5b9714d 100644
--- a/src/backend/api/routes/v1/chats/confirms.py
+++ b/src/backend/api/routes/v1/chats/confirms.py
@@ -3,12 +3,10 @@
import core.chat.operations.schemas as chat_models
import api.routes.v1.chats.pending_access as chat_pending_access
import core.auth.backend as auth_backend
-import core.db.engine as db_engine
from core.auth.backend import UserContext
from core.infra.responses import success_response
from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.orm import Session
-from starlette.concurrency import run_in_threadpool
+from core.db.threads import run_in_session
router = APIRouter()
@@ -16,7 +14,6 @@
@router.get("/pending-confirms", summary="批量查询本人会话的待确认我的空间写操作")
async def list_pending_confirms(
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""前端刷新/首屏加载时一次性拉取——用于在侧边栏对应会话上点亮蓝点。
@@ -30,8 +27,7 @@ async def list_pending_confirms(
items = []
for cid in await _mc.list_pending_chat_ids_shared():
- if not await run_in_threadpool(
- chat_pending_access._owns_pending_chat, db, cid, user.user_id
+ if not await run_in_session(chat_pending_access._owns_pending_chat, cid, user.user_id
):
continue
# Cannot just take "the latest one" (get_pending): when the latest is a
@@ -52,11 +48,9 @@ async def list_pending_confirms(
async def get_pending_confirm(
chat_id: str,
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""前端刷新/切回该会话时恢复确认条(§13)。无待确认项时 pendings=[]。"""
- if not await run_in_threadpool(
- chat_pending_access._owns_pending_chat, db, chat_id, user.user_id
+ if not await run_in_session(chat_pending_access._owns_pending_chat, chat_id, user.user_id
):
raise HTTPException(status_code=404, detail="会话不存在或无权访问")
@@ -76,7 +70,6 @@ async def file_confirm(
chat_id: str,
body: chat_models.FileConfirmBody,
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""用户带外批准/拒绝一次对「我的空间」的 Write/Edit/Delete/Move(§13)。
@@ -84,8 +77,7 @@ async def file_confirm(
不重试整个操作;拒绝则模型据工具反馈调整后续行为。
"""
# Ownership check: must be the caller's own session
- if not await run_in_threadpool(
- chat_pending_access._owns_pending_chat, db, chat_id, user.user_id
+ if not await run_in_session(chat_pending_access._owns_pending_chat, chat_id, user.user_id
):
raise HTTPException(status_code=404, detail="会话不存在或无权访问")
@@ -103,8 +95,7 @@ async def file_confirm(
# server restart". The latter needs a clearer message for the user (not "confirmation
# timed out" but "session was interrupted, please resend"); the frontend shows a more
# prominent notice for it instead of silently dismissing the bar.
- chat_interrupted = await run_in_threadpool(
- chat_pending_access._detect_chat_run_interrupted, db, chat_id
+ chat_interrupted = await run_in_session(chat_pending_access._detect_chat_run_interrupted, chat_id
)
return success_response(
data={
diff --git a/src/backend/api/routes/v1/chats/pending_access.py b/src/backend/api/routes/v1/chats/pending_access.py
index 24ff246c..d7d73571 100644
--- a/src/backend/api/routes/v1/chats/pending_access.py
+++ b/src/backend/api/routes/v1/chats/pending_access.py
@@ -2,7 +2,6 @@
from datetime import datetime
-import core.chat.operations.session_context as chat_session_context
import core.chat.service as chat_services_ChatService_module
from sqlalchemy.orm import Session
@@ -41,7 +40,4 @@ def _detect_chat_run_interrupted(db: Session, chat_id: str) -> bool:
def _owns_pending_chat(db, chat_id, user_id):
- try:
- return chat_services_ChatService_module.ChatService(db).get_session(chat_id, user_id) is not None
- finally:
- chat_session_context.release_request_session(db)
+ return chat_services_ChatService_module.ChatService(db).get_session(chat_id, user_id) is not None
diff --git a/src/backend/api/routes/v1/chats/questions.py b/src/backend/api/routes/v1/chats/questions.py
index 2326ca26..1efddebd 100644
--- a/src/backend/api/routes/v1/chats/questions.py
+++ b/src/backend/api/routes/v1/chats/questions.py
@@ -3,13 +3,11 @@
import core.chat.operations.schemas as chat_models
import api.routes.v1.chats.pending_access as chat_pending_access
import core.auth.backend as auth_backend
-import core.db.engine as db_engine
from core.auth.backend import UserContext
from core.infra.logging import quiet_access_log
from core.infra.responses import success_response
from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.orm import Session
-from starlette.concurrency import run_in_threadpool
+from core.db.threads import run_in_session
router = APIRouter()
@@ -18,7 +16,6 @@
@quiet_access_log
async def list_pending_user_questions(
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""Restore sidebar waiting indicators without opening every conversation."""
@@ -26,8 +23,7 @@ async def list_pending_user_questions(
items = []
for chat_id in await user_questions.list_pending_chat_ids_shared():
- if not await run_in_threadpool(
- chat_pending_access._owns_pending_chat, db, chat_id, user.user_id
+ if not await run_in_session(chat_pending_access._owns_pending_chat, chat_id, user.user_id
):
continue
items.extend(
@@ -44,12 +40,10 @@ async def list_pending_user_questions(
async def get_pending_user_questions(
chat_id: str,
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""Return the authoritative pending queue for refresh/chat switching."""
- if not await run_in_threadpool(
- chat_pending_access._owns_pending_chat, db, chat_id, user.user_id
+ if not await run_in_session(chat_pending_access._owns_pending_chat, chat_id, user.user_id
):
raise HTTPException(status_code=404, detail="会话不存在或无权访问")
@@ -69,12 +63,10 @@ async def answer_user_question(
request_id: str,
body: chat_models.UserQuestionAnswerBody,
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""Validate the human answer and wake the exact suspended tool call."""
- if not await run_in_threadpool(
- chat_pending_access._owns_pending_chat, db, chat_id, user.user_id
+ if not await run_in_session(chat_pending_access._owns_pending_chat, chat_id, user.user_id
):
raise HTTPException(status_code=404, detail="会话不存在或无权访问")
@@ -88,8 +80,7 @@ async def answer_user_question(
if result.get("ok"):
return success_response(data=result)
if result.get("reason") == "stale":
- interrupted = await run_in_threadpool(
- chat_pending_access._detect_chat_run_interrupted, db, chat_id
+ interrupted = await run_in_session(chat_pending_access._detect_chat_run_interrupted, chat_id
)
return success_response(
data={
@@ -114,12 +105,10 @@ async def cancel_user_question(
chat_id: str,
request_id: str,
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""Cancel the pending request; the tool receives an explicit non-retry result."""
- if not await run_in_threadpool(
- chat_pending_access._owns_pending_chat, db, chat_id, user.user_id
+ if not await run_in_session(chat_pending_access._owns_pending_chat, chat_id, user.user_id
):
raise HTTPException(status_code=404, detail="会话不存在或无权访问")
@@ -128,8 +117,7 @@ async def cancel_user_question(
result = await user_questions.cancel_shared(chat_id, request_id)
if result.get("ok"):
return success_response(data=result)
- interrupted = await run_in_threadpool(
- chat_pending_access._detect_chat_run_interrupted, db, chat_id
+ interrupted = await run_in_session(chat_pending_access._detect_chat_run_interrupted, chat_id
)
return success_response(
data={
diff --git a/src/backend/api/routes/v1/chats/reruns.py b/src/backend/api/routes/v1/chats/reruns.py
index 1daa5622..a514a3cd 100644
--- a/src/backend/api/routes/v1/chats/reruns.py
+++ b/src/backend/api/routes/v1/chats/reruns.py
@@ -7,7 +7,6 @@
import core.auth.backend as auth_backend
import core.chat.context as chat_context
import core.chat.runtime.admission as admission
-import core.db.engine as db_engine
import core.infra.responses as responses
import core.chat.service as chat_services_ChatService_module
import core.auth.users as chat_services_UserService_module
@@ -24,7 +23,7 @@
from core.chat.references import render_reference_block
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
-from starlette.concurrency import run_in_threadpool
+from core.db.threads import run_in_session
router = APIRouter()
@@ -34,14 +33,13 @@ async def regenerate_message(
chat_id: str,
body: chat_models.RegenerateRequest,
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""Delete the target assistant message and all subsequent, then re-stream."""
if reserved_chat_owner(chat_id):
raise HTTPException(status_code=403, detail=reserved_chat_error(chat_id))
- def prepare():
+ def prepare(db: Session):
chat_session_context.ensure_main_model_configured()
chat_service = chat_services_ChatService_module.ChatService(db)
db_user_id = chat_context.resolve_db_user_id(
@@ -187,7 +185,7 @@ def prepare():
ChatSequencer(db).abandon_pending_run(accepted.run.run_id, reason=str(exc))
raise
- return await _start_prepared_run(prepare, db, chat_id)
+ return await _start_prepared_run(prepare, chat_id)
@router.post("/{chat_id}/edit", summary="编辑消息并重新生成 (SSE)")
@@ -195,14 +193,13 @@ async def edit_and_resend(
chat_id: str,
body: chat_models.EditAndResendRequest,
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""Delete the target user message and all subsequent, then re-stream with new content."""
if reserved_chat_owner(chat_id):
raise HTTPException(status_code=403, detail=reserved_chat_error(chat_id))
- def prepare():
+ def prepare(db: Session):
chat_session_context.ensure_main_model_configured()
chat_service = chat_services_ChatService_module.ChatService(db)
db_user_id = chat_context.resolve_db_user_id(
@@ -356,32 +353,19 @@ def prepare():
ChatSequencer(db).abandon_pending_run(accepted.run.run_id, reason=str(exc))
raise
- return await _start_prepared_run(prepare, db, chat_id)
+ return await _start_prepared_run(prepare, chat_id)
-async def _start_prepared_run(prepare, db: Session, chat_id: str):
- """Release admission locks before streaming; abandon admission if startup fails."""
+async def _start_prepared_run(prepare, chat_id: str):
+ """Admit on a session of its own, then launch; abandon admission if startup fails."""
from core.chat.sequencer import ChatSequencer
- pass
- def prepare_and_release():
- try:
- return prepare()
- finally:
- chat_session_context.release_request_session(db)
-
- kwargs = await run_in_threadpool(prepare_and_release)
+ kwargs = await run_in_session(prepare)
try:
run = await admission.start_run(**kwargs)
except Exception as exc:
reason = str(exc)
-
- def abandon():
- try:
- ChatSequencer(db).abandon_pending_run(kwargs["accepted_run"].run_id, reason=reason)
- finally:
- chat_session_context.release_request_session(db)
-
- await run_in_threadpool(abandon)
+ run_id = kwargs["accepted_run"].run_id
+ await run_in_session(lambda db: ChatSequencer(db).abandon_pending_run(run_id, reason=reason))
raise
return responses.sse_response(run_sse.follow_run_as_sse(run.run_id, chat_id=chat_id))
diff --git a/src/backend/api/routes/v1/chats/run_views.py b/src/backend/api/routes/v1/chats/run_views.py
index dc79e2c9..8e48f76a 100644
--- a/src/backend/api/routes/v1/chats/run_views.py
+++ b/src/backend/api/routes/v1/chats/run_views.py
@@ -44,17 +44,17 @@ def chat_stream_resume(
run_id: str,
from_offset: int = Query(0, alias="from", ge=0, description="从此 offset 之后继续推送"),
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""续播某个进行中的 run:刷新或断线后从指定 offset 之后重新订阅 SSE 事件。
校验 run 存在且归属当前用户(非属主 403、不存在 404),返回 text/event-stream。
"""
- pass
- db_user_id = chat_context.resolve_db_user_id(
- db, chat_session_context.authenticated_user_id(user)
- )
+ # A scoped session, not the request's: the stream below outlives this handler.
+ with db_engine.SessionLocal() as db:
+ db_user_id = chat_context.resolve_db_user_id(
+ db, chat_session_context.authenticated_user_id(user)
+ )
run = runtime.get_run(run_id)
# A module-owned chat's run is replayed by its module, not as an ordinary chat.
if run is None or reserved_chat_owner(run.chat_id):
@@ -62,7 +62,6 @@ def chat_stream_resume(
if run.user_id != db_user_id:
raise HTTPException(status_code=403, detail="无权访问该 run")
resumed_chat_id = run.chat_id
- chat_session_context.release_request_session(db)
return responses.sse_response(
run_sse.follow_run_as_sse(
run_id, chat_id=resumed_chat_id, from_offset=from_offset
@@ -91,15 +90,15 @@ def chat_active_run(
def chat_run_subscription(
run_id: str,
user: UserContext = Depends(auth_backend.get_current_user),
- db: Session = Depends(db_engine.get_db),
):
"""Native ordinary-chat snapshot + tail; the raw replay API is separate."""
- pass
from core.chat.projection.subscription import subscribe_run
- db_user_id = chat_context.resolve_db_user_id(
- db, chat_session_context.authenticated_user_id(user)
- )
+ # A scoped session, not the request's: the stream below outlives this handler.
+ with db_engine.SessionLocal() as db:
+ db_user_id = chat_context.resolve_db_user_id(
+ db, chat_session_context.authenticated_user_id(user)
+ )
run = runtime.get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail="run not found")
@@ -108,5 +107,4 @@ def chat_run_subscription(
payload = run.request_payload if isinstance(run.request_payload, dict) else {}
if payload.get("kind", "chat") != "chat":
raise HTTPException(status_code=400, detail="This run uses a specialized progress protocol")
- chat_session_context.release_request_session(db)
return responses.sse_response(subscribe_run(run_id))
diff --git a/src/backend/api/routes/v1/content.py b/src/backend/api/routes/v1/content.py
index ccf69a5b..7d22c719 100644
--- a/src/backend/api/routes/v1/content.py
+++ b/src/backend/api/routes/v1/content.py
@@ -12,7 +12,7 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
-from core.files.content.content_blocks_state import DEFAULT_APP_CONFIG, DOCS_BLOCK_MAP
+from core.files.content.content_blocks_constants import DEFAULT_APP_CONFIG, DOCS_BLOCK_MAP
from core.files.content.branding import normalize_app_config
from core.db.engine import get_db
from core.db.models import ContentBlock
@@ -22,7 +22,7 @@
@router.get("/docs", summary="获取文档内容块(前台读取)")
-async def get_docs_content(db: Session = Depends(get_db)):
+def get_docs_content(db: Session = Depends(get_db)):
"""前台读取全部可编辑内容块:功能更新时间轴、能力中心、提示词广场、页面配置与应用配置,
并附各块最近更新时间。无需鉴权,DB 缺失时对应字段返回空值。"""
updates_row = db.query(ContentBlock).filter(ContentBlock.id == "docs_updates").first()
@@ -45,7 +45,7 @@ async def get_docs_content(db: Session = Depends(get_db)):
@router.get("/docs/version", summary="获取各内容块最新更新时间(轻量轮询)")
-async def get_docs_versions(db: Session = Depends(get_db)):
+def get_docs_versions(db: Session = Depends(get_db)):
"""轻量轮询接口:仅返回各内容块的最新更新时间,供前端判断是否需要重新拉取正文。"""
rows = (
db.query(ContentBlock)
diff --git a/src/backend/api/routes/v1/evolution.py b/src/backend/api/routes/v1/evolution.py
index f165fdb2..d9bd40c6 100644
--- a/src/backend/api/routes/v1/evolution.py
+++ b/src/backend/api/routes/v1/evolution.py
@@ -93,7 +93,7 @@ async def list_my_candidates(user: UserContext = Depends(get_current_user)):
from other people's conversations activates for the whole fleet, and that is
not one person's call to make.
"""
- from core.evolution.profiles.user_settings_state import OWN_EVIDENCE_THRESHOLD
+ from core.evolution.profiles.user_settings_constants import OWN_EVIDENCE_THRESHOLD
from core.evolution.profiles.personal_review import pending_for_user
return success_response(
diff --git a/src/backend/api/routes/v1/file_upload.py b/src/backend/api/routes/v1/file_upload.py
index ef68ecf0..0cdc1d3f 100644
--- a/src/backend/api/routes/v1/file_upload.py
+++ b/src/backend/api/routes/v1/file_upload.py
@@ -138,7 +138,7 @@ def upload_user_file(
@router.put("/{file_id}", summary="覆盖已有文件内容")
-async def overwrite_file(
+def overwrite_file(
file_id: str,
file: UploadFile = File(...),
user: UserContext = Depends(get_current_user),
@@ -147,7 +147,7 @@ async def overwrite_file(
"""
用新内容覆盖已有文件,保持 file_id 和 download_url 不变。
"""
- file_bytes = await file.read()
+ file_bytes = file.file.read()
if not file_bytes:
raise HTTPException(status_code=400, detail="文件内容为空")
if len(file_bytes) > _MAX_UPLOAD_BYTES:
diff --git a/src/backend/api/routes/v1/internal_jobs.py b/src/backend/api/routes/v1/internal_jobs.py
index 022f3c24..5e8bb4ea 100644
--- a/src/backend/api/routes/v1/internal_jobs.py
+++ b/src/backend/api/routes/v1/internal_jobs.py
@@ -25,6 +25,7 @@
from fastapi import APIRouter, Header, HTTPException
from .job_callback_session import callback_session
+from core.db.threads import run_in_session
logger = logging.getLogger(__name__)
@@ -145,8 +146,10 @@ def validate_execution():
if remaining["seconds_left"] <= 0 or remaining["calls_left"] <= 0:
raise HTTPException(429, "作业执行预算已用尽")
- with SessionLocal() as db:
+ def _budget_left(db):
left = JobService(db).budget_left(job_id)
+ return left
+ left = await run_in_session(_budget_left)
if left["calls_left"] <= 0:
raise HTTPException(status_code=429, detail="作业子调用预算已用尽")
if left["seconds_left"] <= 0:
diff --git a/src/backend/api/routes/v1/loops.py b/src/backend/api/routes/v1/loops.py
index 37822cbe..ea0f2eb0 100644
--- a/src/backend/api/routes/v1/loops.py
+++ b/src/backend/api/routes/v1/loops.py
@@ -10,6 +10,7 @@
import orchestration.loops.admission as admission
from core.auth.backend import UserContext, get_current_user
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.logging import get_logger
from core.infra.responses import sse_response, success_response
from core.chat.service import ChatService
@@ -159,7 +160,8 @@ def get_iterations(
])
-async def _launch_loop(loop_id: str, req: StartLoopReq, db: Session, user: UserContext, is_resume: bool = False):
+def _launch_params(db: Session, loop_id: str, req: StartLoopReq, user: UserContext, is_resume: bool) -> dict:
+ """Bind the loop's chat and resolve its launch parameters (one session)."""
_require_loop_cap(db, user.user_id)
loop = LoopService(db).get_loop(loop_id, user_id=user.user_id)
if not loop:
@@ -206,7 +208,7 @@ async def _launch_loop(loop_id: str, req: StartLoopReq, db: Session, user: UserC
# The project the loop is bound to (stored in metadata at creation) — the
# worker/reviewer scope to the project folder based on it.
project_id = (loop.extra_data or {}).get("project_id") if loop.extra_data else None
- run = await admission.start_autonomous_loop_run(
+ return dict(
loop_id=loop_id,
chat_id=chat_id,
user_id=user.user_id,
@@ -222,6 +224,12 @@ async def _launch_loop(loop_id: str, req: StartLoopReq, db: Session, user: UserC
is_resume=is_resume,
project_id=project_id,
)
+
+
+async def _launch_loop(loop_id: str, req: StartLoopReq, user: UserContext, is_resume: bool = False):
+ params = await run_in_session(_launch_params, loop_id, req, user, is_resume)
+ chat_id = params["chat_id"]
+ run = await admission.start_autonomous_loop_run(**params)
return sse_response(
run_sse.follow_run_as_sse(
run.run_id,
@@ -237,24 +245,22 @@ async def _launch_loop(loop_id: str, req: StartLoopReq, db: Session, user: UserC
async def start_loop(
loop_id: str,
req: StartLoopReq,
- db: Session = Depends(get_db),
user: UserContext = Depends(get_current_user),
):
- return await _launch_loop(loop_id, req, db, user)
+ return await _launch_loop(loop_id, req, user)
@router.post("/{loop_id}/resume", summary="续跑循环(HITL 批准后 / 崩溃后,从 feature_list.json 断点续跑)")
async def resume_loop(
loop_id: str,
req: StartLoopReq,
- db: Session = Depends(get_db),
user: UserContext = Depends(get_current_user),
):
# Same entry point as start: when the driver detects a persisted
# feature_list.json it automatically resumes from the checkpoint.
# is_resume=True → don't re-record the objective as a user message again
# (it was already recorded on first start).
- return await _launch_loop(loop_id, req, db, user, is_resume=True)
+ return await _launch_loop(loop_id, req, user, is_resume=True)
class SteerLoopReq(BaseModel):
@@ -281,27 +287,34 @@ def steer_loop(
return success_response({"queued": True})
-@router.post("/{loop_id}/cancel", summary="取消循环")
-async def cancel_loop(
- loop_id: str,
- db: Session = Depends(get_db),
- user: UserContext = Depends(get_current_user),
-):
+def _cancel_target(db: Session, loop_id: str, user_id: str):
+ """The loop's active run id, or the settled response when there is none to cancel."""
# Normalize chat_id consistently with _launch_loop: prefer loop.chat_id,
# otherwise loopchat_{id}.
svc = LoopService(db)
- loop = svc.get_loop(loop_id, user_id=user.user_id)
+ loop = svc.get_loop(loop_id, user_id=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 = runtime.get_active_run_for_chat(chat_id, user_id=user.user_id)
- if not run:
- # 无活跃 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 runtime.cancel_run(run.run_id, user_id=user.user_id)
+ run = runtime.get_active_run_for_chat(chat_id, user_id=user_id)
+ if run:
+ return run.run_id, None
+ # 无活跃 run(崩溃遗留/已中断):把 loop 状态归位而不是 404——历史缺陷是
+ # 僵尸 running 行永远取消不掉。
+ if loop.status in ("running", "interrupted", "created", "awaiting_human"):
+ loop.status = "cancelled"
+ db.commit()
+ return None, {"cancelled": True, "note": "无活跃任务,已直接归位为 cancelled"}
+ return None, {"cancelled": False, "status": loop.status}
+
+
+@router.post("/{loop_id}/cancel", summary="取消循环")
+async def cancel_loop(
+ loop_id: str,
+ user: UserContext = Depends(get_current_user),
+):
+ run_id, settled = await run_in_session(_cancel_target, loop_id, user.user_id)
+ if settled is not None:
+ return success_response(settled)
+ ok = await runtime.cancel_run(run_id, user_id=user.user_id)
return success_response({"cancelled": ok})
diff --git a/src/backend/api/routes/v1/me_capabilities.py b/src/backend/api/routes/v1/me_capabilities.py
index 3414adb5..06dd48f7 100644
--- a/src/backend/api/routes/v1/me_capabilities.py
+++ b/src/backend/api/routes/v1/me_capabilities.py
@@ -25,6 +25,7 @@
from core.auth.capabilities import resolve_user_capabilities
from core.config.settings import settings
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.db.models import AdminMcpServer, AdminSkill
from core.infra.exceptions import AccessDeniedError, BadRequestError, ResourceNotFoundError
from core.infra.responses import created_response, success_response
@@ -69,14 +70,15 @@ class CreateUserMcpRequest(BaseModel):
async def create_my_mcp_server(
body: CreateUserMcpRequest,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
"""添加一个仅自己可见可用的远程 MCP 工具(HTTP/SSE)。
创建即试连:连不上返回 422,避免把不可达端点落库。出于安全,用户入口**不支持
stdio**(不能在服务器执行任意命令)。
"""
- _require_flag(str(user.user_id), db, "can_add_mcp", "自助添加 MCP")
+ await run_in_session(
+ lambda db: _require_flag(str(user.user_id), db, "can_add_mcp", "自助添加 MCP")
+ )
if not body.url.strip():
raise BadRequestError(message="url 不能为空")
@@ -100,7 +102,6 @@ async def create_my_mcp_server(
transport=body.transport,
url=body.url.strip(),
headers=body.headers or {},
- is_stable=False, # user-private MCPs don't enter the warmup connection pool
is_enabled=True,
owner_user_id=str(user.user_id),
icon=body.icon,
@@ -109,14 +110,18 @@ async def create_my_mcp_server(
created_by=str(user.user_id),
)
- ok, err = await probe_mcp_connectivity(row, db)
+ ok, err = await probe_mcp_connectivity(row, validate=True)
if not ok:
raise BadRequestError(message=f"MCP 连接失败,无法添加:{err}")
row.headers = encrypt_mcp_headers(body.headers)
- db.add(row)
- db.commit()
- db.refresh(row)
+
+ def save(db: Session) -> None:
+ db.add(row)
+ db.commit()
+ db.refresh(row)
+
+ await run_in_session(save)
refresh_mcp_caches()
return created_response(
@@ -159,7 +164,7 @@ def delete_my_mcp_server(
@router.post("/skills/upload", status_code=201, summary="自助上传私有技能")
-async def upload_my_skill(
+def upload_my_skill(
file: UploadFile = File(...),
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -171,14 +176,14 @@ async def upload_my_skill(
"""
if has_run_capabilities():
return created_response(
- data=local_editor.install_archive(str(user.user_id), await file.read())
+ data=local_editor.install_archive(str(user.user_id), file.file.read())
)
_require_flag(str(user.user_id), db, "can_add_skill", "自助添加技能")
if not file.filename or not file.filename.endswith(".zip"):
raise BadRequestError(message="仅接受 .zip 技能包")
- data = await file.read()
+ data = file.file.read()
if len(data) > USER_SKILL_MAX_BYTES:
raise BadRequestError(
message=f"技能包过大(上限 {USER_SKILL_MAX_BYTES // (1024 * 1024)}MB)"
diff --git a/src/backend/api/routes/v1/me_skill_files.py b/src/backend/api/routes/v1/me_skill_files.py
index eb819ad2..23a90d08 100644
--- a/src/backend/api/routes/v1/me_skill_files.py
+++ b/src/backend/api/routes/v1/me_skill_files.py
@@ -118,7 +118,7 @@ def delete_my_skill_file(
@router.post("/skills/{skill_id}/files/upload", status_code=201, summary="上传我的技能文件")
-async def upload_my_skill_file(
+def upload_my_skill_file(
skill_id: str,
file: UploadFile = File(...),
path: str = Form("", description="可选:存入的相对路径(含子目录),留空取上传文件名"),
@@ -127,7 +127,7 @@ async def upload_my_skill_file(
):
"""以 multipart 上传单个文件到自己的私有技能,二进制按 base64 标记安全存储。"""
if has_run_capabilities():
- return success_response(data=local_editor.file_change(str(user.user_id), skill_id, path or file.filename or "", await file.read()))
+ return success_response(data=local_editor.file_change(str(user.user_id), skill_id, path or file.filename or "", file.file.read()))
_require_flag(str(user.user_id), db, "can_add_skill", "自助添加技能")
from core.capabilities.skills.binary_files import encode_upload
@@ -135,7 +135,7 @@ async def upload_my_skill_file(
filename = validate_skill_file_path(path or file.filename or "")
if filename == "SKILL.md":
raise BadRequestError(message="SKILL.md 请在「编辑技能」表单中修改")
- raw = await file.read()
+ raw = file.file.read()
if len(raw) > USER_SKILL_FILE_MAX_BYTES:
raise BadRequestError(
message=f"文件过大(上限 {USER_SKILL_FILE_MAX_BYTES // (1024 * 1024)}MB)"
diff --git a/src/backend/api/routes/v1/me_system.py b/src/backend/api/routes/v1/me_system.py
index adaab250..c911f31b 100644
--- a/src/backend/api/routes/v1/me_system.py
+++ b/src/backend/api/routes/v1/me_system.py
@@ -23,7 +23,7 @@
from core.db.engine import get_db
from core.infra.responses import success_response
from core.infra.probes.service_probes import config_row_to_dict
-from core.llm.mcp.administration import reinitialize_mcp_pool
+from core.capabilities.mcp.service import McpServerConfigService
from core.infra.probes.service_probes import test_service_group
from core.config.store.system_config import SystemConfigService
from fastapi import APIRouter, Depends, HTTPException
@@ -130,7 +130,7 @@ async def update_personal_configs(
detail=f"配置项 {item.key} 不在个人可配置范围内",
)
svc.bulk_set([item.model_dump() for item in body.items], updated_by="me_system")
- asyncio.ensure_future(reinitialize_mcp_pool("me_system"))
+ McpServerConfigService.get_instance().invalidate_cache()
return success_response(data={"updated": len(body.items)})
diff --git a/src/backend/api/routes/v1/memories.py b/src/backend/api/routes/v1/memories.py
index cfb5f0ff..37c2db43 100644
--- a/src/backend/api/routes/v1/memories.py
+++ b/src/backend/api/routes/v1/memories.py
@@ -24,6 +24,7 @@
from core.auth.backend import UserContext, get_current_user
from core.config.settings import settings as _jx_settings
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import error_response, success_response
from core.memory.context import MemoryContext
from core.memory.access import authorized_memory_context
@@ -138,13 +139,37 @@ def update_memory_settings(
# ── List / clear / delete single ────────────────────────────────
+def _list_scope(db: Session, user_id: str, project_id: Optional[str]):
+ """``(read enabled, memory owner)`` for the list view; None when the project is not visible.
+
+ In a project the project's own ``memory_enabled`` applies (its owner holds the
+ memories); otherwise the user's setting does.
+ """
+ if project_id:
+ from core.auth.edition import resolve_project_access
+ from core.db.models import Project
+
+ project = db.query(Project).filter_by(project_id=project_id, deleted_at=None).first()
+ if project is None or resolve_project_access(db, user_id, project).level == "none":
+ return None
+ return _graph_scope(db, user_id, project_id)
+
+
+def _graph_scope(db: Session, user_id: str, project_id: Optional[str]):
+ if project_id:
+ from core.projects.edition import project_memory_policy
+
+ return project_memory_policy(db, project_id, user_id) or (False, user_id)
+ settings = UserService(db).get_user_settings(user_id)
+ return bool(settings.get("memory_enabled", False)), user_id
+
+
@router.get("", summary="查询长期记忆列表")
async def list_memories(
project_id: Optional[str] = Query(
None, description="若指定,只返回属于该项目 workspace 的记忆"
),
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
"""L2 长期记忆列表(mem0/Milvus 向量)——只存"做法/口径",不存事实。
@@ -161,23 +186,10 @@ async def list_memories(
if not _jx_settings.memory.enabled:
return success_response(data={"enabled": False, "items": [], "count": 0})
- scope_user_id = str(user.user_id)
- if project_id:
- from core.projects.edition import project_memory_policy
- from core.auth.edition import resolve_project_access
- from core.db.models import Project
-
- project = db.query(Project).filter_by(project_id=project_id, deleted_at=None).first()
- if project is None or resolve_project_access(db, scope_user_id, project).level == "none":
- return error_response(code=40401, message="项目不存在", status_code=404)
- policy = project_memory_policy(db, project_id, scope_user_id)
- if policy is None:
- return success_response(data={"enabled": False, "items": [], "count": 0})
- ws_enabled, scope_user_id = policy
- else:
- settings = UserService(db).get_user_settings(str(user.user_id))
- ws_enabled = bool(settings.get("memory_enabled", False))
-
+ scope = await run_in_session(_list_scope, str(user.user_id), project_id)
+ if scope is None:
+ return error_response(code=40401, message="项目不存在", status_code=404)
+ ws_enabled, scope_user_id = scope
if not ws_enabled:
return success_response(data={"enabled": False, "items": [], "count": 0})
@@ -452,28 +464,15 @@ async def get_memory_graph(
limit: int = Query(30, ge=1, le=200, description="返回关系条数"),
project_id: Optional[str] = Query(None, description="若指定,返回该项目作用域的关系"),
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
"""L3 图谱记忆:当前用户或项目作用域的实体关系(Neo4j)。"""
if not (_jx_settings.memory.enabled and _jx_settings.memory.graph_enabled):
return success_response(data={"enabled": False, "relations": [], "count": 0})
- scope_user_id = str(user.user_id)
- workspace_id = "default"
- if project_id:
- from core.projects.edition import project_memory_policy
-
- policy = project_memory_policy(db, project_id, scope_user_id)
- if policy is None:
- return success_response(data={"enabled": False, "relations": [], "count": 0})
- ws_enabled, scope_user_id = policy
- if not ws_enabled:
- return success_response(data={"enabled": False, "relations": [], "count": 0})
- workspace_id = f"project:{project_id}"
- else:
- user_settings = UserService(db).get_user_settings(str(user.user_id))
- if not bool(user_settings.get("memory_enabled", False)):
- return success_response(data={"enabled": False, "relations": [], "count": 0})
+ ws_enabled, scope_user_id = await run_in_session(_graph_scope, str(user.user_id), project_id)
+ if not ws_enabled:
+ return success_response(data={"enabled": False, "relations": [], "count": 0})
+ workspace_id = f"project:{project_id}" if project_id else "default"
relations = await list_graph_relations(
scope_user_id,
diff --git a/src/backend/api/routes/v1/mock_sso.py b/src/backend/api/routes/v1/mock_sso.py
index 1baf8598..3e46d3ca 100644
--- a/src/backend/api/routes/v1/mock_sso.py
+++ b/src/backend/api/routes/v1/mock_sso.py
@@ -1610,30 +1610,27 @@ def _bounce(msg: str) -> RedirectResponse:
return _bounce("两次输入的密码不一致")
try:
- from core.db.engine import SessionLocal
from core.auth.local_users import LocalUserService
+ from core.db.threads import run_in_session
except Exception as exc:
logger.warning("local auth module unavailable: %s", exc)
return _bounce("注册服务暂不可用")
- db = SessionLocal()
try:
- service = LocalUserService(db)
- result = service.register(
- code=code,
- username=username,
- nickname=nickname,
- email=email,
- password=password,
- real_name=real_name,
- phone=phone,
+ result = await run_in_session(
+ lambda db: LocalUserService(db).register(
+ code=code,
+ username=username,
+ nickname=nickname,
+ email=email,
+ password=password,
+ real_name=real_name,
+ phone=phone,
+ )
)
except Exception as exc: # noqa: BLE001
logger.exception("register failed: %s", exc)
- db.close()
return _bounce("注册失败,请稍后重试")
- finally:
- db.close()
if not result.ok or not result.user_info:
return _bounce(result.message or "注册失败")
diff --git a/src/backend/api/routes/v1/model_reasoning.py b/src/backend/api/routes/v1/model_reasoning.py
index e49fe898..1dcb2cd5 100644
--- a/src/backend/api/routes/v1/model_reasoning.py
+++ b/src/backend/api/routes/v1/model_reasoning.py
@@ -2,10 +2,9 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
-from sqlalchemy.orm import Session
from api.deps import require_system_settings
-from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.models.repository import get_provider
from core.infra.responses import success_response
from core.models.providers.reasoning_probe import discover_reasoning_efforts
@@ -26,11 +25,10 @@ class DetectReasoningRequest(BaseModel):
async def detect_reasoning_levels(
body: DetectReasoningRequest,
_: None = Depends(require_system_settings),
- db: Session = Depends(get_db),
):
api_key = body.api_key
if body.provider_id and not api_key:
- existing = get_provider(db, body.provider_id)
+ existing = await run_in_session(get_provider, body.provider_id)
if existing is None:
raise HTTPException(status_code=404, detail="Provider not found")
api_key = existing.api_key or ""
diff --git a/src/backend/api/routes/v1/models.py b/src/backend/api/routes/v1/models.py
index b2627ea0..d259055d 100644
--- a/src/backend/api/routes/v1/models.py
+++ b/src/backend/api/routes/v1/models.py
@@ -27,9 +27,9 @@
from api.deps import require_config, require_system_settings
from core.auth.backend import UserContext, require_auth
-from core.auth.backend import UserContext, require_auth
from core.config.settings import settings
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.models.config import ModelConfigService
from core.models.repository import ROLE_DEFINITIONS, assign_role, create_provider, delete_provider, export_all, get_provider, import_all, list_providers, list_role_assignments, provider_has_capability, provider_is_referenced, role_required_capability, set_provider_test_result, unassign_role, update_provider
from core.infra.responses import success_response
@@ -125,16 +125,16 @@ class ImportRequest(BaseModel):
def _invalidate_model_caches() -> None:
- """Single invalidation point for model-config changes: the resolve cache plus
- every runtime singleton built from it.
-
- The mem0 singleton captures the memory/embedding config at init time; clearing
- the resolve cache alone leaves memory running on stale credentials (a first
- init against the placeholder key means every later write fails 401). The reset
- is lazy — the next memory read/write rebuilds against the fresh config — so it
- is cheap enough to apply on every change without filtering by role.
+ """Single invalidation point for runtime singletons built from model config.
+
+ The resolve cache needs nothing here: every repository write bumps the
+ committed revision, which each lookup checks. The mem0 singleton, though,
+ captures the memory/embedding config at init time and would keep running on
+ stale credentials (a first init against the placeholder key means every
+ later write fails 401). The reset is lazy — the next memory read/write
+ rebuilds against the fresh config — so it is cheap enough to apply on every
+ change without filtering by role.
"""
- ModelConfigService.get_instance().invalidate_cache()
try:
from core.memory import service as memory_service
@@ -594,7 +594,6 @@ def list_providers_endpoint(
async def create_provider_endpoint(
body: ProviderCreateRequest,
_: None = Depends(require_system_settings),
- db: Session = Depends(get_db),
):
"""新增一个模型供应商。仅限管理员;保存前会校验厂商字段并做连通性预校验,失败则返回 400,成功后刷新模型配置缓存。"""
from core.models.providers.registry import validate_payload
@@ -629,32 +628,41 @@ async def create_provider_endpoint(
body.extra_config,
)
- provider = create_provider(
- db,
- display_name=body.display_name,
- provider_type=body.provider_type,
- provider=body.provider,
- base_url=normalized_url,
- api_key=body.api_key,
- model_name=body.model_name,
- gateway_group=body.gateway_group,
- weight=body.weight,
- priority=body.priority,
- extra_config=body.extra_config,
- is_active=body.is_active,
- )
- given = body.model_dump(exclude_unset=True)
- if any(k in given for k in ("input_price", "output_price", "currency")):
- _upsert_pricing(
+ def save(db: Session) -> dict:
+ provider = create_provider(
db,
- model_name=provider.model_name,
- input_price=body.input_price,
- output_price=body.output_price,
- currency=body.currency,
display_name=body.display_name,
+ provider_type=body.provider_type,
+ provider=body.provider,
+ base_url=normalized_url,
+ api_key=body.api_key,
+ model_name=body.model_name,
+ gateway_group=body.gateway_group,
+ weight=body.weight,
+ priority=body.priority,
+ extra_config=body.extra_config,
+ is_active=body.is_active,
)
+ given = body.model_dump(exclude_unset=True)
+ if any(k in given for k in ("input_price", "output_price", "currency")):
+ _upsert_pricing(
+ db,
+ model_name=provider.model_name,
+ input_price=body.input_price,
+ output_price=body.output_price,
+ currency=body.currency,
+ display_name=body.display_name,
+ )
+ return _provider_to_dict(provider, _get_pricing(db, provider.model_name))
+
+ data = await run_in_session(save)
_invalidate_model_caches()
- return success_response(data=_provider_to_dict(provider, _get_pricing(db, provider.model_name)))
+ return success_response(data=data)
+
+
+def _provider_and_roles(db: Session, provider_id: str):
+ existing = get_provider(db, provider_id)
+ return existing, (provider_is_referenced(db, provider_id) if existing is not None else [])
@router.put("/providers/{provider_id}", summary="更新模型供应商")
@@ -662,7 +670,6 @@ async def update_provider_endpoint(
provider_id: str,
body: ProviderUpdateRequest,
_: None = Depends(require_system_settings),
- db: Session = Depends(get_db),
):
"""更新指定供应商的配置(仅传需修改的字段)。仅限管理员;若改动了 base_url/api_key/模型名会重新做连通性校验,成功后刷新模型配置缓存;供应商不存在返回 404。"""
fields = {k: v for k, v in body.model_dump().items() if v is not None}
@@ -672,7 +679,7 @@ async def update_provider_endpoint(
}
# If URL or credentials changed, validate the new config
- existing = get_provider(db, provider_id)
+ existing, roles = await run_in_session(_provider_and_roles, provider_id)
if existing is None:
raise HTTPException(status_code=404, detail="Provider not found")
@@ -683,7 +690,6 @@ async def update_provider_endpoint(
new_provider = fields.get("provider", getattr(existing, "provider", "openai_compatible"))
new_extra = fields.get("extra_config", existing.extra_config or {})
if new_type != existing.provider_type:
- roles = provider_is_referenced(db, provider_id)
if any(ROLE_DEFINITIONS.get(role, {}).get("type") != new_type for role in roles):
raise HTTPException(400, "请先取消该模型的角色分配,再修改模型类型")
@@ -723,18 +729,22 @@ async def update_provider_endpoint(
fields["extra_config"],
)
- provider = update_provider(db, provider_id, **fields)
- if provider is None:
- raise HTTPException(status_code=404, detail="Provider not found")
- if price_fields:
- _upsert_pricing(
- db,
- model_name=provider.model_name,
- display_name=provider.display_name,
- **price_fields,
- )
+ def save(db: Session) -> dict:
+ provider = update_provider(db, provider_id, **fields)
+ if provider is None:
+ raise HTTPException(status_code=404, detail="Provider not found")
+ if price_fields:
+ _upsert_pricing(
+ db,
+ model_name=provider.model_name,
+ display_name=provider.display_name,
+ **price_fields,
+ )
+ return _provider_to_dict(provider, _get_pricing(db, provider.model_name))
+
+ data = await run_in_session(save)
_invalidate_model_caches()
- return success_response(data=_provider_to_dict(provider, _get_pricing(db, provider.model_name)))
+ return success_response(data=data)
@router.delete("/providers/{provider_id}", summary="删除模型供应商")
@@ -763,10 +773,9 @@ def delete_provider_endpoint(
async def test_saved_provider(
provider_id: str,
_: None = Depends(require_system_settings),
- db: Session = Depends(get_db),
):
"""对已保存的供应商发起一次实际连通性测试。仅限管理员;会把测试结果(成功/失败)回写到供应商记录,并返回耗时与错误信息;不存在返回 404。"""
- provider = get_provider(db, provider_id)
+ provider = await run_in_session(get_provider, provider_id)
if provider is None:
raise HTTPException(status_code=404, detail="Provider not found")
@@ -780,7 +789,7 @@ async def test_saved_provider(
provider.model_name,
provider.extra_config or {},
)
- set_provider_test_result(db, provider_id, result["success"])
+ await run_in_session(set_provider_test_result, provider_id, result["success"])
return success_response(data=result)
@@ -806,7 +815,6 @@ async def test_unsaved_provider(
async def detect_context_length(
body: DetectContextRequest,
_: None = Depends(require_system_settings),
- db: Session = Depends(get_db),
):
"""探测模型真实上下文窗口(token)供表单回填。仅限管理员;不落库。
@@ -817,7 +825,7 @@ async def detect_context_length(
api_key = body.api_key
if not api_key and body.provider_id:
- existing = get_provider(db, body.provider_id)
+ existing = await run_in_session(get_provider, body.provider_id)
if existing is not None:
api_key = existing.api_key or ""
@@ -906,7 +914,6 @@ def unassign_role_endpoint(
async def _require_model_export_access(
request: Request,
authorization: Optional[str] = Header(None),
- db: Session = Depends(get_db),
user: Optional[UserContext] = Depends(require_auth(False)),
) -> None:
"""Default to config access; optionally allow authenticated desktop sync.
@@ -916,7 +923,7 @@ async def _require_model_export_access(
scope = (os.getenv("DESKTOP_MODEL_SYNC_SCOPE") or "").strip().lower()
if scope == "authenticated" and user is not None:
return
- await require_config(request, authorization=authorization, db=db)
+ await require_config(request, authorization=authorization)
@router.get("/export", summary="导出模型配置")
@@ -931,11 +938,11 @@ def export_endpoint(
return success_response(data=export_all(db))
-async def _require_import_control(request: Request, db: Session = Depends(get_db)):
+async def _require_import_control(request: Request):
from features.desktop.capability.service import is_desktop_shell_control
if is_desktop_shell_control(request.headers.get("authorization", ""), request.headers.get("origin")):
return
- await require_config(request=request, authorization=request.headers.get("authorization"), db=db)
+ await require_config(request=request, authorization=request.headers.get("authorization"))
@router.post("/import", summary="导入模型配置")
diff --git a/src/backend/api/routes/v1/ontologies.py b/src/backend/api/routes/v1/ontologies.py
index 0ba476c3..3bd748e1 100644
--- a/src/backend/api/routes/v1/ontologies.py
+++ b/src/backend/api/routes/v1/ontologies.py
@@ -694,11 +694,10 @@ def review_ontology_draft(
)
async def generate_ontology_evolution_drafts(
body: OntologyEvolutionGenerateRequest,
- db: Session = Depends(get_db),
):
- from core.ontology.evolution import OntologyEvolutionService
+ from core.ontology.evolution import generate_candidates
- result = await OntologyEvolutionService(db).generate_candidates(
+ result = await generate_candidates(
min_occurrences=body.min_occurrences,
limit=body.limit,
model_name=body.model_name,
diff --git a/src/backend/api/routes/v1/plans.py b/src/backend/api/routes/v1/plans.py
index 0e66a6b5..dca2b637 100644
--- a/src/backend/api/routes/v1/plans.py
+++ b/src/backend/api/routes/v1/plans.py
@@ -8,6 +8,7 @@
from core.auth.backend import UserContext, get_current_user
from core.chat.context import generate_smart_title
from core.db.engine import get_db
+from core.db.threads import run_db, run_in_session
from core.infra.logging import get_logger
from core.infra.responses import sse_response, success_response
from core.chat.service import ChatService
@@ -176,63 +177,65 @@ def _load_chat_history(
async def generate_plan(
req: GeneratePlanRequest,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
"""SSE stream — generate a structured plan from a task description.
后台 task 跑生成工作流,HTTP 连接断开(刷新页面)任务继续;前端可通过
``GET /v1/chats/{chat_id}/active-run`` 探测,结束后从消息列表拿到完整 plan。
"""
- db_chat_id = _ensure_plan_session(
- db,
- req.chat_id,
- user.user_id,
- req.task_description,
- project_id=req.project_id,
- )
- if not db_chat_id:
- raise HTTPException(status_code=400, detail="chat_id 必填,无法创建后台生成任务")
- try:
- selected_model_provider_id = resolve_user_model_provider_id(
- db, req.model_provider_id, user_id=user.user_id
- )
- except UserModelSelectionError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- actual_model_name = resolve_effective_chat_model_name(
- selected_model_provider_id,
- fallback_model_name=req.model_name,
- )
- # On automatic entry into plan mode (enter_plan_mode), task_description is an
- # AI-expanded internal prompt and is NOT persisted as a user message — the original
- # user request and that main-agent turn are already in the same session history;
- # storing another copy would make a refresh display the internal prompt as a user
- # bubble. Manual plan mode (typed by the user) echoes normally.
- if not req.suppress_user_echo:
- _save_plan_message(
+ def prepare(db: Session):
+ db_chat_id = _ensure_plan_session(
db,
- db_chat_id,
- "user",
+ req.chat_id,
+ user.user_id,
req.task_description,
- model=actual_model_name,
- extra_data=(
- {"model_provider_id": selected_model_provider_id}
- if selected_model_provider_id
- else None
- ),
+ project_id=req.project_id,
+ )
+ if not db_chat_id:
+ raise HTTPException(status_code=400, detail="chat_id 必填,无法创建后台生成任务")
+ try:
+ selected_model_provider_id = resolve_user_model_provider_id(
+ db, req.model_provider_id, user_id=user.user_id
+ )
+ except UserModelSelectionError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ actual_model_name = resolve_effective_chat_model_name(
+ selected_model_provider_id,
+ fallback_model_name=req.model_name,
)
- session_messages = _load_chat_history(db, req.chat_id, user.user_id, req.history_messages)
- logger.warning(
- "[plan-generate] chat_id=%s, loaded %d history messages", req.chat_id, len(session_messages)
- )
+ # On automatic entry into plan mode (enter_plan_mode), task_description is an
+ # AI-expanded internal prompt and is NOT persisted as a user message — the original
+ # user request and that main-agent turn are already in the same session history;
+ # storing another copy would make a refresh display the internal prompt as a user
+ # bubble. Manual plan mode (typed by the user) echoes normally.
+ if not req.suppress_user_echo:
+ _save_plan_message(
+ db,
+ db_chat_id,
+ "user",
+ req.task_description,
+ model=actual_model_name,
+ extra_data=(
+ {"model_provider_id": selected_model_provider_id}
+ if selected_model_provider_id
+ else None
+ ),
+ )
+
+ session_messages = _load_chat_history(db, req.chat_id, user.user_id, req.history_messages)
+ logger.warning(
+ "[plan-generate] chat_id=%s, loaded %d history messages", req.chat_id, len(session_messages)
+ )
+ return db_chat_id, actual_model_name, selected_model_provider_id, session_messages
+
+ db_chat_id, actual_model_name, selected_model_provider_id, session_messages = await run_in_session(prepare)
uploaded_files = None
if req.attachments:
uploaded_files = [a.model_dump() for a in req.attachments if a.file_id]
- pass
-
run = await admission.start_plan_generate_run(
chat_id=db_chat_id,
user_id=user.user_id,
@@ -350,7 +353,6 @@ async def execute_plan(
plan_id: str,
req: ExecutePlanRequest = ExecutePlanRequest(),
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
"""SSE stream — execute an approved plan step by step.
@@ -358,32 +360,35 @@ async def execute_plan(
断开(用户刷新页面)不会取消任务,前端可通过 ``GET /v1/chats/{chat_id}/active-run``
+ ``GET /v1/chats/stream/{run_id}`` 续播。
"""
- svc = PlanService(db)
- plan = svc.get_plan(plan_id, user.user_id)
- if not plan:
- raise HTTPException(status_code=404, detail="计划不存在")
- if plan.status != "approved":
- raise HTTPException(status_code=400, detail=f"计划状态为 '{plan.status}',需要先审批")
-
- # Ensure chat session and save "确认执行" user message
- db_chat_id = _ensure_plan_session(
- db,
- req.chat_id,
- user.user_id,
- plan.title,
- project_id=req.project_id,
- )
- if not db_chat_id:
- raise HTTPException(status_code=400, detail="chat_id 必填,无法创建后台执行任务")
- actual_model_name = resolve_effective_chat_model_name() or "qwen"
- _save_plan_message(db, db_chat_id, "user", "确认执行", model=actual_model_name)
-
- session_messages = _load_chat_history(db, req.chat_id, user.user_id, req.history_messages)
- logger.warning(
- "[plan-execute] chat_id=%s, loaded %d history messages", req.chat_id, len(session_messages)
- )
- pass
+ def prepare(db: Session):
+ svc = PlanService(db)
+ plan = svc.get_plan(plan_id, user.user_id)
+ if not plan:
+ raise HTTPException(status_code=404, detail="计划不存在")
+ if plan.status != "approved":
+ raise HTTPException(status_code=400, detail=f"计划状态为 '{plan.status}',需要先审批")
+
+ # Ensure chat session and save "确认执行" user message
+ db_chat_id = _ensure_plan_session(
+ db,
+ req.chat_id,
+ user.user_id,
+ plan.title,
+ project_id=req.project_id,
+ )
+ if not db_chat_id:
+ raise HTTPException(status_code=400, detail="chat_id 必填,无法创建后台执行任务")
+ actual_model_name = resolve_effective_chat_model_name() or "qwen"
+ _save_plan_message(db, db_chat_id, "user", "确认执行", model=actual_model_name)
+
+ session_messages = _load_chat_history(db, req.chat_id, user.user_id, req.history_messages)
+ logger.warning(
+ "[plan-execute] chat_id=%s, loaded %d history messages", req.chat_id, len(session_messages)
+ )
+ return db_chat_id, actual_model_name, session_messages
+
+ db_chat_id, actual_model_name, session_messages = await run_in_session(prepare)
run = await admission.start_plan_execute_run(
plan_id=plan_id,
@@ -414,22 +419,21 @@ async def execute_plan(
async def cancel_plan(
plan_id: str,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
"""Cancel a running plan — 同时杀掉关联的后台 chat_run(如果有)。"""
- svc = PlanService(db)
- plan = svc.get_plan(plan_id, user.user_id)
- if not plan:
- raise HTTPException(status_code=404, detail="计划不存在")
- if plan.status not in ("running", "approved", "draft"):
- raise HTTPException(status_code=400, detail=f"计划状态为 '{plan.status}',无法取消")
+ def cancel(db: Session) -> None:
+ svc = PlanService(db)
+ plan = svc.get_plan(plan_id, user.user_id)
+ if not plan:
+ raise HTTPException(status_code=404, detail="计划不存在")
+ if plan.status not in ("running", "approved", "draft"):
+ raise HTTPException(status_code=400, detail=f"计划状态为 '{plan.status}',无法取消")
+ svc.update_plan(plan_id, status="cancelled")
- svc.update_plan(plan_id, status="cancelled")
+ await run_in_session(cancel)
# Also cancel the background run in lockstep (only exists during the plan execute phase)
- pass
-
- run = admission.get_run_by_plan_id(plan_id)
+ run = await run_db(admission.get_run_by_plan_id, plan_id)
if run is not None:
try:
await runtime.cancel_run(run.run_id, user_id=user.user_id)
diff --git a/src/backend/api/routes/v1/plugin_resources.py b/src/backend/api/routes/v1/plugin_resources.py
index f223205a..46f3a0a7 100644
--- a/src/backend/api/routes/v1/plugin_resources.py
+++ b/src/backend/api/routes/v1/plugin_resources.py
@@ -6,6 +6,7 @@
from sqlalchemy.orm import Session
from core.auth.backend import UserContext, get_current_user
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import success_response
from core.capabilities.plugins.resources import service, store
from core.capabilities.plugins.resources.stream import connect
@@ -44,34 +45,38 @@ def delete_checkpoint(checkpoint_id: str, user: UserContext = Depends(get_curren
return success_response(data={"deleted": True})
@router.post("/plugins/{slug}/resources/{module_id}")
-async def open_resource(slug: str, module_id: str, body: OpenRequest, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)):
- result = await service.create(db, slug, module_id, str(user.user_id), **body.model_dump())
+async def open_resource(slug: str, module_id: str, body: OpenRequest, user: UserContext = Depends(get_current_user)):
+ result = await service.create(slug, module_id, str(user.user_id), **body.model_dump())
return success_response(data={"resource": result})
-@router.get("/chats/{chat_id}/plugin-resources")
-async def chat_resources(chat_id: str, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)):
- import asyncio
+def _chat_resource_ids(db, chat_id, uid):
from core.db.models import ChatSession
chat = db.get(ChatSession, chat_id)
- uid = str(user.user_id)
if chat is None or chat.user_id != uid or chat.deleted_at is not None:
raise HTTPException(404, "conversation_unavailable")
- rows = db.query(store.PluginResource).filter_by(user_id=uid, chat_id=chat_id, status="active").limit(8).all()
- async def live(row):
+ rows = db.query(store.PluginResource.resource_id).filter_by(user_id=uid, chat_id=chat_id, status="active").limit(8)
+ return [resource_id for (resource_id,) in rows]
+
+@router.get("/chats/{chat_id}/plugin-resources")
+async def chat_resources(chat_id: str, user: UserContext = Depends(get_current_user)):
+ import asyncio
+ uid = str(user.user_id)
+ resource_ids = await run_in_session(_chat_resource_ids, chat_id, uid)
+ async def live(resource_id):
try:
- service.authorized(db, row.resource_id, uid)
+ row = await run_in_session(service.authorized_row, resource_id, uid)
state = await asyncio.wait_for(service.request(row, "/state"), 5)
if state.get("closed"):
return None
return store.public(row)
except (HTTPException, TimeoutError):
return None
- items = await asyncio.gather(*(live(row) for row in rows))
+ items = await asyncio.gather(*(live(resource_id) for resource_id in resource_ids))
return success_response(data={"items": [item for item in items if item is not None]})
@router.get("/plugin-resources/{resource_id}")
-async def get_resource(resource_id: str, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)):
- row = service.authorized(db, resource_id, str(user.user_id))
+async def get_resource(resource_id: str, user: UserContext = Depends(get_current_user)):
+ row = await run_in_session(service.authorized_row, resource_id, str(user.user_id))
state = await service.request(row, "/state")
return success_response(data={"resource": store.public(row), "state": state})
@@ -102,14 +107,8 @@ def issue_asset_ticket(resource_id: str, request: Request, user: UserContext = D
from core.capabilities.plugins.resources.authentication import capture
return success_response(data={"ticket": store.asset_ticket(db, row, capture(request)), "entry": module["entry"]})
-@router.get("/plugin-resource-assets/{execution_scope}/{ticket}/{asset_path:path}")
-async def resource_asset(execution_scope: str, ticket: str, asset_path: str, db: Session = Depends(get_db)):
- # Null-origin iframe subresources cannot carry SameSite login cookies.
- # The ticket grants static package reads only; its saved session is still revocable.
+def _asset_response(db, execution_scope, resource_id, user_id, asset_path):
from features.workbench.api.plugin_ui import web_asset_response
- from core.capabilities.plugins.resources.authentication import validate
- resource_id, user_id, authentication = store.resolve_asset_ticket(db, ticket)
- await validate(authentication, user_id, db)
row = service.authorized(db, resource_id, user_id)
if row.scope != execution_scope:
raise HTTPException(409, "execution_scope_mismatch")
@@ -124,26 +123,35 @@ async def resource_asset(execution_scope: str, ticket: str, asset_path: str, db:
response.headers["Access-Control-Allow-Origin"] = "null"
return response
+@router.get("/plugin-resource-assets/{execution_scope}/{ticket}/{asset_path:path}")
+async def resource_asset(execution_scope: str, ticket: str, asset_path: str):
+ # Null-origin iframe subresources cannot carry SameSite login cookies.
+ # The ticket grants static package reads only; its saved session is still revocable.
+ from core.capabilities.plugins.resources.authentication import validate
+ resource_id, user_id, authentication = await run_in_session(store.resolve_asset_ticket, ticket)
+ await validate(authentication, user_id)
+ return await run_in_session(_asset_response, execution_scope, resource_id, user_id, asset_path)
+
@router.websocket("/plugin-resources/{resource_id}/stream")
async def resource_stream(websocket: WebSocket, resource_id: str):
await connect(websocket, resource_id)
@router.delete("/plugin-resources/{resource_id}")
-async def close_resource(resource_id: str, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)):
- row = service.authorized(db, resource_id, str(user.user_id))
- await service.close(db, row)
+async def close_resource(resource_id: str, user: UserContext = Depends(get_current_user)):
+ row = await run_in_session(service.authorized_row, resource_id, str(user.user_id))
+ await service.close(row)
return success_response(data={"closed": True})
@router.post("/plugin-resources/{resource_id}/downloads/{download_id}/retain")
-async def retain_download(resource_id: str, download_id: str, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)):
+async def retain_download(resource_id: str, download_id: str, user: UserContext = Depends(get_current_user)):
from core.capabilities.plugins.resources.artifacts import retain
- row = service.authorized(db, resource_id, str(user.user_id))
+ row = await run_in_session(service.authorized_row, resource_id, str(user.user_id))
return success_response(data=await retain(row, download_id))
@router.get("/plugin-resources/{resource_id}/downloads/{download_id}")
-async def download_resource(resource_id: str, download_id: str, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)):
+async def download_resource(resource_id: str, download_id: str, user: UserContext = Depends(get_current_user)):
import httpx
- row = service.authorized(db, resource_id, str(user.user_id))
+ row = await run_in_session(service.authorized_row, resource_id, str(user.user_id))
target = store.descriptor(row)
client = httpx.AsyncClient(timeout=60, headers=target["headers"], trust_env=False)
response = await client.send(client.build_request("GET", target["url"] + "/download/" + download_id), stream=True)
@@ -165,7 +173,7 @@ async def content():
return StreamingResponse(content(), media_type="application/octet-stream", headers={"Content-Disposition": response.headers.get("content-disposition", "attachment"), "Cache-Control": "no-store"})
@router.post("/internal/plugin-resources/tool")
-async def tool_resource(body: ToolRequest, request: Request, db: Session = Depends(get_db)):
+async def tool_resource(body: ToolRequest, request: Request):
from features.sites.api.internal_site_auth import _check_internal_token
_check_internal_token(request.headers.get("x-internal-token"))
from core.llm.mcp.invocation import verify
@@ -184,17 +192,17 @@ async def tool_resource(body: ToolRequest, request: Request, db: Session = Depen
if not user_id:
raise HTTPException(401, "identity_required")
if body.action == "open":
- result = await service.create(db, body.slug, body.module_id, user_id, body.chat_id, resource_id=body.resource_id, install_id=body.install_id, checkpoint_id=body.checkpoint_id)
+ result = await service.create(body.slug, body.module_id, user_id, body.chat_id, resource_id=body.resource_id, install_id=body.install_id, checkpoint_id=body.checkpoint_id)
return success_response(data={"resource": result})
if not body.resource_id:
raise HTTPException(400, "resource_required")
- row = service.authorized(db, body.resource_id, user_id)
+ row = await run_in_session(service.authorized_row, body.resource_id, user_id)
if (row.chat_id != body.chat_id or row.slug != body.slug or row.module_id != body.module_id
or (signed_install and row.install_id != signed_install)):
raise HTTPException(403, "resource_binding_mismatch")
if body.action == "close":
await service.command(row, {"id": body.command_id, "action": "close", "params": {}}, actor="agent")
- await service.close(db, row)
+ await service.close(row)
return success_response(data={"closed": True})
if body.action == "retain_download":
await service.command(row, {"id": uuid.uuid4().hex, "action": "state", "params": {}}, actor="agent")
diff --git a/src/backend/api/routes/v1/plugins.py b/src/backend/api/routes/v1/plugins.py
index f58ed78e..24c4ff82 100644
--- a/src/backend/api/routes/v1/plugins.py
+++ b/src/backend/api/routes/v1/plugins.py
@@ -120,21 +120,21 @@ async def get_feishu_app_status(_: str = Depends(require_system_settings)):
"""
from integrations.lark.service import LarkService
- return success_response(data=LarkService(None).app_status())
+ return success_response(data=LarkService().app_status())
@router.post("/feishu-cli/app/init", summary="从飞书插件页发起共享应用初始化")
async def init_feishu_app(_: str = Depends(require_system_settings)):
from integrations.lark.service import LarkService
- return success_response(data=await LarkService(None).start_app_init())
+ return success_response(data=await LarkService().start_app_init())
@router.post("/feishu-cli/app/reset", summary="从飞书插件页重置共享应用")
async def reset_feishu_app(_: str = Depends(require_system_settings)):
from integrations.lark.service import LarkService
- return success_response(data=await LarkService(None).reset_app())
+ return success_response(data=await LarkService().reset_app())
@router.get("/{slug}", summary="内置插件详情")
@@ -180,7 +180,7 @@ def install_plugin(
@router.post("/import", status_code=201, summary="导入外部插件(上传 zip)")
-async def import_plugin(
+def import_plugin(
file: UploadFile = File(..., description="插件包 .zip(native / Claude Code / Codex)"),
secrets: Optional[str] = Form(None, description="凭据 JSON 字符串"),
user: UserContext = Depends(get_current_user),
@@ -193,10 +193,10 @@ async def import_plugin(
if _parse_secrets(secrets):
raise BadRequestError(message="本机插件凭据请通过连接配置设置,不写入插件包")
return created_response(
- data=install_archive(str(user.user_id), await file.read(), "plugin")
+ data=install_archive(str(user.user_id), file.file.read(), "plugin")
)
_require_can_import_plugin(str(user.user_id), db)
- raw = await file.read()
+ raw = file.file.read()
secret_map = _parse_secrets(secrets)
result = ps.import_plugin_from_zip(
db,
diff --git a/src/backend/api/routes/v1/project_api_keys.py b/src/backend/api/routes/v1/project_api_keys.py
index cf137f00..eb85ec69 100644
--- a/src/backend/api/routes/v1/project_api_keys.py
+++ b/src/backend/api/routes/v1/project_api_keys.py
@@ -9,6 +9,7 @@
from core.auth.backend import UserContext, get_current_user
from core.auth.capabilities import resolve_user_capabilities
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import created_response, success_response
from core.projects.api import require_project_key_access
from core.auth.api_keys import ApiKeyService
@@ -136,15 +137,16 @@ async def toggle_key(
key_id: str,
body: ToggleProjectApiKeyRequest,
user: UserContext = Depends(_manager),
- db: Session = Depends(get_db),
):
- _find(db, user, project_id, key_id)
- row = ApiKeyService(db).set_enabled(user.user_id, key_id, body.enabled, project_id=project_id)
- data = _dto(row)
+ def toggle(db):
+ _find(db, user, project_id, key_id)
+ return _dto(ApiKeyService(db).set_enabled(user.user_id, key_id, body.enabled, project_id=project_id))
+
+ data = await run_in_session(toggle)
if not body.enabled:
from core.auth.api_key_runs import stop_cancelled_key_workers
- await stop_cancelled_key_workers(db, key_id, user.user_id)
+ await stop_cancelled_key_workers(key_id, user.user_id)
return success_response(data=data)
@@ -153,11 +155,13 @@ async def revoke_key(
project_id: str,
key_id: str,
user: UserContext = Depends(_manager),
- db: Session = Depends(get_db),
):
- _find(db, user, project_id, key_id)
- ApiKeyService(db).revoke_key(user.user_id, key_id, project_id=project_id)
+ def revoke(db):
+ _find(db, user, project_id, key_id)
+ ApiKeyService(db).revoke_key(user.user_id, key_id, project_id=project_id)
+
+ await run_in_session(revoke)
from core.auth.api_key_runs import stop_cancelled_key_workers
- await stop_cancelled_key_workers(db, key_id, user.user_id)
+ await stop_cancelled_key_workers(key_id, user.user_id)
return success_response(data={"id": key_id, "revoked": True})
diff --git a/src/backend/api/routes/v1/projects.py b/src/backend/api/routes/v1/projects.py
index 552b5db2..5a483f90 100644
--- a/src/backend/api/routes/v1/projects.py
+++ b/src/backend/api/routes/v1/projects.py
@@ -89,7 +89,7 @@ class UpdateInstructionsBody(BaseModel):
@router.get("", summary="列出可见项目")
-async def list_projects(
+def list_projects(
q: Optional[str] = Query(None, description="按 name / description 模糊搜索"),
sort: str = Query("-last_activity_at", description="-last_activity_at | name | created"),
page: int = Query(1, ge=1),
@@ -106,7 +106,7 @@ async def list_projects(
@router.post("", status_code=status.HTTP_201_CREATED, summary="创建项目")
-async def create_project(
+def create_project(
body: CreateProjectBody,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -148,7 +148,7 @@ async def create_project(
@router.get("/{project_id}", summary="项目详情")
-async def get_project(
+def get_project(
access: ProjectAccess = Depends(require_project_access("view")),
db: Session = Depends(get_db),
):
@@ -164,7 +164,7 @@ async def get_project(
@router.patch("/{project_id}", summary="更新项目元信息 / instructions")
-async def update_project(
+def update_project(
body: UpdateProjectBody,
access: ProjectAccess = Depends(require_project_access("edit")),
db: Session = Depends(get_db),
@@ -179,7 +179,7 @@ async def update_project(
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, summary="软删项目")
-async def delete_project(
+def delete_project(
access: ProjectAccess = Depends(require_project_access("admin")),
db: Session = Depends(get_db),
):
@@ -193,7 +193,7 @@ async def delete_project(
@router.post("/{project_id}/favorite", summary="收藏项目")
-async def favorite_project(
+def favorite_project(
access: ProjectAccess = Depends(require_project_access("view")),
db: Session = Depends(get_db),
):
@@ -204,7 +204,7 @@ async def favorite_project(
@router.delete("/{project_id}/favorite", summary="取消收藏")
-async def unfavorite_project(
+def unfavorite_project(
access: ProjectAccess = Depends(require_project_access("view")),
db: Session = Depends(get_db),
):
@@ -218,7 +218,7 @@ async def unfavorite_project(
@router.patch("/{project_id}/instructions", summary="更新项目指令(system prompt 末段注入)")
-async def update_instructions(
+def update_instructions(
body: UpdateInstructionsBody,
access: ProjectAccess = Depends(require_project_access("edit")),
db: Session = Depends(get_db),
@@ -240,7 +240,7 @@ async def update_instructions(
@router.get("/{project_id}/files", summary="项目文件列表(递归挂钩文件夹子树)")
-async def list_project_files(
+def list_project_files(
access: ProjectAccess = Depends(require_project_access("view")),
db: Session = Depends(get_db),
):
@@ -342,7 +342,7 @@ def _local_project_file_path(path: str, access: ProjectAccess) -> str:
@router.post("/{project_id}/files/upload", summary="直传文件到项目(写入挂钩文件夹)")
-async def upload_project_file(
+def upload_project_file(
file: UploadFile = File(...),
access: ProjectAccess = Depends(require_project_access("edit")),
db: Session = Depends(get_db),
@@ -350,7 +350,7 @@ async def upload_project_file(
"""直传文件到项目挂钩文件夹(filename 含路径时自动创建子文件夹);需 edit 权限。"""
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
- contents = await file.read()
+ contents = file.file.read()
if not contents:
raise HTTPException(status_code=400, detail="文件内容为空")
pf_svc = ProjectFileService(db)
@@ -366,7 +366,7 @@ async def upload_project_file(
@router.delete("/{project_id}/files/{artifact_id}", summary="软删项目文件(同步 MySpace)")
-async def remove_project_file(
+def remove_project_file(
artifact_id: str,
access: ProjectAccess = Depends(require_project_access("edit")),
db: Session = Depends(get_db),
@@ -400,7 +400,7 @@ async def remove_project_file(
@router.get("/{project_id}/chats", summary="项目内的会话列表")
-async def list_project_chats(
+def list_project_chats(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
scope: Literal["all", "mine", "shared"] = Query(
diff --git a/src/backend/api/routes/v1/prompt_management.py b/src/backend/api/routes/v1/prompt_management.py
index f04cf467..4be49bdd 100644
--- a/src/backend/api/routes/v1/prompt_management.py
+++ b/src/backend/api/routes/v1/prompt_management.py
@@ -14,6 +14,7 @@
from api.deps import require_system_settings as require_config
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import success_response
from core.config.store.execution_options import code_capability_enabled
from fastapi import APIRouter, Depends, HTTPException
@@ -165,7 +166,14 @@ def _render_tool_schema_appendix(tool_schemas: List[Dict[str, Any]]) -> str:
return "\n".join(lines).strip()
-async def _runtime_prompt_preview(db: Session, *, approval_mode: Optional[str] = None) -> Optional[Dict[str, Any]]:
+def _active_prompt_versions(db: Session):
+ active = prompt_versions_module.get_active_version("system", db=db)
+ code_active = prompt_versions_module.get_active_version("code_exec", db=db)
+ code_seg = prompt_rendering_module.render_kind_segment("code_exec", db) if code_capability_enabled() else ""
+ return active, code_active, code_seg
+
+
+async def _runtime_prompt_preview(*, approval_mode: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Build a runtime preview by instantiating the main agent.
If MCP/model setup is unavailable, callers fall back to static assembly.
@@ -199,8 +207,7 @@ async def _runtime_prompt_preview(db: Session, *, approval_mode: Optional[str] =
+ tool_appendix
)
- active = prompt_versions_module.get_active_version("system", db=db)
- code_active = prompt_versions_module.get_active_version("code_exec", db=db)
+ active, code_active, code_seg = await run_in_session(_active_prompt_versions)
enabled_parts = len(
[
p
@@ -208,7 +215,6 @@ async def _runtime_prompt_preview(db: Session, *, approval_mode: Optional[str] =
if p.get("is_enabled", True) and (p.get("content") or "").strip()
]
)
- code_seg = prompt_rendering_module.render_kind_segment("code_exec", db) if code_capability_enabled() else ""
part_count = (
enabled_parts
+ (1 if code_seg else 0)
@@ -463,16 +469,21 @@ def update_order(req: OrderUpdateRequest, db: Session = Depends(get_db)):
@router.post("/preview", dependencies=[Depends(require_config)], summary="预览运行时提示词")
-async def preview_prompt(db: Session = Depends(get_db)):
+async def preview_prompt():
"""基于当前 DB + 文件系统状态预览运行时指令面。
优先实例化主 agent,返回真实 ``sys_prompt``、AgentScope 自动追加的技能
prompt,以及工具 schema 描述的审计展开;若运行时装配失败,再退回静态
DB + 文件系统拼接。
"""
- runtime = await _runtime_prompt_preview(db)
+ runtime = await _runtime_prompt_preview()
if runtime:
return success_response(data=runtime)
+ return success_response(data=await run_in_session(_static_prompt_preview))
+
+
+def _static_prompt_preview(db: Session) -> Dict[str, Any]:
+ """The prompt assembled from the DB + filesystem, when no agent can be built."""
def _finalize(prompt: str, part_count: int, base: Dict[str, Any]):
seg = ""
@@ -506,7 +517,7 @@ def _finalize(prompt: str, part_count: int, base: Dict[str, Any]):
"preview_mode": "static_fallback",
}
)
- return success_response(data=base)
+ return base
# Prefer the active version from the prompt_version pool
active = prompt_versions_module.get_active_version("system", db=db)
@@ -728,13 +739,15 @@ class DesktopPreviewRequest(BaseModel):
@router.post("/desktop-preview")
-async def desktop_preview(request: DesktopPreviewRequest, _=Depends(require_config), db: Session = Depends(get_db)):
+async def desktop_preview(request: DesktopPreviewRequest, _=Depends(require_config)):
"""Render a saved desktop version without activating it or launching an agent."""
from core.config.local_mode import local_mode_enabled
from prompts.desktop_templates import desktop_version, render_desktop_part
from prompts.desktop_workspace import SKIP_PARTS
from prompts.prompt_runtime import build_system_prompt
- version = prompt_versions_module.get_version("desktop", request.version_id, db=db)
+ version = await run_in_session(
+ lambda db: prompt_versions_module.get_version("desktop", request.version_id, db=db)
+ )
if version is None:
raise HTTPException(404, "desktop prompt version not found")
if request.parts is not None:
@@ -754,13 +767,15 @@ async def desktop_preview(request: DesktopPreviewRequest, _=Depends(require_conf
)) or " "
prompt = build_system_prompt(load_prompt_config(), ctx, desktop_preview=True)
if code_capability_enabled():
- parts = prompt_rendering_module.effective_parts("code_exec", db=db)
+ parts = await run_in_session(
+ lambda db: prompt_rendering_module.effective_parts("code_exec", db=db)
+ )
prompt += "\n\n" + "\n\n".join(p["content"] for p in sorted(parts, key=lambda p: p.get("sort_order", 0))
if p.get("is_enabled", True) and p.get("part_id") not in SKIP_PARTS["code_exec"])
from core.sandbox.workspace.paths import WORKSPACE
from core.sandbox.workspace.desktop import workspace_directory
tool = render_desktop_part("bash_tool", cwd=workspace_directory(WORKSPACE, ctx["chat_id"]) if local else "{runtime.cwd}")
- runtime = await _runtime_prompt_preview(db, approval_mode="ask") if local else None
+ runtime = await _runtime_prompt_preview(approval_mode="ask") if local else None
if runtime:
prompt = runtime["prompt"]
conditional = {p["part_id"]: p.get("content", "") for p in version.get("parts", [])
diff --git a/src/backend/api/routes/v1/public_chats.py b/src/backend/api/routes/v1/public_chats.py
index c5fe0e0a..9eb8662f 100644
--- a/src/backend/api/routes/v1/public_chats.py
+++ b/src/backend/api/routes/v1/public_chats.py
@@ -3,6 +3,7 @@
from core.auth.backend import UserContext, get_current_user
from core.auth.public_chat import public_session_matches
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.db.models import ChatSession, Project, UserAgent
from core.infra.responses import success_response
from core.chat import public as service
@@ -48,16 +49,22 @@ async def toggle_link(
body: LinkState,
response: Response,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
+ from core.db.models import ContentBlock
+
+ def toggle(db):
+ data = service.set_enabled(db, user.user_id, kind, resource_id, body.enabled)
+ if body.enabled:
+ return data, None
+ row = db.get(ContentBlock, service.link_id(user.user_id, kind, resource_id))
+ return data, row.payload["key_id"]
+
response.headers["Cache-Control"] = "no-store"
- data = service.set_enabled(db, user.user_id, kind, resource_id, body.enabled)
+ data, key_id = await run_in_session(toggle)
if not body.enabled:
- from core.db.models import ContentBlock
from core.auth.api_key_runs import stop_cancelled_key_workers
- row = db.get(ContentBlock, service.link_id(user.user_id, kind, resource_id))
- await stop_cancelled_key_workers(db, row.payload["key_id"], user.user_id)
+ await stop_cancelled_key_workers(key_id, user.user_id)
return success_response(data=data)
diff --git a/src/backend/api/routes/v1/users.py b/src/backend/api/routes/v1/users.py
index 985a226a..9947ba99 100644
--- a/src/backend/api/routes/v1/users.py
+++ b/src/backend/api/routes/v1/users.py
@@ -186,7 +186,7 @@ def complete_my_onboarding(
final, idempotent checkpoint that unlocks the normal application shell.
"""
from core.db.models import ModelProvider, ModelRoleAssignment
- from core.auth.local_users_state import CE_ONBOARDING_VERSION
+ from core.auth.local_users_constants import CE_ONBOARDING_VERSION
if settings.edition.edition != "ce":
raise AccessDeniedError(
@@ -282,7 +282,7 @@ def _persist_avatar_url(
@router.post("/me/avatar", summary="上传自定义头像(multipart)")
-async def upload_my_avatar(
+def upload_my_avatar(
file: UploadFile = File(..., description="头像图片,≤2MB,支持 png/jpg/webp/gif"),
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -296,7 +296,7 @@ async def upload_my_avatar(
message="头像仅支持 PNG / JPG / WEBP / GIF",
)
- file_bytes = await file.read()
+ file_bytes = file.file.read()
if not file_bytes:
raise ValidationError(
errors=[{"field": "file", "message": "文件内容为空"}],
diff --git a/src/backend/api/startup/bootstrap.py b/src/backend/api/startup/bootstrap.py
index 2a4e0ec9..7d3ad9ae 100644
--- a/src/backend/api/startup/bootstrap.py
+++ b/src/backend/api/startup/bootstrap.py
@@ -6,6 +6,9 @@
from core.infra.logging import get_logger
logger = get_logger(__name__)
+
+from core.db.threads import run_in_session
+
async def _startup_ensure_tables():
"""Ensure database tables exist for SQLite environments.
@@ -27,16 +30,11 @@ async def _startup_seed_ce_admin():
if settings.edition.edition != "ce":
return
try:
- from core.db.engine import SessionLocal
from core.auth.local_user_bootstrap import ensure_ce_default_admin
- db = SessionLocal()
- try:
- user_id, created = ensure_ce_default_admin(db)
- if created:
- logger.info("[startup] CE default administrator created: %s", user_id)
- finally:
- db.close()
+ user_id, created = await run_in_session(ensure_ce_default_admin)
+ if created:
+ logger.info("[startup] CE default administrator created: %s", user_id)
except Exception as exc:
logger.warning("[startup] CE default administrator seed failed: %s", exc)
@@ -62,17 +60,14 @@ async def _startup_seed_page_config():
"""Seed default page_config content block if missing (idempotent)."""
try:
from core.files.content.branding import enforce_ce_branding, seed_page_config_if_missing
- from core.db.engine import SessionLocal
-
- db = SessionLocal()
- try:
- inserted = seed_page_config_if_missing(db)
- if inserted:
- logger.info("[startup] default page_config seeded")
- if enforce_ce_branding(db):
- logger.info("[startup] CE page config normalized")
- finally:
- db.close()
+
+ inserted, normalized = await run_in_session(
+ lambda db: (seed_page_config_if_missing(db), enforce_ce_branding(db))
+ )
+ if inserted:
+ logger.info("[startup] default page_config seeded")
+ if normalized:
+ logger.info("[startup] CE page config normalized")
except Exception as exc:
logger.warning("[startup] page_config seed failed: %s", exc)
@@ -82,17 +77,10 @@ async def _startup_seed_prompt_versions():
Idempotent: only inserts versions that don't already exist.
"""
try:
- from core.db.engine import SessionLocal
- from core.llm import prompt_versions as pvs
-
- db = SessionLocal()
- try:
- result = prompt_versions_module.seed_from_filesystem(db=db)
- added = result.get("added") or []
- if added:
- logger.info("[startup] prompt versions seeded: %s", ", ".join(added))
- finally:
- db.close()
+ result = await run_in_session(lambda db: prompt_versions_module.seed_from_filesystem(db=db))
+ added = result.get("added") or []
+ if added:
+ logger.info("[startup] prompt versions seeded: %s", ", ".join(added))
except Exception as exc:
logger.warning("[startup] prompt versions seed failed: %s", exc)
@@ -103,15 +91,10 @@ async def _startup_seed_roles():
populates fresh deployments. When CE has no roles table, degrades entirely to a no-op.
"""
try:
- from core.db.engine import SessionLocal
from orchestration.workers.edition_startup import seed_default_roles
- db = SessionLocal()
- try:
- added = seed_default_roles(db)
- if added:
- logger.info("[startup] default roles seeded: %s", ", ".join(added))
- finally:
- db.close()
+ added = await run_in_session(seed_default_roles)
+ if added:
+ logger.info("[startup] default roles seeded: %s", ", ".join(added))
except Exception as exc:
logger.warning("[startup] roles seed failed: %s", exc)
diff --git a/src/backend/api/startup/commands.py b/src/backend/api/startup/commands.py
index 0808abfe..5602925b 100644
--- a/src/backend/api/startup/commands.py
+++ b/src/backend/api/startup/commands.py
@@ -21,6 +21,9 @@ async def _run():
async def startup_commands():
global _task
runtime.bind_loop()
+ bind = getattr(get_sandbox_provider(), "bind_loop", None)
+ if bind is not None:
+ bind()
_task = asyncio.create_task(_run(), name="background-command-reconciliation")
async def shutdown_commands():
diff --git a/src/backend/api/startup/plugins.py b/src/backend/api/startup/plugins.py
index 114d3bcd..7e324e3b 100644
--- a/src/backend/api/startup/plugins.py
+++ b/src/backend/api/startup/plugins.py
@@ -5,7 +5,9 @@
from core.infra.logging import get_logger
logger = get_logger(__name__)
-async def _startup_seed_mcp_servers():
+from core.db.threads import run_db
+
+def _seed_mcp_servers():
"""Seed the built-in global MCP catalog if the DB has none (idempotent).
On compose these rows come from alembic seed migrations; on the no-Docker
@@ -39,6 +41,10 @@ async def _startup_seed_mcp_servers():
except Exception as exc:
logger.warning("[startup] MCP catalog seed failed: %s", exc)
+
+async def _startup_seed_mcp_servers():
+ await run_db(_seed_mcp_servers)
+
async def _startup_mcp_market_monitor():
"""Start periodic remote-tool drift detection after MCP rows are available."""
try:
@@ -111,7 +117,7 @@ async def _startup_backfill_api_protocol():
if count:
logger.info("[startup] api_protocol recorded for %d model provider(s)", count)
-async def _startup_seed_default_plugins():
+def _seed_default_plugins():
"""Install edition-specific default plugins once per persistent database.
Local/desktop installs run the equivalent bootstrap in ``cli.py`` before
@@ -162,3 +168,7 @@ async def _startup_seed_default_plugins():
raise
finally:
db.close()
+
+
+async def _startup_seed_default_plugins():
+ await run_db(_seed_default_plugins)
diff --git a/src/backend/api/startup/runtime.py b/src/backend/api/startup/runtime.py
index 00c2c404..82cdae46 100644
--- a/src/backend/api/startup/runtime.py
+++ b/src/backend/api/startup/runtime.py
@@ -1,10 +1,6 @@
"""Application lifecycle operations."""
-import os
-import sys
-
import core.chat.runtime.recovery as recovery
-import core.runs.watchdog as watchdog
import orchestration.loops.recovery as loops_recovery
import orchestration.workers.run_recovery as _run_recovery
from core.config.settings import settings
@@ -12,6 +8,13 @@
logger = get_logger(__name__)
+async def _startup_thread_budget():
+ """Size this worker's thread pools to the connection pool before serving."""
+ from core.db.threads import apply_thread_budget
+
+ apply_thread_budget()
+
+
async def _startup_watch_capability_changes():
"""智能体 / 技能 / 连接器 / 插件被增删时递增能力变更号,桌面端据此同步一次。"""
from features.desktop.capabilities.change_signal import watch_capability_tables
@@ -22,8 +25,6 @@ async def _startup_watch_capability_changes():
async def _startup_recover_chat_runs():
"""Resume claimable chat_runs; valid leases are retried by the recovery loop."""
try:
- pass
-
count = await recovery.recover_orphan_runs()
if count:
logger.info("[startup] chat_runs orphan recovered: %d", count)
@@ -33,8 +34,6 @@ async def _startup_recover_chat_runs():
async def _startup_resume_loops():
"""Resume interrupted autonomous loops (M4, off by default; enable with LOOP_AUTO_RESUME=true)."""
try:
- pass
-
count = await loops_recovery.resume_running_loops()
if count:
logger.info("[startup] autonomous loops resumed: %d", count)
@@ -132,6 +131,7 @@ async def _startup_warm_sandbox_pool():
logger.info("[startup] sandbox provider %s warmup kicked off", provider.name)
except Exception as exc: # noqa: BLE001
logger.warning("[startup] sandbox warmup failed: %s", exc)
+ raise
async def _startup_local_sidecars():
"""Spawn and verify MCP + script_runner sidecars in local profile.
@@ -282,3 +282,17 @@ async def _shutdown_extensions():
for _, stop in reversed(lifecycle_hooks()):
await stop()
+
+
+async def _shutdown_sandbox_maintenance():
+ from core.sandbox import get_sandbox_provider
+ stop = getattr(get_sandbox_provider(), "stop_maintenance", None)
+ if stop is not None:
+ await stop()
+
+
+async def _shutdown_sandbox_provider():
+ from core.sandbox import get_sandbox_provider
+ shutdown = getattr(get_sandbox_provider(), "shutdown", None)
+ if shutdown is not None:
+ await shutdown()
diff --git a/src/backend/api/startup/workers.py b/src/backend/api/startup/workers.py
index f6abec50..f1e06ecf 100644
--- a/src/backend/api/startup/workers.py
+++ b/src/backend/api/startup/workers.py
@@ -17,9 +17,6 @@ def _env_flag(name: str, default: bool) -> bool:
def _startup_preload_enabled() -> bool:
return _env_flag("STARTUP_PRELOAD_ENABLED", True)
-def _mcp_warmup_enabled() -> bool:
- return _env_flag("MCP_WARMUP_ENABLED", True)
-
async def _shutdown_automation_scheduler():
"""Stop the automation scheduler, whether the process is exiting or this
worker just lost the leader role. Idempotent: the scheduler's own ``stop``
@@ -80,7 +77,7 @@ async def _run():
logger.warning("[startup] idle session reaper failed to start: %s", exc)
async def _startup_preload():
- """Pre-load caches and initialize MCP connection pool at startup."""
+ """Pre-load application caches at startup."""
import asyncio
if not _startup_preload_enabled():
@@ -169,17 +166,6 @@ def _warm_runtime_catalog() -> None:
await _run_sync("Runtime catalog", _warm_runtime_catalog)
- # 4. Initialize MCP connection pool (the big one — 1-7s savings)
- if _mcp_warmup_enabled():
- try:
- from core.llm.factory import warmup_mcp_tools
-
- await warmup_mcp_tools()
- except Exception as exc:
- logger.warning("[startup] MCP pool initialization failed: %s", exc)
- else:
- logger.info("[startup] MCP pool warmup skipped for current environment")
-
# 5. Pre-load DB prompt parts so first chat skips DB query
from prompts.prompt_runtime import warmup_prompt_cache
@@ -333,7 +319,9 @@ async def _startup_distillation_scheduler():
logger.warning("[startup] Distillation cron scheduler failed to start: %s", exc)
async def _shutdown_pools():
- """Close MCP connection pool, KB HTTP server, automation scheduler, and Redis on shutdown."""
+ """Close chat MCP sessions and application services on shutdown."""
+ from core.llm.mcp.chat_sessions import close_chat
+ await close_chat()
# Stop automation scheduler — same handler the leader path calls, so a
# deposed leader and an exiting process cannot stop different things.
try:
@@ -352,12 +340,13 @@ async def _shutdown_pools():
await _shutdown_idle_session_reaper()
except Exception as e:
logger.warning("idle_reaper_shutdown_error", error=str(e))
- try:
- from core.llm.mcp.pool import MCPConnectionPool
+ import asyncio
- await MCPConnectionPool.get_instance().shutdown()
- except Exception as e:
- logger.warning("mcp_pool_shutdown_error", error=str(e))
+ from core.harness.observation_writer import flush_observations
+
+ # Hook stage events and usage are written in the background; persist the
+ # tail before the database and Redis go away.
+ await asyncio.to_thread(flush_observations, 5.0)
try:
from core.infra.redis import close_redis
diff --git a/src/backend/cli.py b/src/backend/cli.py
index 5ab583cd..585df498 100644
--- a/src/backend/cli.py
+++ b/src/backend/cli.py
@@ -282,7 +282,6 @@ def configure_model(
from core.models import repository as mr
from core.db.engine import SessionLocal
- from core.models.config import ModelConfigService
if test:
from api.routes.v1.models import _test_connection
@@ -325,13 +324,11 @@ def configure_model(
mr.assign_role(db, role, provider.provider_id, updated_by="onboard")
finally:
db.close()
- # Repository writes don't invalidate the read cache — do it explicitly.
- ModelConfigService.get_instance().invalidate_cache()
def mark_web_onboarding_complete(user_id: str) -> None:
"""Avoid repeating the browser setup after the terminal wizard succeeds."""
from core.db.engine import SessionLocal
- from core.auth.local_users_state import CE_ONBOARDING_VERSION
+ from core.auth.local_users_constants import CE_ONBOARDING_VERSION
from core.auth.users import UserService
db = SessionLocal()
diff --git a/src/backend/core/agent_runtime/budget.py b/src/backend/core/agent_runtime/budget.py
index 3dfe80d7..8eb040b4 100644
--- a/src/backend/core/agent_runtime/budget.py
+++ b/src/backend/core/agent_runtime/budget.py
@@ -1,4 +1,4 @@
-"""core agent_runtime budget responsibilities."""
+"""Read the frozen model and prompt surface a turn's compaction budget is computed from."""
from __future__ import annotations
diff --git a/src/backend/core/agent_runtime/complete.py b/src/backend/core/agent_runtime/complete.py
deleted file mode 100644
index 6cfe2b80..00000000
--- a/src/backend/core/agent_runtime/complete.py
+++ /dev/null
@@ -1,422 +0,0 @@
-"""core agent_runtime complete responsibilities."""
-
-from __future__ import annotations
-from core.ontology import output_review_state as output_review_state_module
-from core.ontology import runtime_pack as runtime_pack_module
-from core.llm.context import rendering as rendering_module
-from core.llm.context import ir_state as ir_state_module
-from core.llm.context import items as items_module
-from core.ontology import runtime_policy
-
-import dataclasses as _dataclasses
-import logging
-import typing as _typing
-from dataclasses import dataclass
-from typing import Any, Dict, List
-
-import core.agent_runtime.budget as budget
-import core.agent_runtime.integrations.ontology_evidence as ontology_evidence
-import core.agent_runtime.integrations.ontology_repair as ontology_repair
-import core.agent_runtime.skills as skills
-import core.agent_runtime.targets as targets
-import core.chat.message_parser as message_parser
-import core.capabilities.catalog.resolver as catalog_resolver
-import core.llm.context.adapter as context_adapter
-import core.llm.context.ir as context_ir
-import core.llm.factory as factory
-import core.llm.mcp.manager as mcp_manager
-import core.llm.runtime.message_compat as message_compat
-import core.ontology.validator as validator
-import core.ontology.service as ontology_service
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class WorkflowResult:
- route: str = "main"
- response: str = ""
- is_markdown: bool = False
- sources: _typing.List[_typing.Dict[str, _typing.Any]] = _dataclasses.field(default_factory=list)
- artifacts: _typing.List[_typing.Dict[str, _typing.Any]] = _dataclasses.field(
- default_factory=list
- )
- warnings: _typing.List[str] = _dataclasses.field(default_factory=list)
- meta: _typing.Dict[str, _typing.Any] = _dataclasses.field(default_factory=dict)
-
-
-def run_chat_workflow(
- *,
- session_messages: List[Dict[str, Any]],
- user_message: str,
- context: Dict[str, Any],
-) -> WorkflowResult:
- """Run route -> target execution."""
- from core.harness.registry.evaluation import apply_evaluation_scope
-
- context = apply_evaluation_scope(context)
-
- _explicit_command = context.get("explicit_subagent_command")
- _explicit_agent_id = (
- str(_explicit_command.get("agent_id"))
- if isinstance(_explicit_command, dict) and _explicit_command.get("agent_id")
- else None
- )
- _mention_agent_id = str(context.get("mention_agent_id") or "") or None
- _direct_agent_id = targets._direct_agent_id_from_context(context)
- _direct_user_agent = None
- _direct_read_only = False
- _direct_allow_bash = True
- _visible_subagents: _typing.List[_typing.Dict[str, _typing.Any]] = []
- _request_ontology_runtime = context.get("ontology_runtime")
- if not isinstance(_request_ontology_runtime, dict):
- _request_ontology_runtime = {}
- context["ontology_runtime"] = _request_ontology_runtime
- if _direct_agent_id:
- _direct_read_only, _direct_allow_bash = targets._direct_agent_execution_permissions(
- _direct_agent_id
- )
- _direct_user_agent = targets._load_direct_user_agent(
- _direct_agent_id,
- str(context.get("user_id", "")),
- {
- "enabled_skill_ids": catalog_resolver.enabled_skill_ids_from_context(context) or [],
- "enabled_mcp_ids": catalog_resolver.enabled_mcp_ids_from_context(context) or [],
- "enabled_kb_ids": catalog_resolver.enabled_kb_ids_from_context(context) or [],
- "sandbox_tools_enabled": bool(context.get("sandbox_tools_enabled", False)),
- "code_capability_enabled": bool(context.get("code_capability_enabled", False)),
- },
- )
- activations = runtime_pack_module.activate_runtime_for_asset(
- _request_ontology_runtime,
- kind="subagent",
- asset_id=_direct_agent_id,
- tags=list((_direct_user_agent.extra_config or {}).get("ontology_tags") or []),
- )
- ontology_evidence._record_ontology_activations(
- activations,
- _request_ontology_runtime,
- context,
- )
- else:
- try:
- from core.db.engine import SessionLocal as _SessionLocal
- from core.capabilities.agents.edition import UserAgentService as _UAS
- from core.auth.users import UserService as _UserService
-
- with _SessionLocal() as _db:
- _visible_subagents = (
- []
- if context.get("evaluation_session")
- else _UAS(_db).list_for_user(str(context.get("user_id", "")))
- )
- _disabled_builtin_ids = _UserService(_db).get_disabled_builtin_subagent_ids(
- str(context.get("user_id", ""))
- )
- except Exception as exc: # noqa: BLE001
- logger.warning("[workflow] failed to load visible subagents: %s", exc)
- _disabled_builtin_ids = set()
- for skill_id in skills._explicit_skill_ids_from_context(context):
- runtime_policy.resolve_runtime_asset_tags(
- runtime=_request_ontology_runtime,
- kind="skill",
- asset_id=skill_id,
- user_id=str(context.get("user_id", "") or ""),
- )
- activations = runtime_pack_module.activate_runtime_for_asset(
- _request_ontology_runtime,
- kind="skill",
- asset_id=skill_id,
- )
- ontology_evidence._record_ontology_activations(
- activations,
- _request_ontology_runtime,
- context,
- )
-
- # ── Inject explicit skill instructions ──
- skill_msg = skills._build_skill_injection(context)
- if skill_msg:
- session_messages.insert(-1, skill_msg)
- logger.info(
- "[skill_inject] injected skill instructions for '%s'",
- context.get("skill_id"),
- )
-
- warnings: _typing.List[str] = []
- enabled_skill_ids = (
- None if _direct_user_agent else catalog_resolver.enabled_skill_ids_from_context(context)
- )
- enabled_kb_ids = (
- None if _direct_user_agent else catalog_resolver.enabled_kb_ids_from_context(context)
- )
- enabled_mcp_ids = (
- None if _direct_user_agent else catalog_resolver.enabled_mcp_ids_from_context(context)
- )
-
- _workflow_user_id = str(context.get("user_id", ""))
- _workflow_model_name = str(context.get("model_name", ""))
- _workflow_model_provider_id = str(context.get("model_provider_id", "") or "")
- _workflow_chat_mode = str(context.get("chat_mode", "") or "")
- _workflow_mem_enabled = bool(context.get("memory_enabled", False))
- _reranker_enabled = bool(context.get("reranker_enabled", False))
-
- if _direct_user_agent is None:
- _visible_subagents = targets._runtime_visible_subagents(
- _visible_subagents,
- {
- "enabled_skill_ids": enabled_skill_ids or [],
- "enabled_mcp_ids": enabled_mcp_ids or [],
- "enabled_kb_ids": enabled_kb_ids or [],
- },
- _disabled_builtin_ids,
- [_explicit_agent_id, _mention_agent_id],
- )
-
- _workflow_mode_spec = targets._resolve_mode_spec(context)
- _workflow_turbo = (
- _direct_user_agent is None
- and _workflow_mode_spec is not None
- and _workflow_mode_spec.restricted
- )
- if _workflow_turbo:
- # 收窄模式:可见子智能体 = 模式圈定的那批 ∪ 本轮被显式委派/@ 的那个
- # (此路径无文本 @ 解析)。
- _turbo_delegated = str(_explicit_agent_id or _mention_agent_id or "")
- _visible_subagents = targets._mode_visible_subagents(
- _workflow_mode_spec, _visible_subagents, [_turbo_delegated]
- )
-
- async def _run():
- agent, mcp_clients = await factory.create_agent_executor(
- agent_spec=None,
- enabled_skill_ids=enabled_skill_ids,
- enabled_mcp_ids=enabled_mcp_ids,
- enabled_kb_ids=enabled_kb_ids,
- current_user_id=_workflow_user_id,
- reranker_enabled=_reranker_enabled,
- model_name=_workflow_model_name,
- model_provider_id=_workflow_model_provider_id,
- chat_mode=_workflow_chat_mode,
- turbo_mode=_workflow_turbo,
- mode_spec=_workflow_mode_spec,
- turbo_explicit_skill_ids=(
- skills._explicit_skill_ids_from_context(context) if _workflow_turbo else None
- ),
- turbo_explicit_mcp_ids=(
- [m for m in (context.get("mcp_ids") or []) if isinstance(m, str)]
- if _workflow_turbo
- else None
- ),
- # 渐进式插件加载:本轮显式呼唤的能力不得被延迟(用户消息注入已承诺
- # 其可用),并作为会话级激活落库。
- invoked_skill_ids=skills._explicit_skill_ids_from_context(context),
- invoked_mcp_ids=[m for m in (context.get("mcp_ids") or []) if isinstance(m, str)],
- required_mcp_ids=(
- [str(context["connector_id"])] if context.get("connector_id") else None
- ),
- required_skill_id=str(context.get("skill_id") or "") or None,
- required_skill_name=str(context.get("skill_name") or "") or None,
- required_plugin_id=str(context.get("plugin_id") or "") or None,
- required_plugin_name=str(context.get("plugin_name") or "") or None,
- required_plugin_skill_ids=[
- str(item)
- for item in (context.get("plugin_skill_ids") or [])
- if isinstance(item, str) and item
- ],
- required_plugin_mcp_ids=[
- str(item)
- for item in (context.get("plugin_mcp_ids") or [])
- if isinstance(item, str) and item
- ],
- memory_enabled=_workflow_mem_enabled,
- workflow_mode=bool(context.get("workflow_chat", False)),
- user_agent=_direct_user_agent,
- read_only=_direct_read_only,
- allow_bash=_direct_allow_bash,
- approval_mode=targets._approval_mode(context),
- visible_subagents=_visible_subagents if _visible_subagents else None,
- chat_id=context.get("chat_id"),
- sandbox_session_id=context.get("sandbox_session_id"),
- run_id=str(context.get("run_id") or "") or None,
- journal_owner=str(context.get("journal_owner") or "") or None,
- workspace_id=str(context.get("workspace_id") or "default"),
- project_ctx=targets._extract_project_ctx(context),
- channel_origin=context.get("channel_origin"),
- automation_run=bool(context.get("automation_run")),
- ontology_runtime=context.get("ontology_runtime"),
- )
- try:
- # AgentScope 2.0: ctx → agent.state (AgentRuntimeState), replacing _jx_context.
- # Uploaded/history files are written into state by
- # apply_request_context, then injected uniformly at reply time by
- # FileContextMiddleware (not hand-copied here).
- try:
- agent.state.apply_request_context(context, user_message or "")
- except Exception as exc: # noqa: BLE001
- logger.warning("[workflow] set agent.state failed: %s", exc)
-
- _actual_model, _ctx_window = budget._resolve_agent_model_runtime(
- agent, _workflow_model_name
- )
-
- # PreTurn compaction safety net (symmetric with the streaming path
- # — both workflow entry points protect themselves, and future new
- # callers get it automatically). Zero overhead below the threshold.
- _pt_messages = session_messages
- try:
- from core.chat.compaction.service import maybe_run_pre_turn_compaction
-
- _pt_messages, _ = await maybe_run_pre_turn_compaction(
- context.get("chat_id"),
- session_messages,
- run_id=str(context.get("run_id") or ""),
- **budget._pre_turn_compaction_inputs(agent, _ctx_window, _actual_model),
- )
- except Exception as _pt_exc: # noqa: BLE001
- from core.harness.hooks import HookPaused
-
- if isinstance(_pt_exc, HookPaused):
- raise
- logger.warning("[workflow] pre-turn compaction failed: %s", _pt_exc)
-
- # Load history EXCLUDING the last user message — reply() adds it.
- history = list(_pt_messages)
- if history and history[-1].get("role") in ("user", "human"):
- history.pop()
-
- if history:
- agent.state.context.extend(message_compat.session_to_msgs(history))
-
- # Uploaded-file context is injected uniformly at reply time by
- # FileContextMiddleware (apply_request_context already wrote
- # uploaded_files into state; the middleware validates attachment
- # ownership by state.user_id); no manual append here, otherwise it
- # would duplicate the middleware's injection and waste tokens.
-
- model_user_message = user_message or ""
- _delegated_agent_id = _explicit_agent_id or _mention_agent_id
- if _delegated_agent_id and _visible_subagents:
- from core.llm.subagents.tool import build_explicit_subagent_command_hint
-
- explicit_hint = build_explicit_subagent_command_hint(
- _visible_subagents,
- _delegated_agent_id,
- )
- if explicit_hint:
- rendering_module.append_context_text(
- agent,
- explicit_hint,
- kind=ir_state_module.KIND_REMINDER,
- origin="harness:subagent_routing",
- trust="system",
- priority=950,
- token_budget=4_000,
- truncation_policy=ir_state_module.POLICY_NEVER,
- )
- request_seq = rendering_module.next_request_sequence(agent.state.context)
- user_msg = rendering_module.render_context_item(
- items_module.make_text_context_item(
- model_user_message,
- item_id=f"request:user_input:{request_seq}",
- kind=ir_state_module.KIND_USER_INPUT,
- origin="user:chat",
- trust="user",
- created_seq=request_seq,
- priority=1_000,
- token_budget=100_000,
- truncation_policy=ir_state_module.POLICY_NEVER,
- )
- )
- result = await agent.reply(inputs=user_msg)
- response = result.get_text_content() or ""
- ontology_runtime = context.get("ontology_runtime")
- if not isinstance(ontology_runtime, dict):
- ontology_runtime = {}
- context["ontology_runtime"] = ontology_runtime
- review_owner = ontology_evidence._ontology_review_owner(ontology_runtime, context)
- review_claimed = bool(
- response and output_review_state_module.claim_output_review(ontology_runtime, owner=review_owner)
- )
- if review_claimed:
- from core.ontology.reviewer import review_ontology_output
-
- async def _remediate(payload: Dict[str, Any]) -> str:
- repair_seq = rendering_module.next_request_sequence(agent.state.context)
- repair_msg = rendering_module.render_context_item(
- items_module.make_text_context_item(
- ontology_repair._ontology_repair_prompt(payload),
- item_id=f"request:ontology_repair:{repair_seq}",
- kind=ir_state_module.KIND_REMINDER,
- origin="harness:ontology_repair",
- trust="system",
- created_seq=repair_seq,
- priority=900,
- token_budget=4_000,
- truncation_policy=ir_state_module.POLICY_HEAD_TAIL,
- )
- )
- repaired_result = await agent.reply(inputs=repair_msg)
- return (repaired_result.get_text_content() or "").strip()
-
- try:
- review = await review_ontology_output(
- task=user_message,
- answer=response,
- runtime=ontology_runtime,
- trace=[],
- citations=[],
- user_id=_workflow_user_id,
- chat_id=context.get("chat_id"),
- model_name=_workflow_model_name or None,
- model_provider_id=_workflow_model_provider_id or None,
- trace_complete=False,
- remediate=_remediate,
- )
- except BaseException:
- output_review_state_module.release_output_review(ontology_runtime, owner=review_owner)
- raise
- output_review_state_module.complete_output_review(
- ontology_runtime,
- owner=review_owner,
- verdict=str(review.get("verdict") or "unknown"),
- attempts=int(review.get("attempts") or 1),
- )
- ontology_runtime.setdefault("output_review", {}).update(
- {
- "revised": bool(review.get("revised")),
- "annotated": bool(review.get("annotated")),
- "repair_attempts": int(review.get("repair_attempts") or 0),
- "violations": review.get("violations") or [],
- "affected_claims": review.get("affected_claims") or [],
- "latency_ms": review.get("latency_ms"),
- }
- )
- response = review["answer"]
- return response
- finally:
- await mcp_manager.close_clients(mcp_clients)
-
- try:
- import asyncio as _asyncio
-
- response = _asyncio.run(_run())
- except Exception as e:
- warnings.append(f"Agent execution error: {str(e)[:200]}")
- response = ""
-
- return WorkflowResult(
- route=f"subagent:{_direct_agent_id}" if _direct_agent_id else "main",
- response=response,
- is_markdown=message_parser.looks_markdown(response),
- sources=message_parser.resolve_sources_conflict([]),
- artifacts=[],
- warnings=warnings,
- meta={
- "ontology_governance": ontology_evidence._ontology_governance_summary(
- context.get("ontology_runtime")
- if isinstance(context.get("ontology_runtime"), dict)
- else {}
- )
- },
- )
diff --git a/src/backend/core/agent_runtime/direct.py b/src/backend/core/agent_runtime/direct.py
index cb550a2a..ad1c105d 100644
--- a/src/backend/core/agent_runtime/direct.py
+++ b/src/backend/core/agent_runtime/direct.py
@@ -1,13 +1,12 @@
"""Coordinate the direct agent lifecycle."""
from __future__ import annotations
-from core.agent_runtime.integrations import memory as memory_module
-from core.agent_runtime.integrations import memory_session as memory_session_module
-from core.ontology import output_review_state as output_review_state_module
-from core.ontology import runtime_pack as runtime_pack_module
-from core.runs.tool_effects import errors as errors_module
-from core.llm.citations import allocator as allocator_module
-from core.harness import log_writer
+from core.agent_runtime.integrations import memory
+from core.agent_runtime.integrations import memory_session
+from core.ontology import output_review_state
+from core.ontology import runtime_pack
+from core.runs.tool_effects import errors
+from core.llm.citations import allocator
import asyncio
import logging
@@ -17,23 +16,19 @@
import core.agent_runtime.budget as budget
import core.agent_runtime.integrations.evolution as evolution
-import core.agent_runtime.integrations.memory as memory
import core.agent_runtime.integrations.memory_budget as memory_budget
import core.agent_runtime.integrations.ontology_evidence as ontology_evidence
import core.agent_runtime.integrations.ontology_repair as ontology_repair
-import core.agent_runtime.resources as resources
import core.agent_runtime.targets as targets
import core.chat.message_parser as message_parser
import core.capabilities.catalog.resolver as catalog_resolver
-import core.llm.citations.anchor as citation_anchor
-import core.ontology.validator as validator
-import core.runs.tool_effects.journal as tool_effect_ledger
-logger = logging.getLogger(__name__)
from core.agent_runtime import prepare_direct, stream_direct
from core.agent_runtime.direct_state import DirectTurn
+logger = logging.getLogger(__name__)
+
async def _astream_subagent_direct(
*,
@@ -73,7 +68,7 @@ async def _astream_subagent_direct(
"code_capability_enabled": bool(turn.context.get("code_capability_enabled", False)),
},
)
- from core.harness import log_writer as log_writer
+ from core.harness import log_writer
_direct_log_started = _time.monotonic()
turn.direct_tool_count = 0
@@ -144,7 +139,7 @@ async def record():
if not isinstance(turn.ontology_runtime, dict):
turn.ontology_runtime = {}
turn.context["ontology_runtime"] = turn.ontology_runtime
- activations = runtime_pack_module.activate_runtime_for_asset(
+ activations = runtime_pack.activate_runtime_for_asset(
turn.ontology_runtime,
kind="subagent",
asset_id=agent_id,
@@ -177,7 +172,7 @@ async def record():
turn.memory_enabled,
)
- turn.session_memory = await memory_session_module.open_session_memory(
+ turn.session_memory = await memory_session.open_session_memory(
chat_id=_mem0_chat_id,
scope_user_id=_mem0_scope_user_id,
workspace_id=turn.memory_workspace_id,
@@ -198,9 +193,9 @@ async def record():
turn.ontology_trace: _typing.List[_typing.Dict[str, _typing.Any]] = []
# 证据锚点发号器:跨轮续号;创建后绑到 agent 上(见下方 attach_allocator),
# 中间件与本函数由此共享同一个计数器
- turn.anchor_allocator = allocator_module.AnchorAllocator(
+ turn.anchor_allocator = allocator.AnchorAllocator(
await asyncio.to_thread(
- allocator_module.anchor_start_for_chat,
+ allocator.anchor_start_for_chat,
str(turn.context.get("chat_id") or "") or None,
)
)
@@ -219,13 +214,10 @@ async def record():
logger.error("subagent_stream_error: %s\n%s", e, traceback.format_exc())
warnings.append(f"Streaming error: {str(e)[:200]}")
- unknown_outcome = errors_module.find_tool_outcome_unknown(e)
+ unknown_outcome = errors.find_tool_outcome_unknown(e)
if unknown_outcome is not None:
if turn.streaming_agent is not None:
await turn.streaming_agent.shutdown()
- resources.process_state().persistent_clients.append(
- (turn.streaming_agent, list(turn.mcp_clients))
- )
await _finish_direct_log("failed", error=str(unknown_outcome)[:200])
if unknown_outcome is e:
raise
@@ -238,9 +230,6 @@ async def record():
# would read as a completed answer.
if turn.streaming_agent is not None:
await turn.streaming_agent.shutdown()
- resources.process_state().persistent_clients.append(
- (turn.streaming_agent, list(turn.mcp_clients))
- )
await _finish_direct_log("failed", error=str(e)[:200])
raise
@@ -256,7 +245,7 @@ async def record():
)
_ontology_review_claimed = bool(
turn.full_response
- and output_review_state_module.claim_output_review(
+ and output_review_state.claim_output_review(
turn.ontology_runtime, owner=_ontology_review_owner_id
)
)
@@ -323,17 +312,16 @@ async def _remediate(payload: Dict[str, Any]) -> str:
review = await turn.review_task
except (asyncio.CancelledError, GeneratorExit):
await cancel_and_join(turn.review_task)
- output_review_state_module.release_output_review(
+ output_review_state.release_output_review(
turn.ontology_runtime, owner=_ontology_review_owner_id
)
await turn.streaming_agent.shutdown()
- resources.process_state().persistent_clients.append((turn.streaming_agent, list(turn.mcp_clients)))
raise
except Exception as exc: # noqa: BLE001
if not turn.review_task.done():
turn.review_task.cancel()
review = ontology_evidence._ontology_review_failure_result(turn.full_response, exc)
- output_review_state_module.complete_output_review(
+ output_review_state.complete_output_review(
turn.ontology_runtime,
owner=_ontology_review_owner_id,
verdict=str(review.get("verdict") or "unknown"),
@@ -378,7 +366,6 @@ async def _remediate(payload: Dict[str, Any]) -> str:
_direct_usage = await turn.streaming_agent.aget_usage()
_direct_context_usage = turn.streaming_agent.get_context_usage(_direct_usage)
await turn.streaming_agent.shutdown()
- resources.process_state().persistent_clients.append((turn.streaming_agent, list(turn.mcp_clients)))
await _finish_direct_log(
"success",
output=turn.full_response,
@@ -415,7 +402,7 @@ async def _remediate(payload: Dict[str, Any]) -> str:
# ── [memory] Post-response pipeline (SSE already closed, user isn't waiting) ────
# No memory is written unless the user opted into memory_write_enabled (first gate).
if _mem0_write_enabled:
- memory_module.save_memories_background(
+ memory.save_memories_background(
turn.memory_user_id,
user_message,
turn.full_response,
@@ -457,7 +444,7 @@ async def _remediate(payload: Dict[str, Any]) -> str:
if turn.review_task is not None:
await cancel_and_join(turn.review_task)
if turn.review_owner is not None:
- output_review_state_module.release_output_review(turn.ontology_runtime, owner=turn.review_owner)
+ output_review_state.release_output_review(turn.ontology_runtime, owner=turn.review_owner)
if turn.direct_stream is not None:
await turn.direct_stream.aclose()
if not _direct_log_finished and turn.streaming_agent is not None:
diff --git a/src/backend/core/agent_runtime/events.py b/src/backend/core/agent_runtime/events.py
index 15813a56..92fc500d 100644
--- a/src/backend/core/agent_runtime/events.py
+++ b/src/backend/core/agent_runtime/events.py
@@ -1,4 +1,4 @@
-"""core agent_runtime events responsibilities."""
+"""Helpers for the agent event stream: synthesized tool calls, scoped log context, live-job checks."""
from __future__ import annotations
from core.harness import log_context
@@ -49,7 +49,6 @@ def _synthesize_missing_tool_call(
async def _anext_in_subagent_log_scope(iterator, subagent_log_id: str): # noqa: ANN001
"""Advance one agent-stream item without leaking log tokens across SSE yields."""
- from core.harness import log_writer as log_writer
with log_context.subagent_scope(subagent_log_id, source="subagent"):
return await anext(iterator)
diff --git a/src/backend/core/agent_runtime/integrations/evolution.py b/src/backend/core/agent_runtime/integrations/evolution.py
index a8229cf5..5310da96 100644
--- a/src/backend/core/agent_runtime/integrations/evolution.py
+++ b/src/backend/core/agent_runtime/integrations/evolution.py
@@ -1,4 +1,4 @@
-"""core agent_runtime integrations evolution responsibilities."""
+"""Hand a finished turn's evidence to the evolution loop after the stream closes."""
from __future__ import annotations
diff --git a/src/backend/core/agent_runtime/integrations/memory.py b/src/backend/core/agent_runtime/integrations/memory.py
index d8ce7538..c599ebae 100644
--- a/src/backend/core/agent_runtime/integrations/memory.py
+++ b/src/backend/core/agent_runtime/integrations/memory.py
@@ -1,4 +1,4 @@
-"""memory responsibilities."""
+"""Memory retrieval for a turn: background recall, the frozen memory block and post-turn saving."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
@@ -9,8 +9,9 @@
from core.memory.retrieval_types import MemoryRetrievalResult
from core.memory.service import retrieve_memories_structured
import logging
+from core.agent_runtime.integrations import memory_state
+
logger = logging.getLogger(__name__)
-from core.agent_runtime.integrations import memory_state as memory_state_module
async def launch_memory_retrieval(
user_id: str,
@@ -66,24 +67,24 @@ def as_result(value: object) -> Optional[MemoryRetrievalResult]:
def track_memory_retrieval(task: asyncio.Task, *, budget_s: Optional[float] = None) -> asyncio.Task:
"""Attach lifecycle observation without taking ownership of cancellation."""
- memory_state_module.process_state().retrieval_states[task] = "running"
+ memory_state.process_state().retrieval_states[task] = "running"
if budget_s is not None:
- memory_state_module.process_state().retrieval_budgets[task] = max(0.0, budget_s)
+ memory_state.process_state().retrieval_budgets[task] = max(0.0, budget_s)
def _finished(done: asyncio.Task) -> None:
- previous = memory_state_module.process_state().retrieval_states.get(done)
+ previous = memory_state.process_state().retrieval_states.get(done)
if done.cancelled():
- memory_state_module.process_state().retrieval_states[done] = "cancelled"
+ memory_state.process_state().retrieval_states[done] = "cancelled"
return
try:
value = done.result()
except Exception:
- memory_state_module.process_state().retrieval_states[done] = "failed"
+ memory_state.process_state().retrieval_states[done] = "failed"
return
result = as_result(value)
if result is not None:
set_last_retrieval(done, result)
- memory_state_module.process_state().retrieval_states[done] = (
+ memory_state.process_state().retrieval_states[done] = (
"completed_after_timeout" if previous == "timed_out_running" else "completed"
)
@@ -94,12 +95,12 @@ def _finished(done: asyncio.Task) -> None:
def get_retrieval_state(task: Optional[asyncio.Task]) -> Optional[str]:
if task is None:
return None
- return memory_state_module.process_state().retrieval_states.get(task)
+ return memory_state.process_state().retrieval_states.get(task)
def set_last_retrieval(task: asyncio.Task, result: MemoryRetrievalResult) -> None:
try:
- memory_state_module.process_state().last_retrieval[task] = result
+ memory_state.process_state().last_retrieval[task] = result
except TypeError: # pragma: no cover - non-weakrefable task
pass
@@ -113,7 +114,7 @@ def get_last_retrieval(task: Optional[asyncio.Task]) -> Optional[MemoryRetrieval
if task is None:
return None
try:
- return memory_state_module.process_state().last_retrieval.get(task)
+ return memory_state.process_state().last_retrieval.get(task)
except TypeError: # pragma: no cover
return None
@@ -175,7 +176,7 @@ async def build_frozen_memory_block(
# started before the agent was created, so in most cases it is nearly done by now.
# The old value of 50ms was measured to be far below Milvus warm search's ~200ms,
# so Fact injection almost never hit.
- wait_budget_s = memory_state_module.process_state().retrieval_budgets.get(
+ wait_budget_s = memory_state.process_state().retrieval_budgets.get(
memory_task, max(0.1, settings.memory.retrieval_budget_ms / 1000.0)
)
fact_result = as_result(
@@ -192,7 +193,7 @@ async def build_frozen_memory_block(
set_last_retrieval(memory_task, fact_result)
except asyncio.TimeoutError:
if not memory_task.done():
- memory_state_module.process_state().retrieval_states[memory_task] = "timed_out_running"
+ memory_state.process_state().retrieval_states[memory_task] = "timed_out_running"
logger.info(
"[memory] fact retrieval still running past wait window, skipping injection"
)
diff --git a/src/backend/core/agent_runtime/integrations/memory_budget.py b/src/backend/core/agent_runtime/integrations/memory_budget.py
index 6a15e7dd..b035af85 100644
--- a/src/backend/core/agent_runtime/integrations/memory_budget.py
+++ b/src/backend/core/agent_runtime/integrations/memory_budget.py
@@ -1,4 +1,4 @@
-"""core agent_runtime integrations memory_budget responsibilities."""
+"""How long a turn waits for memory retrieval, per the active agent profile."""
from __future__ import annotations
diff --git a/src/backend/core/agent_runtime/integrations/memory_identity.py b/src/backend/core/agent_runtime/integrations/memory_identity.py
index d38d0bd7..a587d4ea 100644
--- a/src/backend/core/agent_runtime/integrations/memory_identity.py
+++ b/src/backend/core/agent_runtime/integrations/memory_identity.py
@@ -1,13 +1,13 @@
-"""memory identity responsibilities."""
+"""Build and cache the per-user identity block injected at the head of a chat."""
from __future__ import annotations
import asyncio
+from dataclasses import dataclass, field
from time import monotonic
from typing import Dict
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
import logging
+from core.agent_runtime.integrations import memory_state
+
logger = logging.getLogger(__name__)
-from core.agent_runtime.integrations import memory_state as memory_state_module
async def build_user_identity_block(user_id: str) -> str:
"""Assemble the "current user" identity block (username / nickname) for injection into the session-frozen message.
@@ -58,18 +58,17 @@ def _query() -> tuple:
+ "\n".join(lines)
+ "\n需要称呼用户时,用上述昵称(无昵称则用用户名)自然称呼。"
)
- process_state().identity_cache[user_id] = (now + memory_state_module.IDENTITY_CACHE_TTL_S, block)
+ process_state().identity_cache[user_id] = (now + memory_state.IDENTITY_CACHE_TTL_S, block)
return block
-@state_dataclass
+@dataclass
class MemoryState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- identity_cache: Dict[str, tuple] = state_field(default_factory=lambda: {})
+ identity_cache: Dict[str, tuple] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> MemoryState:
- return MemoryState()
+_STATE = MemoryState()
-process_state()
+
+def process_state() -> MemoryState:
+ return _STATE
diff --git a/src/backend/core/agent_runtime/integrations/memory_session.py b/src/backend/core/agent_runtime/integrations/memory_session.py
index 1327a88b..74a0c01c 100644
--- a/src/backend/core/agent_runtime/integrations/memory_session.py
+++ b/src/backend/core/agent_runtime/integrations/memory_session.py
@@ -1,15 +1,14 @@
-"""memory session responsibilities."""
+"""Open, assemble and inject the chat's session-frozen memory and identity blocks."""
from __future__ import annotations
-from core.agent_runtime.integrations import memory as memory_module_
+from core.agent_runtime.integrations import memory
import asyncio
from dataclasses import dataclass, replace
from typing import Any, Dict, List, Optional
-from core.llm.context.ir_state import KIND_IDENTITY, KIND_MEMORY, SESSION_CONTEXT_META_KEY
+from core.llm.context.ir_constants import KIND_IDENTITY, KIND_MEMORY, SESSION_CONTEXT_META_KEY
from core.llm.context.items import make_text_context_item
from core.memory.session_snapshot import SessionMemorySnapshot, load_session_memory_snapshot, save_session_memory_snapshot
import logging
logger = logging.getLogger(__name__)
-from core.agent_runtime.integrations import memory as memory_module
@dataclass(frozen=True)
class SessionMemory:
@@ -70,7 +69,7 @@ async def open_session_memory(
)
return replace(handle, snapshot=snapshot)
- task = await memory_module_.launch_memory_retrieval(
+ task = await memory.launch_memory_retrieval(
scope_user_id,
user_message,
memory_enabled,
@@ -95,7 +94,7 @@ async def resolve_session_memory(handle: SessionMemory) -> str:
if handle.snapshot is not None:
return handle.snapshot.text
- built = await memory_module_.build_frozen_memory_block(
+ built = await memory.build_frozen_memory_block(
handle.scope_user_id,
handle.workspace_id,
handle.retrieval_task,
diff --git a/src/backend/core/agent_runtime/integrations/memory_state.py b/src/backend/core/agent_runtime/integrations/memory_state.py
index def21ad5..68865743 100644
--- a/src/backend/core/agent_runtime/integrations/memory_state.py
+++ b/src/backend/core/agent_runtime/integrations/memory_state.py
@@ -1,33 +1,23 @@
-"""memory state responsibilities."""
+"""Per-task memory retrieval results, states and budgets shared within one process."""
from __future__ import annotations
import weakref
import asyncio
+from dataclasses import dataclass, field
from core.memory.retrieval_types import MemoryRetrievalResult
-
-
-
-
-
-
-
-
IDENTITY_CACHE_TTL_S = 60.0
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class MemoryState:
+ last_retrieval: 'weakref.WeakKeyDictionary[asyncio.Task, MemoryRetrievalResult]' = field(default_factory=lambda: weakref.WeakKeyDictionary())
+ retrieval_states: 'weakref.WeakKeyDictionary[asyncio.Task, str]' = field(default_factory=lambda: weakref.WeakKeyDictionary())
+ retrieval_budgets: 'weakref.WeakKeyDictionary[asyncio.Task, float]' = field(default_factory=lambda: weakref.WeakKeyDictionary())
+
-@state_dataclass
-class MemoryStateState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- last_retrieval: 'weakref.WeakKeyDictionary[asyncio.Task, MemoryRetrievalResult]' = state_field(default_factory=lambda: weakref.WeakKeyDictionary())
- retrieval_states: 'weakref.WeakKeyDictionary[asyncio.Task, str]' = state_field(default_factory=lambda: weakref.WeakKeyDictionary())
- retrieval_budgets: 'weakref.WeakKeyDictionary[asyncio.Task, float]' = state_field(default_factory=lambda: weakref.WeakKeyDictionary())
+_STATE = MemoryState()
-@state_cache(maxsize=1)
-def process_state() -> MemoryStateState:
- return MemoryStateState()
-process_state()
+def process_state() -> MemoryState:
+ return _STATE
diff --git a/src/backend/core/agent_runtime/integrations/ontology_evidence.py b/src/backend/core/agent_runtime/integrations/ontology_evidence.py
index 66b102e5..48a3d4a9 100644
--- a/src/backend/core/agent_runtime/integrations/ontology_evidence.py
+++ b/src/backend/core/agent_runtime/integrations/ontology_evidence.py
@@ -1,15 +1,13 @@
-"""core agent_runtime integrations ontology_evidence responsibilities."""
+"""Collect and summarise ontology governance evidence for the final answer review."""
from __future__ import annotations
-from core.ontology import output_review_state as output_review_state_module
-from core.llm.citations import allocator as allocator_module
+from core.ontology import output_review_state
+from core.llm.citations.allocator import collect_citation_dicts
import json
import logging
from typing import Any, Dict, List, Optional
-import core.llm.citations.anchor as citation_anchor
-import core.ontology.validator as validator
from core.llm.citations.allocator import AnchorAllocator
logger = logging.getLogger(__name__)
@@ -89,7 +87,7 @@ def _ontology_governance_summary(runtime: Dict[str, Any]) -> Optional[Dict[str,
review_state["committee_size"] = _ontology_review_event_context(runtime)["committee_size"]
if not activations and not gates and review_state.get("status") == "pending":
return None
- if review_state.get("status") == "pending" and not output_review_state_module.requires_output_review(runtime):
+ if review_state.get("status") == "pending" and not output_review_state.requires_output_review(runtime):
review_state["status"] = "skipped"
return {
"governance_run_id": runtime.get("governance_run_id"),
@@ -215,7 +213,7 @@ def _capture_nested_ontology_evidence(
return []
result = _parse_tool_result_value(payload.get("output"))
- cit_dicts = allocator_module.collect_citation_dicts(tool_id, allocator)
+ cit_dicts = collect_citation_dicts(tool_id, allocator)
citations.extend(cit_dicts)
trace.append(
{
diff --git a/src/backend/core/agent_runtime/integrations/ontology_repair.py b/src/backend/core/agent_runtime/integrations/ontology_repair.py
index 46d6e833..78a01e25 100644
--- a/src/backend/core/agent_runtime/integrations/ontology_repair.py
+++ b/src/backend/core/agent_runtime/integrations/ontology_repair.py
@@ -1,7 +1,7 @@
-"""core agent_runtime integrations ontology_repair responsibilities."""
+"""Re-run the originating agent to produce a revision when ontology review rejects an answer."""
from __future__ import annotations
-from core.llm.citations import allocator as allocator_module
+from core.llm.citations.allocator import collect_citation_dicts
import json
import logging
@@ -11,7 +11,6 @@
import core.agent_runtime.events as agent_runtime_events
import core.agent_runtime.integrations.ontology_evidence as ontology_evidence
import core.config.display_names as display_names
-import core.llm.citations.anchor as citation_anchor
import core.ontology.revision as revision
from core.llm.citations.allocator import AnchorAllocator
from core.llm.runtime.streaming import StreamingAgent
@@ -282,7 +281,7 @@ def _is_revision_opening_boundary(line_before_open: str) -> bool:
tool_name = str(event_payload.get("name") or "unknown")
tool_id = str(event_payload.get("id") or "")
result = ontology_evidence._parse_tool_result_value(event_payload.get("content"))
- cit_dicts = allocator_module.collect_citation_dicts(tool_id, allocator)
+ cit_dicts = collect_citation_dicts(tool_id, allocator)
citations.extend(cit_dicts)
trace.append(
{
diff --git a/src/backend/core/agent_runtime/prepare_direct.py b/src/backend/core/agent_runtime/prepare_direct.py
index ed40332f..c6aa980a 100644
--- a/src/backend/core/agent_runtime/prepare_direct.py
+++ b/src/backend/core/agent_runtime/prepare_direct.py
@@ -1,25 +1,23 @@
"""Assemble a directly selected agent and its contextual input."""
from __future__ import annotations
-from core.agent_runtime.integrations import memory_identity as memory_identity_module
-from core.agent_runtime.integrations import memory_session as memory_session_module
-from core.llm.runtime import streaming as streaming_module
-from core.llm.citations import allocator as allocator_module
+from core.agent_runtime.integrations import memory_identity
+from core.agent_runtime.integrations import memory_session
+from core.llm.runtime import streaming
+from core.llm.citations import allocator
import logging
import typing as _typing
import core.agent_runtime.budget as budget
-import core.agent_runtime.integrations.memory as memory
import core.agent_runtime.targets as targets
-import core.llm.citations.anchor as citation_anchor
import core.llm.factory as factory
-import core.llm.runtime.streaming as streaming
from core.agent_runtime.direct_state import DirectTurn
-logger = logging.getLogger(__name__)
import time as _time
+logger = logging.getLogger(__name__)
+
async def prepare_direct(turn: DirectTurn):
yield {"type": "thinking", "message": "正在连接子智能体..."}
@@ -87,24 +85,24 @@ async def prepare_direct(turn: DirectTurn):
# 证据锚点:把发号器绑到 agent 上(中间件与本函数由此共享同一个计数器;
# 仅靠 ContextVar 不行——本函数是 async generator,与 agent 执行所在的
# task 上下文不互通)
- allocator_module.attach_allocator(turn.agent, turn.anchor_allocator)
+ allocator.attach_allocator(turn.agent, turn.anchor_allocator)
# ── Session-constant blocks: user identity + the chat's frozen memory
# snapshot (built on the first turn, replayed byte-for-byte after). ──
- turn.session_messages = await memory_session_module.inject_session_blocks(
+ turn.session_messages = await memory_session.inject_session_blocks(
turn.session_messages,
identity_block=(
""
if turn.context.get("agent_api_scope") or turn.context.get("evaluation_session")
- else await memory_identity_module.build_user_identity_block(turn.memory_user_id)
+ else await memory_identity.build_user_identity_block(turn.memory_user_id)
),
- memory_block=await memory_session_module.resolve_session_memory(turn.session_memory),
+ memory_block=await memory_session.resolve_session_memory(turn.session_memory),
)
# The canonical ContextAssembler owns selection and truncation. Feed
# it the complete candidate set so every exclusion is visible in the
# request manifest rather than silently pre-trimming shared history.
- turn.streaming_agent = streaming_module.StreamingAgent(turn.agent, turn.mcp_clients)
+ turn.streaming_agent = streaming.StreamingAgent(turn.agent, turn.mcp_clients)
turn.skill_load_ids: set = set()
# tool_id → skill_id (parsed from the tool_call's file_path; looked up at tool_result time to replace the SSE payload with the curated detail, avoiding sending the full SKILL.md text to the frontend)
turn.skill_id_by_tool_id: _typing.Dict[str, str] = {}
diff --git a/src/backend/core/agent_runtime/prepare_main.py b/src/backend/core/agent_runtime/prepare_main.py
index cf180d10..6a435bef 100644
--- a/src/backend/core/agent_runtime/prepare_main.py
+++ b/src/backend/core/agent_runtime/prepare_main.py
@@ -1,29 +1,36 @@
"""Assemble the main agent and its complete contextual input."""
from __future__ import annotations
-from core.agent_runtime.integrations import memory_identity as memory_identity_module
-from core.agent_runtime.integrations import memory_session as memory_session_module
-from core.llm.runtime import streaming as streaming_module
-from core.llm.context import ir_state as ir_state_module
-from core.llm.context import items as items_module
-from core.llm.citations import allocator as allocator_module
+from core.agent_runtime.integrations import memory_identity
+from core.agent_runtime.integrations import memory_session
+from core.llm.runtime import streaming
+from core.llm.context import ir_constants
+from core.llm.context import items
+from core.llm.citations import allocator
import logging
import typing as _typing
import core.agent_runtime.budget as budget
-import core.agent_runtime.integrations.memory as memory
import core.agent_runtime.skills as skills
import core.agent_runtime.targets as targets
import core.capabilities.catalog.resolver as catalog_resolver
-import core.llm.citations.anchor as citation_anchor
-import core.llm.context.ir as context_ir
import core.llm.factory as factory
from core.agent_runtime.main_state import MainTurn
+from core.db.threads import run_in_session
logger = logging.getLogger(__name__)
+def _load_subagent_visibility(db, user_id: str, *, evaluation: bool):
+ """The user's own sub-agents and the built-in ones they switched off."""
+ from core.capabilities.agents.edition import UserAgentService
+ from core.auth.users import UserService
+
+ visible = [] if evaluation else UserAgentService(db).list_for_user(user_id)
+ return visible, UserService(db).get_disabled_builtin_subagent_ids(user_id)
+
+
async def prepare_main(turn: MainTurn):
import time as _time
@@ -52,20 +59,11 @@ async def prepare_main(turn: MainTurn):
)
_mention_agent_id = str(turn.context.get("mention_agent_id") or "")
try:
- from core.db.engine import SessionLocal as _SessionLocal
- from core.capabilities.agents.edition import UserAgentService as _UAS
- from core.auth.users import UserService as _UserService
-
- with _SessionLocal() as _db:
- _ua_svc = _UAS(_db)
- turn.visible_subagents = (
- []
- if turn.context.get("evaluation_session")
- else _ua_svc.list_for_user(_stream_user_id)
- )
- _disabled_builtin_ids = _UserService(_db).get_disabled_builtin_subagent_ids(
- _stream_user_id
- )
+ turn.visible_subagents, _disabled_builtin_ids = await run_in_session(
+ _load_subagent_visibility,
+ _stream_user_id,
+ evaluation=bool(turn.context.get("evaluation_session")),
+ )
except Exception as _exc:
logger.warning("[workflow] failed to load visible subagents: %s", _exc)
_disabled_builtin_ids = set()
@@ -180,7 +178,7 @@ async def prepare_main(turn: MainTurn):
# 证据锚点:把发号器绑到 agent 上(中间件与本函数由此共享同一个计数器;
# 仅靠 ContextVar 不行——本函数是 async generator,与 agent 执行所在的
# task 上下文不互通)
- allocator_module.attach_allocator(turn.agent, turn.anchor_allocator)
+ allocator.attach_allocator(turn.agent, turn.anchor_allocator)
# ── Inject the per-turn sub-agent constraint into the current user message ──
# Keeps it OUT of the system prompt so the LLM provider's prefix cache
@@ -207,23 +205,23 @@ async def prepare_main(turn: MainTurn):
and turn.session_messages
and turn.session_messages[-1].get("role") in ("user", "human")
):
- mention_item = items_module.make_text_context_item(
+ mention_item = items.make_text_context_item(
_mention_hint,
item_id="harness:subagent_routing",
- kind=ir_state_module.KIND_REMINDER,
+ kind=ir_constants.KIND_REMINDER,
origin="harness:subagent_routing",
trust="system",
created_seq=0,
priority=950,
token_budget=4_000,
- truncation_policy=ir_state_module.POLICY_NEVER,
+ truncation_policy=ir_constants.POLICY_NEVER,
)
turn.session_messages.insert(
-1,
{
"role": "user",
"content": _mention_hint,
- ir_state_module.SESSION_CONTEXT_META_KEY: items_module.session_context_metadata(
+ ir_constants.SESSION_CONTEXT_META_KEY: items.session_context_metadata(
mention_item
),
},
@@ -268,21 +266,21 @@ async def prepare_main(turn: MainTurn):
# snapshot. Built on the first turn (L1 profile always, L2 facts only if
# retrieval beat its budget), replayed byte-for-byte on every turn after,
# so this head of the message list never moves the prefix-cache boundary.
- turn.session_messages = await memory_session_module.inject_session_blocks(
+ turn.session_messages = await memory_session.inject_session_blocks(
turn.session_messages,
identity_block=(
""
if turn.context.get("evaluation_session")
- else await memory_identity_module.build_user_identity_block(turn.memory_user_id)
+ else await memory_identity.build_user_identity_block(turn.memory_user_id)
),
- memory_block=await memory_session_module.resolve_session_memory(turn.session_memory),
+ memory_block=await memory_session.resolve_session_memory(turn.session_memory),
)
# The final request assembler receives the full post-compaction
# candidate set. It is the only budget selector, and records every
# included, truncated, or excluded item in the context manifest.
- turn.streaming_agent = streaming_module.StreamingAgent(turn.agent, turn.mcp_clients)
+ turn.streaming_agent = streaming.StreamingAgent(turn.agent, turn.mcp_clients)
turn.skill_load_ids: set = set() # track tool_ids that are skill loads
# tool_id → skill_id; looked up at tool_result time to replace the SSE payload with the curated detail
diff --git a/src/backend/core/agent_runtime/resources.py b/src/backend/core/agent_runtime/resources.py
deleted file mode 100644
index 9fd6f64a..00000000
--- a/src/backend/core/agent_runtime/resources.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""core agent_runtime resources responsibilities."""
-
-from __future__ import annotations
-
-import logging
-
-logger = logging.getLogger(__name__)
-
-
-
-
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
-class ResourcesState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- persistent_clients: list = state_field(default_factory=lambda: [])
-
-@state_cache(maxsize=1)
-def process_state() -> ResourcesState:
- return ResourcesState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
diff --git a/src/backend/core/agent_runtime/runner.py b/src/backend/core/agent_runtime/runner.py
index 8ade6237..332dbb2f 100644
--- a/src/backend/core/agent_runtime/runner.py
+++ b/src/backend/core/agent_runtime/runner.py
@@ -1,12 +1,12 @@
"""Coordinate the main agent lifecycle."""
from __future__ import annotations
-from core.agent_runtime.integrations import memory as memory_module
-from core.agent_runtime.integrations import memory_session as memory_session_module
-from core.ontology import output_review_state as output_review_state_module
-from core.ontology import runtime_pack as runtime_pack_module
-from core.runs.tool_effects import errors as errors_module
-from core.llm.citations import allocator as allocator_module
+from core.agent_runtime.integrations import memory
+from core.agent_runtime.integrations import memory_session
+from core.ontology import output_review_state
+from core.ontology import runtime_pack
+from core.runs.tool_effects import errors
+from core.llm.citations import allocator
from core.ontology import runtime_policy
import asyncio
@@ -19,25 +19,20 @@
import core.agent_runtime.direct as direct
import core.agent_runtime.events as events
import core.agent_runtime.integrations.evolution as evolution
-import core.agent_runtime.integrations.memory as memory
import core.agent_runtime.integrations.memory_budget as memory_budget
import core.agent_runtime.integrations.ontology_evidence as ontology_evidence
import core.agent_runtime.integrations.ontology_repair as ontology_repair
-import core.agent_runtime.resources as resources
import core.agent_runtime.skills as skills
import core.agent_runtime.targets as targets
import core.chat.message_parser as message_parser
import core.chat.plan_progress as plan_progress
-import core.llm.citations.anchor as citation_anchor
-import core.ontology.validator as validator
-import core.ontology.service as ontology_service
-import core.runs.tool_effects.journal as tool_effect_ledger
-logger = logging.getLogger(__name__)
from core.agent_runtime import prepare_main, stream_main
from core.agent_runtime.main_state import MainTurn
+logger = logging.getLogger(__name__)
+
async def astream_chat_workflow(
*, session_messages: List[Dict[str, Any]], user_message: str, context: Dict[str, Any]
@@ -54,7 +49,7 @@ async def astream_chat_workflow(
if turn.review_task is not None:
await cancel_and_join(turn.review_task)
if turn.review_owner is not None:
- output_review_state_module.release_output_review(turn.ontology_runtime, owner=turn.review_owner)
+ output_review_state.release_output_review(turn.ontology_runtime, owner=turn.review_owner)
await _close_turn(turn)
@@ -62,7 +57,6 @@ async def _close_turn(turn: MainTurn) -> None:
if turn.streaming_agent is not None and not turn.closed:
turn.closed = True
await turn.streaming_agent.shutdown()
- resources.process_state().persistent_clients.append((turn.streaming_agent, list(turn.mcp_clients)))
async def _stream_turn(turn: MainTurn):
@@ -111,7 +105,7 @@ async def _stream_turn(turn: MainTurn):
asset_id=skill_id,
user_id=str(turn.context.get("user_id", "") or ""),
)
- activations = runtime_pack_module.activate_runtime_for_asset(
+ activations = runtime_pack.activate_runtime_for_asset(
_request_ontology_runtime,
kind="skill",
asset_id=skill_id,
@@ -154,7 +148,7 @@ async def _stream_turn(turn: MainTurn):
_mem0_write_enabled,
)
- turn.session_memory = await memory_session_module.open_session_memory(
+ turn.session_memory = await memory_session.open_session_memory(
chat_id=_mem0_chat_id,
scope_user_id=_mem0_scope_user_id,
workspace_id=turn.memory_workspace_id,
@@ -180,9 +174,9 @@ async def _stream_turn(turn: MainTurn):
_stream_errored = False
# 证据锚点发号器:跨轮续号;创建后绑到 agent 上(见下方 attach_allocator),
# 中间件与本函数由此共享同一个计数器
- turn.anchor_allocator = allocator_module.AnchorAllocator(
+ turn.anchor_allocator = allocator.AnchorAllocator(
await asyncio.to_thread(
- allocator_module.anchor_start_for_chat, str(turn.context.get("chat_id") or "") or None
+ allocator.anchor_start_for_chat, str(turn.context.get("chat_id") or "") or None
)
)
@@ -207,7 +201,7 @@ async def _stream_turn(turn: MainTurn):
warnings.append(f"Streaming error: {str(e)[:200]}")
_stream_errored = True
- unknown_outcome = errors_module.find_tool_outcome_unknown(e)
+ unknown_outcome = errors.find_tool_outcome_unknown(e)
if unknown_outcome is not None:
if turn.streaming_agent is not None:
await _close_turn(turn)
@@ -236,7 +230,7 @@ async def _stream_turn(turn: MainTurn):
)
_ontology_review_claimed = bool(
turn.full_response
- and output_review_state_module.claim_output_review(turn.ontology_runtime, owner=_ontology_review_owner_id)
+ and output_review_state.claim_output_review(turn.ontology_runtime, owner=_ontology_review_owner_id)
)
if _ontology_review_claimed:
turn.review_owner = _ontology_review_owner_id
@@ -299,14 +293,14 @@ async def _remediate(payload: Dict[str, Any]) -> str:
from core.infra.task_lifecycle import cancel_and_join
await cancel_and_join(review_task)
- output_review_state_module.release_output_review(turn.ontology_runtime, owner=_ontology_review_owner_id)
+ output_review_state.release_output_review(turn.ontology_runtime, owner=_ontology_review_owner_id)
await _close_turn(turn)
raise
except Exception as exc: # noqa: BLE001
if not review_task.done():
review_task.cancel()
review = ontology_evidence._ontology_review_failure_result(turn.full_response, exc)
- output_review_state_module.complete_output_review(
+ output_review_state.complete_output_review(
turn.ontology_runtime,
owner=_ontology_review_owner_id,
verdict=str(review.get("verdict") or "unknown"),
@@ -415,7 +409,7 @@ async def _remediate(payload: Dict[str, Any]) -> str:
turn.memory_user_id,
turn.memory_workspace_id,
)
- memory_module.save_memories_background(
+ memory.save_memories_background(
turn.memory_user_id,
turn.user_message,
turn.full_response,
diff --git a/src/backend/core/agent_runtime/skills.py b/src/backend/core/agent_runtime/skills.py
index 1a76ace3..0e34ea24 100644
--- a/src/backend/core/agent_runtime/skills.py
+++ b/src/backend/core/agent_runtime/skills.py
@@ -1,14 +1,13 @@
-"""core agent_runtime skills responsibilities."""
+"""Turn explicitly invoked skills into the load-instruction message for a turn."""
from __future__ import annotations
-from core.llm.context import ir_state as ir_state_module
-from core.llm.context import items as items_module
+from core.llm.context import ir_constants
+from core.llm.context import items
import logging
import typing as _typing
from typing import Any, Dict, List, Optional
-import core.llm.context.ir as context_ir
logger = logging.getLogger(__name__)
@@ -159,19 +158,19 @@ def _build_skill_injection(context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ "\n\n".join(sections)
+ "\n"
)
- item = items_module.make_text_context_item(
+ item = items.make_text_context_item(
content,
item_id="harness:explicit_invocation",
- kind=ir_state_module.KIND_REMINDER,
+ kind=ir_constants.KIND_REMINDER,
origin="harness:explicit_invocation",
trust="system",
created_seq=0,
priority=900,
token_budget=8_000,
- truncation_policy=ir_state_module.POLICY_HEAD_TAIL,
+ truncation_policy=ir_constants.POLICY_HEAD_TAIL,
)
return {
"role": "user",
"content": content,
- ir_state_module.SESSION_CONTEXT_META_KEY: items_module.session_context_metadata(item),
+ ir_constants.SESSION_CONTEXT_META_KEY: items.session_context_metadata(item),
}
diff --git a/src/backend/core/agent_runtime/stream_direct.py b/src/backend/core/agent_runtime/stream_direct.py
index cbf1fd4c..229a639b 100644
--- a/src/backend/core/agent_runtime/stream_direct.py
+++ b/src/backend/core/agent_runtime/stream_direct.py
@@ -1,7 +1,7 @@
"""Translate direct-agent output while preserving scoped audit context."""
from __future__ import annotations
-from core.llm.citations import allocator as allocator_module
+from core.llm.citations import allocator
import json
import logging
@@ -11,7 +11,6 @@
import core.agent_runtime.skills as skills
import core.chat.tool_payloads as tool_payloads
import core.config.display_names as display_names
-import core.llm.citations.anchor as citation_anchor
from core.agent_runtime.direct_state import DirectTurn
logger = logging.getLogger(__name__)
@@ -145,6 +144,9 @@ async def stream_direct(turn: DirectTurn):
"tool_id": tool_id,
}
+ elif event_type == "tool_progress":
+ yield {"type": "tool_progress", **payload}
+
elif event_type == "tool_result":
tool_name = payload.get("name", "unknown")
tool_id = payload.get("id", "")
@@ -177,6 +179,8 @@ async def stream_direct(turn: DirectTurn):
tool_result_json = json.loads(tool_content) if tool_content else {}
except json.JSONDecodeError:
tool_result_json = {"result": tool_content}
+ if isinstance(payload.get("mcp"), dict):
+ tool_result_json = payload["mcp"]
# Skill load: replace the full SKILL.md text with the same
# curated detail used by the capability center; affects
@@ -206,7 +210,7 @@ async def stream_direct(turn: DirectTurn):
if isinstance(result_data, dict):
extracted_query = result_data.get("query", result_data.get("question", ""))
- cit_dicts = allocator_module.collect_citation_dicts(tool_id, turn.anchor_allocator)
+ cit_dicts = allocator.collect_citation_dicts(tool_id, turn.anchor_allocator)
turn.all_citations.extend(cit_dicts)
turn.ontology_trace.append(
{
diff --git a/src/backend/core/agent_runtime/stream_main.py b/src/backend/core/agent_runtime/stream_main.py
index fdbad229..84916e9e 100644
--- a/src/backend/core/agent_runtime/stream_main.py
+++ b/src/backend/core/agent_runtime/stream_main.py
@@ -1,7 +1,7 @@
"""Translate main-agent model and tool output into chat events."""
from __future__ import annotations
-from core.llm.citations import allocator as allocator_module
+from core.llm.citations import allocator
import json
import logging
@@ -12,7 +12,6 @@
import core.chat.plan_progress as plan_progress
import core.chat.tool_payloads as tool_payloads
import core.config.display_names as display_names
-import core.llm.citations.anchor as citation_anchor
import core.llm.tools.plan_update as plan_update_tool
from core.agent_runtime.main_state import MainTurn
@@ -180,6 +179,9 @@ async def stream_main(turn: MainTurn):
**({"subagent_name": _tc_sa_name} if _tc_sa_name else {}),
}
+ elif event_type == "tool_progress":
+ yield {"type": "tool_progress", **payload}
+
elif event_type == "tool_result":
tool_name = payload.get("name", "unknown")
tool_id = payload.get("id", "")
@@ -223,6 +225,8 @@ async def stream_main(turn: MainTurn):
tool_result_json = json.loads(tool_content) if tool_content else {}
except json.JSONDecodeError:
tool_result_json = {"result": tool_content}
+ if isinstance(payload.get("mcp"), dict):
+ tool_result_json = payload["mcp"]
# Skill load: replace the full SKILL.md text with the same curated detail used by the capability center
if is_skill_result:
@@ -251,7 +255,7 @@ async def stream_main(turn: MainTurn):
extracted_query = result_data.get("query", result_data.get("question", ""))
# Citations
- cit_dicts = allocator_module.collect_citation_dicts(tool_id, turn.anchor_allocator)
+ cit_dicts = allocator.collect_citation_dicts(tool_id, turn.anchor_allocator)
turn.all_citations.extend(cit_dicts)
turn.ontology_trace.append(
{
diff --git a/src/backend/core/agent_runtime/targets.py b/src/backend/core/agent_runtime/targets.py
index 55c6d8b2..cf726843 100644
--- a/src/backend/core/agent_runtime/targets.py
+++ b/src/backend/core/agent_runtime/targets.py
@@ -1,4 +1,4 @@
-"""core agent_runtime targets responsibilities."""
+"""Resolve which agent a turn runs: mode bindings, @mentions, direct sub-agent chats, project scope."""
from __future__ import annotations
diff --git a/src/backend/core/auth/__init__.py b/src/backend/core/auth/__init__.py
index 86edd465..33d54e52 100644
--- a/src/backend/core/auth/__init__.py
+++ b/src/backend/core/auth/__init__.py
@@ -8,7 +8,7 @@
from importlib import import_module
-_BACKEND_EXPORTS = {"AuthService", "UserContext", "get_current_user", "require_auth"}
+_BACKEND_EXPORTS = {"UserContext", "get_current_user", "require_auth"}
def __getattr__(name: str):
diff --git a/src/backend/core/auth/api_key_runs.py b/src/backend/core/auth/api_key_runs.py
index 3aa71787..be63c55b 100644
--- a/src/backend/core/auth/api_key_runs.py
+++ b/src/backend/core/auth/api_key_runs.py
@@ -93,22 +93,25 @@ def cancel_key_runs(db: Session, key_id: str, user_id: str, now: datetime) -> No
cancel_run_in_session(db, row, reason="API key disabled or revoked", now=now)
-async def stop_cancelled_key_workers(db: Session, key_id: str, user_id: str) -> None:
- """Wake SSE followers and stop local workers after the durable commit."""
- from core.harness.registry.run_cancellation import cancel_run
-
- runs = (
- _key_runs_query(db, key_id, user_id)
+def _cancelled_key_runs(db: Session, key_id: str, user_id: str) -> list[str]:
+ return [
+ run_id
+ for (run_id,) in _key_runs_query(db, key_id, user_id)
.with_entities(ChatRun.run_id)
.filter(
ChatRun.status == "cancelled",
ChatRun.failure_reason == "API key disabled or revoked",
)
.all()
- )
- # End the read transaction before waiting for workers using separate sessions.
- db.rollback()
- for (run_id,) in runs:
+ ]
+
+
+async def stop_cancelled_key_workers(key_id: str, user_id: str) -> None:
+ """Wake SSE followers and stop local workers after the durable commit."""
+ from core.db.threads import run_in_session
+ from core.harness.registry.run_cancellation import cancel_run
+
+ for run_id in await run_in_session(_cancelled_key_runs, key_id, user_id):
await cancel_run(run_id, user_id=user_id, stop_terminal_worker=True)
diff --git a/src/backend/core/auth/backend.py b/src/backend/core/auth/backend.py
index 07495a74..7ff08d95 100644
--- a/src/backend/core/auth/backend.py
+++ b/src/backend/core/auth/backend.py
@@ -8,11 +8,11 @@
from typing import Any, Dict, Optional
-import requests
+from core.auth.bearer import MOCK_TOKEN, verify_bearer
from core.auth.edition import AccountCapacityExceeded
from core.config.settings import settings
-from core.db.engine import get_db
from core.db.repository import AuditLogRepository
+from core.db.threads import run_in_session
from core.infra.logging import get_logger
from core.auth.users import UserService
from fastapi import Depends, HTTPException, Request
@@ -46,100 +46,71 @@ def _auth_mode() -> str:
return settings.auth.mode
-class AuthService:
- """Authentication service for mock / remote Bearer token modes."""
-
- def __init__(self):
- self.auth_mode = _auth_mode()
- self.user_center_url = settings.auth.api_url
- self.timeout = settings.auth.api_timeout
- self.retry_count = settings.auth.retry_count
-
- def verify_token_remote(self, token: str) -> Optional[Dict[str, Any]]:
- """Verify token with remote user center."""
- if not self.user_center_url:
- raise HTTPException(
- status_code=500,
- detail={"code": 52002, "message": "AUTH_API_URL not configured", "data": {}},
- )
-
- headers = {"Authorization": f"Bearer {token}"}
-
- for attempt in range(self.retry_count):
- try:
- response = requests.get(
- f"{self.user_center_url}/verify", headers=headers, timeout=self.timeout
- )
+def _audit_rejected_token(db: Session) -> None:
+ # No user to attribute it to: user_id is a foreign key, so it stays NULL, and
+ # status must be one of success / failure / error.
+ try:
+ AuditLogRepository(db).create(
+ {
+ "user_id": None,
+ "action": "auth.login.failed",
+ "resource_type": "user",
+ "resource_id": "unknown",
+ "status": "failure",
+ "details": {"reason": "invalid_or_expired_token"},
+ }
+ )
+ except Exception as e:
+ logger.warning(f"Failed to log auth failure: {e}")
+
+
+def _bind_bearer_user(db: Session, identity: Dict[str, Any], *, audit_login: bool) -> UserContext:
+ """Map a verified identity onto its shadow user (database work, run off the event loop)."""
+ user_shadow = UserService(db).get_or_create_user_shadow(
+ user_center_id=identity["user_center_id"],
+ username=identity["username"],
+ email=identity.get("email"),
+ avatar_url=identity.get("avatar_url"),
+ refresh_unchanged=False,
+ )
+ if audit_login:
+ AuditLogRepository(db).create(
+ {
+ "user_id": user_shadow.user_id,
+ "action": "auth.login.success",
+ "resource_type": "user",
+ "resource_id": user_shadow.user_id,
+ "status": "success",
+ }
+ )
+ return UserContext(
+ user_id=user_shadow.user_id,
+ user_center_id=user_shadow.user_center_id,
+ username=user_shadow.username,
+ email=user_shadow.email,
+ avatar_url=user_shadow.avatar_url,
+ )
- if response.status_code == 200:
- return response.json()
- elif response.status_code == 401:
- return None
- else:
- continue
-
- except requests.RequestException as e:
- if attempt == self.retry_count - 1:
- raise HTTPException(
- status_code=502,
- detail={
- "code": 52001,
- "message": "User center unavailable",
- "data": {"error": str(e)},
- },
- )
- continue
- return None
+async def _resolve_bearer_user(token: str, *, audit: bool) -> UserContext:
+ """Verify a mock / remote Bearer token and bind it to its shadow user.
- def verify_token_mock(self, token: str) -> Dict[str, Any]:
- """Mock authentication for development."""
- if token and token != "mock_token":
- return {
- "user_center_id": token,
- "username": token,
- "email": f"{token}@mock.local",
- "avatar_url": None,
- }
- return {
- "user_center_id": settings.auth.mock_user_id,
- "username": settings.auth.mock_username,
- "email": "dev@example.com",
- "avatar_url": None,
- }
-
- def verify_token(self, token: str, db: Session = None) -> Dict[str, Any]:
- """Verify token based on configured auth mode (mock or remote)."""
- if self.auth_mode == "mock":
- return self.verify_token_mock(token)
- else:
- user_info = self.verify_token_remote(token)
- if not user_info:
- if db:
- try:
- audit_repo = AuditLogRepository(db)
- audit_repo.create(
- {
- "user_id": "unknown",
- "action": "auth.login.failed",
- "resource_type": "user",
- "resource_id": "unknown",
- "status": "failed",
- "details": {"reason": "invalid_or_expired_token"},
- }
- )
- except Exception as e:
- logger.warning(f"Failed to log auth failure: {e}")
-
- raise HTTPException(
- status_code=401,
- detail={
- "code": 30002,
- "message": "Invalid or expired token",
- "data": {"login_url": _sso_login_url()},
- },
- )
- return user_info
+ A login is audited when the user center actually verified the token, not
+ on every request that reuses that verification.
+ """
+ identity, checked = await verify_bearer(token)
+ if identity is None:
+ if audit:
+ await run_in_session(_audit_rejected_token)
+ raise HTTPException(
+ status_code=401,
+ detail={
+ "code": 30002,
+ "message": "Invalid or expired token",
+ "data": {"login_url": _sso_login_url()},
+ },
+ )
+ return await run_in_session(_bind_bearer_user, identity, audit_login=audit and checked)
# Bearer token scheme (do not auto-reject missing header so mock mode can inject dev user)
@@ -172,7 +143,7 @@ def _resolve_api_key_context(token: Optional[str], db: Session) -> Optional[User
def _require_api_key_context(
- token: Optional[str], db: Session, request: Optional[Request] = None
+ db: Session, token: Optional[str], request: Optional[Request] = None
) -> UserContext:
"""Resolve an API-Key; any failure is a 401. A Bearer that looks like an API-Key
must be valid — never silently downgrade to anonymous. Shared by get_current_user
@@ -248,10 +219,23 @@ async def _resolve_session_user(request: Request) -> Optional[UserContext]:
)
+async def _resolve_host_user(request: Request) -> Optional[UserContext]:
+ """Identity injected by the host (desktop shell), when it claims this request.
+
+ Most requests are not claimed — no resolver is registered, or the request
+ carries no host credential — and pay no thread hop for asking.
+ """
+ from core.harness.registry.authentication import host_user_resolver
+
+ resolver = host_user_resolver(request)
+ if resolver is None:
+ return None
+ return await run_in_session(lambda db: resolver(request, db))
+
+
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
- db: Session = Depends(get_db),
) -> UserContext:
"""Dependency to get current authenticated user.
@@ -262,11 +246,16 @@ async def get_current_user(
AUTH_MODE=session still accepts explicit API Keys; other Bearer tokens
require a session cookie.
+
+ It holds no database session: each lookup runs to completion on a session
+ of its own in a worker thread, so nothing stays checked out while the
+ request (or a stream it starts) continues. Websockets and re-validation
+ call it directly with the request and credentials they have.
"""
if request.headers.get("x-public-chat") is not None:
from core.auth.public_chat import authenticate_public_chat
- return authenticate_public_chat(request, db)
+ return await run_in_session(lambda db: authenticate_public_chat(request, db))
auth_mode = _auth_mode()
# An explicit API credential must not be widened by an accompanying cookie
@@ -277,14 +266,11 @@ async def get_current_user(
if bearer_token is None and credentials is not None:
bearer_token = credentials.credentials.strip()
if is_api_key_token(bearer_token):
- return _require_api_key_context(bearer_token, db, request)
+ return await run_in_session(_require_api_key_context, bearer_token, request)
# ── 0. 桌面桥接(仅桌面壳孵化的本机后端启用;混合架构 P2)──
# 桌面壳注入的云端身份优先于本机自身的会话体系;未启用/未命中时零开销回落。
- from core.harness.registry.authentication import resolve_host_user as resolve_bridge_user
- from starlette.concurrency import run_in_threadpool
-
- bridge_user = await run_in_threadpool(resolve_bridge_user, request, db)
+ bridge_user = await _resolve_host_user(request)
if bridge_user is not None:
return bridge_user
@@ -306,8 +292,6 @@ async def get_current_user(
)
# ── 3. mock / remote: Bearer token path ──
- current_auth = AuthService()
-
if credentials is None:
if auth_mode != "mock":
raise HTTPException(
@@ -318,39 +302,10 @@ async def get_current_user(
"data": {"login_url": _sso_login_url()},
},
)
- token = "mock_token"
+ token = MOCK_TOKEN
else:
token = credentials.credentials
-
- user_info = current_auth.verify_token(token, db)
-
- user_service = UserService(db)
- user_shadow = user_service.get_or_create_user_shadow(
- user_center_id=user_info["user_center_id"],
- username=user_info["username"],
- email=user_info.get("email"),
- avatar_url=user_info.get("avatar_url"),
- )
-
- # Audit log for successful authentication
- audit_repo = AuditLogRepository(db)
- audit_repo.create(
- {
- "user_id": user_shadow.user_id,
- "action": "auth.login.success",
- "resource_type": "user",
- "resource_id": user_shadow.user_id,
- "status": "success",
- }
- )
-
- return UserContext(
- user_id=user_shadow.user_id,
- user_center_id=user_shadow.user_center_id,
- username=user_shadow.username,
- email=user_shadow.email,
- avatar_url=user_shadow.avatar_url,
- )
+ return await _resolve_bearer_user(token, audit=True)
def require_auth(required: bool = True):
@@ -369,26 +324,22 @@ def private_endpoint(user: UserContext = Depends(require_auth(True))):
return get_current_user
else:
- async def optional_user(
- request: Request, db: Session = Depends(get_db)
- ) -> Optional[UserContext]:
+ async def optional_user(request: Request) -> Optional[UserContext]:
if request.headers.get("x-public-chat") is not None:
from core.auth.public_chat import authenticate_public_chat
- return authenticate_public_chat(request, db)
+
+ return await run_in_session(lambda db: authenticate_public_chat(request, db))
auth_mode = _auth_mode()
from core.auth.api_keys import api_key_token_from_header
auth_header = request.headers.get("Authorization", "")
raw_bearer = api_key_token_from_header(auth_header)
if raw_bearer is not None:
- return _require_api_key_context(raw_bearer, db, request)
+ return await run_in_session(_require_api_key_context, raw_bearer, request)
# Match required authentication: the desktop shell owns the local
# identity, even when a forwarded cloud cookie is absent or stale.
- from core.harness.registry.authentication import resolve_host_user as resolve_bridge_user
- from starlette.concurrency import run_in_threadpool
-
- bridge_user = await run_in_threadpool(resolve_bridge_user, request, db)
+ bridge_user = await _resolve_host_user(request)
if bridge_user is not None:
return bridge_user
@@ -406,44 +357,18 @@ async def optional_user(
return None
# ── mock / remote: try Bearer ──
- current_auth = AuthService()
if not auth_header or not auth_header.startswith("Bearer "):
if auth_mode != "mock":
return None
- token = "mock_token"
+ token = MOCK_TOKEN
else:
token = auth_header.split(" ", 1)[1]
try:
- user_info = current_auth.verify_token(token)
- user_service = UserService(db)
- user_shadow = user_service.get_or_create_user_shadow(
- user_center_id=user_info["user_center_id"],
- username=user_info["username"],
- email=user_info.get("email"),
- avatar_url=user_info.get("avatar_url"),
- )
-
- return UserContext(
- user_id=user_shadow.user_id,
- user_center_id=user_shadow.user_center_id,
- username=user_shadow.username,
- email=user_shadow.email,
- avatar_url=user_shadow.avatar_url,
- )
+ return await _resolve_bearer_user(token, audit=False)
except AccountCapacityExceeded:
# Edition admission blocks must not degrade to anonymous access.
- try:
- db.rollback()
- except Exception as fallback_error:
- from core.infra.failures import record_best_effort_failure
- record_best_effort_failure('core.auth.backend:optional_user', fallback_error)
raise
except Exception as e:
- try:
- db.rollback()
- except Exception as fallback_error:
- from core.infra.failures import record_best_effort_failure
- record_best_effort_failure('core.auth.backend:optional_user', fallback_error)
logger.warning("optional_auth_resolve_failed", error=str(e))
return None
diff --git a/src/backend/core/auth/bearer.py b/src/backend/core/auth/bearer.py
new file mode 100644
index 00000000..f8b275ee
--- /dev/null
+++ b/src/backend/core/auth/bearer.py
@@ -0,0 +1,149 @@
+"""Bearer identities for AUTH_MODE=mock / remote.
+
+Resolving a Bearer token is two steps with different costs. Verifying it is a
+round trip to the user center (remote) or a pure function of the token (mock);
+binding the identity to a ``users_shadow`` row is local database work. Both used
+to run synchronously on the event loop for every request, so one slow user
+center stalled every stream the worker was serving.
+
+Verification is now asynchronous, and a verified token is remembered for
+``VERIFIED_TOKEN_TTL_SECONDS`` in the shared TTL keyspace, so every worker
+reuses it and the user center sees one request per token per window. That
+window is also how long a token revoked at the user center keeps working here.
+Rejections are never remembered: a token that failed may be valid a moment later.
+The cache is an optimization; when the store is unavailable the token is simply
+verified again.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import os
+import ssl
+import weakref
+from typing import Any, Optional
+
+import httpx
+
+from core.config.settings import settings
+from core.infra.ephemeral import get_ephemeral_state
+from core.infra.failures import record_best_effort_failure
+from fastapi import HTTPException
+
+VERIFIED_TOKEN_TTL_SECONDS = 60
+MOCK_TOKEN = "mock_token"
+_CACHE_PREFIX = "auth:bearer:"
+# The fields the backend reads. Only these are cached, so whatever else the user
+# center returns never lands in the shared store.
+_IDENTITY_FIELDS = ("user_center_id", "username", "email", "avatar_url")
+# Per event loop, the verification already in flight for each token: a burst of
+# requests carrying one uncached token shares a single call to the user center.
+_in_flight: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Task]]" = (
+ weakref.WeakKeyDictionary()
+)
+
+
+def mock_identity(token: str) -> dict[str, Any]:
+ """Mock authentication for development: the token names the user."""
+ if token and token != MOCK_TOKEN:
+ return {
+ "user_center_id": token,
+ "username": token,
+ "email": f"{token}@mock.local",
+ "avatar_url": None,
+ }
+ return {
+ "user_center_id": settings.auth.mock_user_id,
+ "username": settings.auth.mock_username,
+ "email": "dev@example.com",
+ "avatar_url": None,
+ }
+
+
+def _identity(payload: Any) -> Optional[dict[str, Any]]:
+ """The identity in a user center reply, or None when the reply names nobody."""
+ if not isinstance(payload, dict) or not payload.get("user_center_id") or "username" not in payload:
+ return None
+ return {name: payload.get(name) for name in _IDENTITY_FIELDS}
+
+
+def _tls_verification() -> ssl.SSLContext | bool:
+ """Trust the CA bundle operators point ``requests`` at, which httpx does not read."""
+ bundle = os.getenv("REQUESTS_CA_BUNDLE") or os.getenv("CURL_CA_BUNDLE")
+ return ssl.create_default_context(cafile=bundle) if bundle else True
+
+
+async def _ask_user_center(token: str) -> Optional[dict[str, Any]]:
+ """The user center's verdict: the identity it vouches for, or None when it rejects the token."""
+ url = settings.auth.api_url
+ if not url:
+ raise HTTPException(
+ status_code=500,
+ detail={"code": 52002, "message": "AUTH_API_URL not configured", "data": {}},
+ )
+ attempts = settings.auth.retry_count
+ async with httpx.AsyncClient(
+ timeout=settings.auth.api_timeout, follow_redirects=True, verify=_tls_verification()
+ ) as client:
+ for attempt in range(attempts):
+ try:
+ response = await client.get(
+ f"{url}/verify", headers={"Authorization": f"Bearer {token}"}
+ )
+ except httpx.HTTPError as error:
+ if attempt == attempts - 1:
+ raise HTTPException(
+ status_code=502,
+ detail={
+ "code": 52001,
+ "message": "User center unavailable",
+ "data": {"error": str(error)},
+ },
+ ) from None
+ continue
+ if response.status_code == 200:
+ return _identity(response.json())
+ if response.status_code == 401:
+ return None
+ return None
+
+
+async def verify_bearer(token: str) -> tuple[Optional[dict[str, Any]], bool]:
+ """Verify a Bearer token under the configured mode.
+
+ Returns ``(identity, checked)``: ``identity`` is None when the token is
+ rejected, and ``checked`` is True only for the one call that asked the user
+ center, which is what a login audit records.
+ """
+ if settings.auth.mode == "mock":
+ return mock_identity(token), False
+ key = _CACHE_PREFIX + hashlib.sha256(token.encode()).hexdigest()
+ state = get_ephemeral_state()
+ try:
+ cached = await state.get(key)
+ except Exception as error: # noqa: BLE001 - a cache outage must not fail authentication
+ record_best_effort_failure("core.auth.bearer:verify_bearer.get", error)
+ cached = None
+ if cached is not None:
+ return json.loads(cached), False
+ pending = _in_flight.setdefault(asyncio.get_running_loop(), {})
+ task = pending.get(key)
+ if task is not None:
+ # Shielded: a waiter whose client disconnects must not cancel the others' answer.
+ return await asyncio.shield(task), False
+ task = asyncio.ensure_future(_verify_and_remember(token, key, state))
+ pending[key] = task
+ task.add_done_callback(lambda _task: pending.pop(key, None))
+ return await asyncio.shield(task), True
+
+
+async def _verify_and_remember(token: str, key: str, state) -> Optional[dict[str, Any]]:
+ identity = await _ask_user_center(token)
+ if identity is not None:
+ try:
+ await state.put(key, json.dumps(identity), ttl=VERIFIED_TOKEN_TTL_SECONDS)
+ except Exception as error: # noqa: BLE001 - the next request verifies again
+ record_best_effort_failure("core.auth.bearer:verify_bearer.put", error)
+ return identity
diff --git a/src/backend/core/auth/local_user_bootstrap.py b/src/backend/core/auth/local_user_bootstrap.py
index 1dd0f0eb..467893a0 100644
--- a/src/backend/core/auth/local_user_bootstrap.py
+++ b/src/backend/core/auth/local_user_bootstrap.py
@@ -1,4 +1,4 @@
-"""local user bootstrap responsibilities."""
+"""Create the default CE administrator on a fresh instance."""
from __future__ import annotations
from core.infra.time import utc_now
from typing import Optional
@@ -6,7 +6,7 @@
from core.auth.password import hash_password, verify_password
from core.config.settings import settings
from core.db.models import LocalUser, UserShadow
-from core.auth import local_users_state
+from core.auth import local_users_constants
def ensure_ce_default_admin(db: Session) -> tuple[Optional[str], bool]:
"""Ensure a fresh CE instance has an ``admin/admin`` local administrator.
@@ -18,7 +18,7 @@ def ensure_ce_default_admin(db: Session) -> tuple[Optional[str], bool]:
if settings.edition.edition != "ce":
return None, False
- shadow = db.query(UserShadow).filter(UserShadow.username == local_users_state.CE_DEFAULT_ADMIN_USERNAME).first()
+ shadow = db.query(UserShadow).filter(UserShadow.username == local_users_constants.CE_DEFAULT_ADMIN_USERNAME).first()
created = False
if shadow is None:
@@ -28,12 +28,12 @@ def ensure_ce_default_admin(db: Session) -> tuple[Optional[str], bool]:
shadow = UserShadow(
user_id=user_id,
user_center_id=user_id,
- username=local_users_state.CE_DEFAULT_ADMIN_USERNAME,
+ username=local_users_constants.CE_DEFAULT_ADMIN_USERNAME,
email=None,
avatar_url=None,
extra_data={
"auth_source": "local",
- **local_users_state.CE_DEFAULT_ADMIN_CAPABILITIES,
+ **local_users_constants.CE_DEFAULT_ADMIN_CAPABILITIES,
"must_change_password": True,
"onboarding_required": True,
},
@@ -43,7 +43,7 @@ def ensure_ce_default_admin(db: Session) -> tuple[Optional[str], bool]:
db.flush()
local = LocalUser(
user_id=user_id,
- password_hash=hash_password(local_users_state.CE_DEFAULT_ADMIN_PASSWORD),
+ password_hash=hash_password(local_users_constants.CE_DEFAULT_ADMIN_PASSWORD),
nickname="Administrator",
status="active",
password_updated_at=None,
@@ -55,7 +55,7 @@ def ensure_ce_default_admin(db: Session) -> tuple[Optional[str], bool]:
if local is None:
local = LocalUser(
user_id=shadow.user_id,
- password_hash=hash_password(local_users_state.CE_DEFAULT_ADMIN_PASSWORD),
+ password_hash=hash_password(local_users_constants.CE_DEFAULT_ADMIN_PASSWORD),
nickname="Administrator",
status="active",
password_updated_at=None,
@@ -64,17 +64,17 @@ def ensure_ce_default_admin(db: Session) -> tuple[Optional[str], bool]:
created = True
meta = dict(shadow.extra_data or {})
- meta.update(local_users_state.CE_DEFAULT_ADMIN_CAPABILITIES)
+ meta.update(local_users_constants.CE_DEFAULT_ADMIN_CAPABILITIES)
meta.setdefault("auth_source", "local")
try:
completed_version = int(meta.get("onboarding_completed_version") or 0)
except (TypeError, ValueError):
completed_version = 0
- if completed_version < local_users_state.CE_ONBOARDING_VERSION:
+ if completed_version < local_users_constants.CE_ONBOARDING_VERSION:
meta["onboarding_required"] = True
else:
meta.pop("onboarding_required", None)
- if verify_password(local_users_state.CE_DEFAULT_ADMIN_PASSWORD, local.password_hash):
+ if verify_password(local_users_constants.CE_DEFAULT_ADMIN_PASSWORD, local.password_hash):
meta["must_change_password"] = True
shadow.extra_data = meta
shadow.updated_at = utc_now()
diff --git a/src/backend/core/auth/local_user_policy.py b/src/backend/core/auth/local_user_policy.py
index 10de5f58..8b5f9aa4 100644
--- a/src/backend/core/auth/local_user_policy.py
+++ b/src/backend/core/auth/local_user_policy.py
@@ -1,8 +1,8 @@
-"""local user policy responsibilities."""
+"""Whether the CE bootstrap owner still has to finish web onboarding."""
from __future__ import annotations
from typing import Any, Dict, Optional
from core.config.settings import settings
-from core.auth import local_users_state
+from core.auth import local_users_constants
def ce_onboarding_required(metadata: Optional[Dict[str, Any]]) -> bool:
"""Return whether the CE bootstrap owner must finish the current Web setup flow."""
@@ -13,4 +13,4 @@ def ce_onboarding_required(metadata: Optional[Dict[str, Any]]) -> bool:
completed_version = int(meta.get("onboarding_completed_version") or 0)
except (TypeError, ValueError):
completed_version = 0
- return bool(meta.get("onboarding_required")) and completed_version < local_users_state.CE_ONBOARDING_VERSION
+ return bool(meta.get("onboarding_required")) and completed_version < local_users_constants.CE_ONBOARDING_VERSION
diff --git a/src/backend/core/auth/local_user_types.py b/src/backend/core/auth/local_user_types.py
deleted file mode 100644
index db2c34cb..00000000
--- a/src/backend/core/auth/local_user_types.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""local user types responsibilities."""
-from __future__ import annotations
-from dataclasses import dataclass
-from typing import Any, Dict, Optional
-
-@dataclass
-class RegisterResult:
- ok: bool
- message: str
- user_id: Optional[str] = None
- user_info: Optional[Dict[str, Any]] = None
-
-
-@dataclass
-class LoginResult:
- ok: bool
- message: str
- user_id: Optional[str] = None
- user_info: Optional[Dict[str, Any]] = None
diff --git a/src/backend/core/auth/local_users.py b/src/backend/core/auth/local_users.py
index 161c713d..f6ed43c4 100644
--- a/src/backend/core/auth/local_users.py
+++ b/src/backend/core/auth/local_users.py
@@ -1,4 +1,4 @@
-"""local users responsibilities."""
+"""Local account registration, login, password changes and profile edits."""
from __future__ import annotations
from core.infra.time import utc_now
import secrets
@@ -12,8 +12,25 @@
from core.db.models import LocalUser, UserShadow
from core.db.repository import AuditLogRepository, LocalUserRepository, UserRepository
from core.auth import local_user_policy
-from core.auth import local_user_types
-from core.auth import local_users_state
+from core.auth import local_users_constants
+from dataclasses import dataclass
+
+
+@dataclass
+class RegisterResult:
+ ok: bool
+ message: str
+ user_id: Optional[str] = None
+ user_info: Optional[Dict[str, Any]] = None
+
+
+@dataclass
+class LoginResult:
+ ok: bool
+ message: str
+ user_id: Optional[str] = None
+ user_info: Optional[Dict[str, Any]] = None
+
class LocalUserService:
"""Encapsulates local account registration, login, and disabling logic. Transactions are completed within this service."""
@@ -34,7 +51,7 @@ def register(
email: Optional[str] = None,
real_name: Optional[str] = None,
phone: Optional[str] = None,
- ) -> local_user_types.RegisterResult:
+ ) -> RegisterResult:
username = (username or "").strip()
nickname = (nickname or "").strip()
email = (email or "").strip().lower() or None
@@ -42,37 +59,37 @@ def register(
code = (code or "").strip()
if not username:
- return local_user_types.RegisterResult(False, "账号不能为空")
- if not local_users_state.USERNAME_PATTERN.match(username):
- return local_user_types.RegisterResult(False, "账号只能包含英文、数字、下划线,长度 2-32 位")
+ return RegisterResult(False, "账号不能为空")
+ if not local_users_constants.USERNAME_PATTERN.match(username):
+ return RegisterResult(False, "账号只能包含英文、数字、下划线,长度 2-32 位")
if not nickname:
- return local_user_types.RegisterResult(False, "用户名不能为空")
+ return RegisterResult(False, "用户名不能为空")
if len(nickname) > 32:
- return local_user_types.RegisterResult(False, "用户名长度不能超过 32 位")
+ return RegisterResult(False, "用户名长度不能超过 32 位")
if not email:
- return local_user_types.RegisterResult(False, "邮箱不能为空")
- if not local_users_state.EMAIL_PATTERN.match(email):
- return local_user_types.RegisterResult(False, "邮箱格式不正确")
+ return RegisterResult(False, "邮箱不能为空")
+ if not local_users_constants.EMAIL_PATTERN.match(email):
+ return RegisterResult(False, "邮箱格式不正确")
if len(password) < settings.auth.password_min_length:
- return local_user_types.RegisterResult(False, f"密码长度至少 {settings.auth.password_min_length} 位")
+ return RegisterResult(False, f"密码长度至少 {settings.auth.password_min_length} 位")
# Account uniqueness
if self.db.query(UserShadow).filter(UserShadow.username == username).first() is not None:
- return local_user_types.RegisterResult(False, "账号已被占用")
+ return RegisterResult(False, "账号已被占用")
# Email uniqueness
if self.db.query(UserShadow).filter(UserShadow.email == email).first() is not None:
- return local_user_types.RegisterResult(False, "邮箱已被使用")
+ return RegisterResult(False, "邮箱已被使用")
from core.auth.edition import account_capacity_block_reason
seat_block = account_capacity_block_reason(self.db)
if seat_block:
- return local_user_types.RegisterResult(False, seat_block)
+ return RegisterResult(False, seat_block)
# First only validate the registration code (no state change); consume it after the user is successfully created
ok, reason, invite = validate_registration_credential(self.db, code)
if not ok:
- return local_user_types.RegisterResult(False, reason or "注册码无效")
+ return RegisterResult(False, reason or "注册码无效")
user_id = f"user_{uuid.uuid4().hex[:16]}"
try:
@@ -109,7 +126,7 @@ def register(
claimed_ok, claim_reason = claim_registration_credential(self.db, code, user_id)
if not claimed_ok:
self.db.rollback()
- return local_user_types.RegisterResult(False, claim_reason or "注册码已被使用")
+ return RegisterResult(False, claim_reason or "注册码已被使用")
# Audit
self.audit_repo.create(
@@ -126,9 +143,9 @@ def register(
self.db.commit()
except Exception as exc: # noqa: BLE001
self.db.rollback()
- return local_user_types.RegisterResult(False, f"注册失败:{exc}")
+ return RegisterResult(False, f"注册失败:{exc}")
- return local_user_types.RegisterResult(
+ return RegisterResult(
ok=True,
message="注册成功",
user_id=user_id,
@@ -149,7 +166,7 @@ def create_by_admin(
scope_id: Optional[str] = None,
scope_role: str = "member",
actor: Optional[str] = None,
- ) -> local_user_types.RegisterResult:
+ ) -> RegisterResult:
"""Create a local account directly from the Config console.
Differences from :meth:`register`: no registration code required, email optional,
@@ -163,36 +180,36 @@ def create_by_admin(
scope_role = (scope_role or "member").strip() or "member"
if not username:
- return local_user_types.RegisterResult(False, "账号不能为空")
- if not local_users_state.USERNAME_PATTERN.match(username):
- return local_user_types.RegisterResult(False, "账号只能包含英文、数字、下划线,长度 2-32 位")
+ return RegisterResult(False, "账号不能为空")
+ if not local_users_constants.USERNAME_PATTERN.match(username):
+ return RegisterResult(False, "账号只能包含英文、数字、下划线,长度 2-32 位")
if len(nickname) > 32:
- return local_user_types.RegisterResult(False, "用户名长度不能超过 32 位")
- if email and not local_users_state.EMAIL_PATTERN.match(email):
- return local_user_types.RegisterResult(False, "邮箱格式不正确")
+ return RegisterResult(False, "用户名长度不能超过 32 位")
+ if email and not local_users_constants.EMAIL_PATTERN.match(email):
+ return RegisterResult(False, "邮箱格式不正确")
if len(password) < settings.auth.password_min_length:
- return local_user_types.RegisterResult(False, f"密码长度至少 {settings.auth.password_min_length} 位")
+ return RegisterResult(False, f"密码长度至少 {settings.auth.password_min_length} 位")
if status not in ("active", "disabled", "pending"):
- return local_user_types.RegisterResult(False, "状态不合法")
+ return RegisterResult(False, "状态不合法")
# Account uniqueness
if self.db.query(UserShadow).filter(UserShadow.username == username).first() is not None:
- return local_user_types.RegisterResult(False, "账号已被占用")
+ return RegisterResult(False, "账号已被占用")
# Email uniqueness (only when an email is provided)
if (
email
and self.db.query(UserShadow).filter(UserShadow.email == email).first() is not None
):
- return local_user_types.RegisterResult(False, "邮箱已被使用")
+ return RegisterResult(False, "邮箱已被使用")
if not validate_account_scope(self.db, scope_id):
- return local_user_types.RegisterResult(False, "目标范围不存在")
+ return RegisterResult(False, "目标范围不存在")
from core.auth.edition import account_capacity_block_reason
seat_block = account_capacity_block_reason(self.db)
if seat_block:
- return local_user_types.RegisterResult(False, seat_block)
+ return RegisterResult(False, seat_block)
user_id = f"user_{uuid.uuid4().hex[:16]}"
try:
@@ -241,9 +258,9 @@ def create_by_admin(
self.db.commit()
except Exception as exc: # noqa: BLE001
self.db.rollback()
- return local_user_types.RegisterResult(False, f"创建失败:{exc}")
+ return RegisterResult(False, f"创建失败:{exc}")
- return local_user_types.RegisterResult(
+ return RegisterResult(
ok=True,
message="创建成功",
user_id=user_id,
@@ -341,11 +358,11 @@ def get_or_create_external_account(
return shadow, True
# ── Login ──────────────────────────────────────────────────
- def authenticate(self, identifier: str, password: str) -> local_user_types.LoginResult:
+ def authenticate(self, identifier: str, password: str) -> LoginResult:
"""Log in. identifier can be an account name (username) or an email."""
identifier = (identifier or "").strip()
if not identifier or not password:
- return local_user_types.LoginResult(False, "账号或密码为空")
+ return LoginResult(False, "账号或密码为空")
# An identifier with @ takes the email branch; otherwise the account branch (falling back to email if the account branch misses)
row = None
@@ -357,18 +374,18 @@ def authenticate(self, identifier: str, password: str) -> local_user_types.Login
row = self.local_repo.get_by_email(identifier.lower())
if not row:
- return local_user_types.LoginResult(False, "账号或密码错误")
+ return LoginResult(False, "账号或密码错误")
local, shadow = row
if local.status == "disabled":
- return local_user_types.LoginResult(False, "账号已被禁用")
+ return LoginResult(False, "账号已被禁用")
if local.status == "pending":
- return local_user_types.LoginResult(False, "账号待审核")
+ return LoginResult(False, "账号待审核")
if not verify_password(password, local.password_hash):
- return local_user_types.LoginResult(False, "账号或密码错误")
+ return LoginResult(False, "账号或密码错误")
- return local_user_types.LoginResult(
+ return LoginResult(
ok=True,
message="登录成功",
user_id=shadow.user_id,
@@ -378,16 +395,16 @@ def authenticate(self, identifier: str, password: str) -> local_user_types.Login
)
# ── Password change ────────────────────────────────────────
- def change_password(self, user_id: str, old_password: str, new_password: str) -> local_user_types.LoginResult:
+ def change_password(self, user_id: str, old_password: str, new_password: str) -> LoginResult:
local = self.local_repo.get(user_id)
if not local:
- return local_user_types.LoginResult(False, "账号不存在")
+ return LoginResult(False, "账号不存在")
if not verify_password(old_password, local.password_hash):
- return local_user_types.LoginResult(False, "原密码错误")
+ return LoginResult(False, "原密码错误")
if len(new_password or "") < settings.auth.password_min_length:
- return local_user_types.LoginResult(False, f"新密码长度至少 {settings.auth.password_min_length} 位")
+ return LoginResult(False, f"新密码长度至少 {settings.auth.password_min_length} 位")
if verify_password(new_password, local.password_hash):
- return local_user_types.LoginResult(False, "新密码不能与原密码相同")
+ return LoginResult(False, "新密码不能与原密码相同")
local.password_hash = hash_password(new_password)
local.password_updated_at = utc_now()
@@ -399,7 +416,7 @@ def change_password(self, user_id: str, old_password: str, new_password: str) ->
shadow.extra_data = meta
shadow.updated_at = utc_now()
self.db.commit()
- return local_user_types.LoginResult(True, "密码修改成功", user_id=user_id)
+ return LoginResult(True, "密码修改成功", user_id=user_id)
def _build_user_info(
self,
@@ -433,18 +450,18 @@ def update_profile(
nickname: Optional[str] = None,
real_name: Optional[str] = None,
phone: Optional[str] = None,
- ) -> local_user_types.LoginResult:
+ ) -> LoginResult:
"""Update the editable profile fields of a local account. A None field means leave unchanged."""
local = self.local_repo.get(user_id)
if not local:
- return local_user_types.LoginResult(False, "账号不存在")
+ return LoginResult(False, "账号不存在")
if nickname is not None:
nickname = nickname.strip()
if not nickname:
- return local_user_types.LoginResult(False, "用户名不能为空")
+ return LoginResult(False, "用户名不能为空")
if len(nickname) > 32:
- return local_user_types.LoginResult(False, "用户名长度不能超过 32 位")
+ return LoginResult(False, "用户名长度不能超过 32 位")
local.nickname = nickname
if real_name is not None:
rn = real_name.strip()
@@ -458,7 +475,7 @@ def update_profile(
self.db.refresh(local)
shadow = self.user_repo.get_by_id(user_id)
- return local_user_types.LoginResult(
+ return LoginResult(
ok=True,
message="已更新",
user_id=user_id,
diff --git a/src/backend/core/auth/local_users_state.py b/src/backend/core/auth/local_users_constants.py
similarity index 92%
rename from src/backend/core/auth/local_users_state.py
rename to src/backend/core/auth/local_users_constants.py
index 4a9b0642..20170f71 100644
--- a/src/backend/core/auth/local_users_state.py
+++ b/src/backend/core/auth/local_users_constants.py
@@ -1,4 +1,4 @@
-"""local users state responsibilities."""
+"""Validation patterns and CE bootstrap constants for local accounts."""
from __future__ import annotations
import re
from typing import Any, Dict
diff --git a/src/backend/core/auth/mock_ticket_store.py b/src/backend/core/auth/mock_ticket_store.py
index c65dc453..823cc784 100644
--- a/src/backend/core/auth/mock_ticket_store.py
+++ b/src/backend/core/auth/mock_ticket_store.py
@@ -22,6 +22,7 @@
import hashlib
import json
import secrets
+from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
@@ -35,8 +36,6 @@
TICKET_PREFIX = "mock_ticket_"
-
-
def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
@@ -110,17 +109,13 @@ async def consume_ticket(ticket: str) -> Optional[Dict[str, Any]]:
return None
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class MockTicketStoreState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- MEMORY_TICKETS: Dict[str, Dict[str, Any]] = state_field(default_factory=lambda: {})
+ MEMORY_TICKETS: Dict[str, Dict[str, Any]] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> MockTicketStoreState:
- return MockTicketStoreState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = MockTicketStoreState()
+
+
+def process_state() -> MockTicketStoreState:
+ return _STATE
diff --git a/src/backend/core/auth/oa_ticket_store.py b/src/backend/core/auth/oa_ticket_store.py
index 35b2d6ce..764b0107 100644
--- a/src/backend/core/auth/oa_ticket_store.py
+++ b/src/backend/core/auth/oa_ticket_store.py
@@ -19,6 +19,7 @@
import hashlib
import json
import secrets
+from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
@@ -30,7 +31,6 @@
TICKET_KEY_PREFIX = "jx:oa_ticket:"
-
def _ttl_seconds() -> int:
return int(settings.oa_sso.ticket_ttl_seconds)
@@ -109,17 +109,13 @@ async def consume_ticket(token: str) -> Optional[Dict[str, Any]]:
return None
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class OaTicketStoreState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- MEMORY_TICKETS: dict[str, dict[str, Any]] = state_field(default_factory=lambda: {})
+ MEMORY_TICKETS: dict[str, dict[str, Any]] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> OaTicketStoreState:
- return OaTicketStoreState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = OaTicketStoreState()
+
+
+def process_state() -> OaTicketStoreState:
+ return _STATE
diff --git a/src/backend/core/auth/personal_permissions_iface.py b/src/backend/core/auth/personal_permissions_iface.py
index 6e03d405..ba5f27d0 100644
--- a/src/backend/core/auth/personal_permissions_iface.py
+++ b/src/backend/core/auth/personal_permissions_iface.py
@@ -77,7 +77,7 @@ def require_project_access(
from core.auth.backend import UserContext, get_current_user # 避免循环导入
- async def _dep(
+ def _dep(
project_id: str = Path(..., description="项目 ID"),
request: Request = None,
user: "UserContext" = Depends(get_current_user),
diff --git a/src/backend/core/auth/session.py b/src/backend/core/auth/session.py
index 7ab0beb7..2df60e4a 100644
--- a/src/backend/core/auth/session.py
+++ b/src/backend/core/auth/session.py
@@ -9,6 +9,7 @@
import hashlib
import json
import secrets
+from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
@@ -21,7 +22,6 @@
SESSION_KEY_PREFIX = "jx:session:"
-
def _ttl_seconds() -> int:
return int(settings.session.ttl_hours * 3600)
@@ -207,11 +207,8 @@ async def revoke_session(token: str) -> bool:
logger.info("session_revoked", token_hash=token_hash[:8])
if data and data.get("user_id"):
from core.harness.registry.resource_owners import resource_owner
- from core.db.engine import SessionLocal
-
try:
- with SessionLocal() as db:
- await resource_owner().revoke_session(db, data["user_id"], token_hash)
+ await resource_owner().revoke_session(data["user_id"], token_hash)
except Exception as exc:
# The credential is already revoked. Preserve successful logout;
# expired workers stay denied while the resource janitor retries.
@@ -247,17 +244,13 @@ def expires_at_iso(ttl_seconds: Optional[int] = None) -> str:
return (datetime.now(timezone.utc) + timedelta(seconds=ttl)).isoformat()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class SessionState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- MEMORY_SESSIONS: dict[str, dict[str, Any]] = state_field(default_factory=lambda: {})
+ MEMORY_SESSIONS: dict[str, dict[str, Any]] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> SessionState:
- return SessionState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = SessionState()
+
+
+def process_state() -> SessionState:
+ return _STATE
diff --git a/src/backend/core/auth/users.py b/src/backend/core/auth/users.py
index 2746444e..2800e655 100644
--- a/src/backend/core/auth/users.py
+++ b/src/backend/core/auth/users.py
@@ -100,8 +100,7 @@ def get_user_settings(self, user_id: str) -> Dict[str, Any]:
return {}
metadata = dict(user.extra_data) if user.extra_data else {}
- # Imported lazily to keep the services package's compatibility exports
- # from creating an import cycle during application startup.
+ # Imported lazily to avoid an import cycle during application startup.
from core.memory.settings import MemorySettingsService
return MemorySettingsService(self.db).apply_effective_defaults(metadata)
diff --git a/src/backend/core/capabilities/__init__.py b/src/backend/core/capabilities/__init__.py
index 6c571b50..37c1c8cb 100644
--- a/src/backend/core/capabilities/__init__.py
+++ b/src/backend/core/capabilities/__init__.py
@@ -1 +1 @@
-"""capabilities domain package."""
+"""Capabilities an agent can use: skills, plugins, MCP servers, sub-agents and their catalog."""
diff --git a/src/backend/core/capabilities/agents/__init__.py b/src/backend/core/capabilities/agents/__init__.py
index 45274be0..d47444f5 100644
--- a/src/backend/core/capabilities/agents/__init__.py
+++ b/src/backend/core/capabilities/agents/__init__.py
@@ -1 +1 @@
-"""agents domain package."""
+"""User-defined agents: storage, versioning and the resources they may bind."""
diff --git a/src/backend/core/capabilities/agents/account_store.py b/src/backend/core/capabilities/agents/account_store.py
deleted file mode 100644
index 2532cfe6..00000000
--- a/src/backend/core/capabilities/agents/account_store.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""account store responsibilities."""
-from __future__ import annotations
-
-
-def desktop_store() -> bool:
- """Whether this process keeps a desktop capability file store (agents projected there)."""
- from core.harness.registry.run_capabilities import has_run_capabilities
-
- return has_run_capabilities()
diff --git a/src/backend/core/capabilities/agents/available_resources.py b/src/backend/core/capabilities/agents/available_resources.py
index a30d3708..c71ac626 100644
--- a/src/backend/core/capabilities/agents/available_resources.py
+++ b/src/backend/core/capabilities/agents/available_resources.py
@@ -1,4 +1,4 @@
-"""available resources operations."""
+"""List the MCP servers, skills, plugins and knowledge spaces an agent may bind."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import logging
diff --git a/src/backend/core/capabilities/agents/base.py b/src/backend/core/capabilities/agents/base.py
index 14dd5a02..79da15d5 100644
--- a/src/backend/core/capabilities/agents/base.py
+++ b/src/backend/core/capabilities/agents/base.py
@@ -1,4 +1,4 @@
-"""base responsibilities."""
+"""CRUD, versioning and permission checks for user-defined agents."""
from __future__ import annotations
from core.capabilities.agents import available_resources
from core.capabilities.agents import version_history
@@ -9,9 +9,17 @@
from core.harness.registry.build_governance import ensure_ontology_build_valid
from sqlalchemy.orm import Session
import logging
+from core.capabilities.agents import base_constants
+
logger = logging.getLogger(__name__)
-from core.capabilities.agents import account_store as account_store_module
-from core.capabilities.agents import base_state as base_state_module
+
+
+def desktop_store() -> bool:
+ """Whether this process keeps a desktop capability file store (agents projected there)."""
+ from core.harness.registry.run_capabilities import has_run_capabilities
+
+ return has_run_capabilities()
+
class UserAgentBaseService:
"""Service for user agent CRUD and permission checks."""
@@ -25,7 +33,7 @@ def __init__(self, db: Session):
def list_for_user(self, user_id: str) -> List[Dict[str, Any]]:
agents = self.repo.list_for_user(user_id)
rows = [self._serialize(a) for a in agents]
- if account_store_module.desktop_store():
+ if desktop_store():
# Desktop: the bridged account's stored agents join the device's own;
# a display-name clash is decided by the resolver, never by order.
from core.harness.registry.local_publication import local_publication
@@ -67,7 +75,7 @@ def get_raw_by_id(self, agent_id: str, user_id: Optional[str] = None) -> UserAge
@staticmethod
def _stored_account_agent(agent_id: str, user_id: Optional[str] = None):
- if not account_store_module.desktop_store():
+ if not desktop_store():
return None
from core.harness.registry.local_publication import local_publication
from core.harness.registry.execution_account import account_authorized_for
@@ -77,7 +85,7 @@ def _stored_account_agent(agent_id: str, user_id: Optional[str] = None):
return local_publication().account_agent_definition(agent_id)
def _project_to_store(self, agent: UserAgent) -> None:
- if not account_store_module.desktop_store():
+ if not desktop_store():
return
from core.harness.registry.local_publication import local_publication
@@ -85,7 +93,7 @@ def _project_to_store(self, agent: UserAgent) -> None:
@staticmethod
def _remove_from_store(agent_id: str) -> None:
- if not account_store_module.desktop_store():
+ if not desktop_store():
return
from core.harness.registry.local_publication import local_publication
@@ -125,7 +133,7 @@ def create(
created_at = self._now_iso()
creation_history = [
{
- "version": base_state_module.DEFAULT_AGENT_VERSION,
+ "version": base_constants.DEFAULT_AGENT_VERSION,
"timestamp": created_at,
"content": "创建了子智能体",
"operator_name": operator_name or user_id or "未知用户",
@@ -135,7 +143,7 @@ def create(
extra_config = self._merge_extra_config(
current_extra=None,
incoming_extra=incoming_extra,
- version=base_state_module.DEFAULT_AGENT_VERSION,
+ version=base_constants.DEFAULT_AGENT_VERSION,
change_history=creation_history,
)
@@ -198,8 +206,8 @@ def update(
)
changed_fields = self._collect_changed_fields(agent, data)
- versioned_fields = [field for field in changed_fields if field not in base_state_module.NON_VERSIONED_FIELDS]
- changed_labels = [base_state_module.VERSIONED_FIELDS[field] for field in changed_fields]
+ versioned_fields = [field for field in changed_fields if field not in base_constants.NON_VERSIONED_FIELDS]
+ changed_labels = [base_constants.VERSIONED_FIELDS[field] for field in changed_fields]
next_version = self._read_version(current_extra)
change_history = self._read_change_history(current_extra)
@@ -219,7 +227,7 @@ def update(
"details": change_details,
}
)
- change_history = change_history[-base_state_module.MAX_CHANGE_HISTORY:]
+ change_history = change_history[-base_constants.MAX_CHANGE_HISTORY:]
payload = dict(data)
payload["extra_config"] = self._merge_extra_config(
@@ -283,8 +291,8 @@ def _validate_create_scope(
if owner_type == "user":
if not user_id:
raise ValueError("user_id required for user agents")
- if self.repo.count_user_agents(user_id) >= base_state_module.MAX_USER_AGENTS:
- raise ValueError(f"Maximum {base_state_module.MAX_USER_AGENTS} agents per user reached")
+ if self.repo.count_user_agents(user_id) >= base_constants.MAX_USER_AGENTS:
+ raise ValueError(f"Maximum {base_constants.MAX_USER_AGENTS} agents per user reached")
return
if owner_type == "admin":
return
diff --git a/src/backend/core/capabilities/agents/base_state.py b/src/backend/core/capabilities/agents/base_constants.py
similarity index 91%
rename from src/backend/core/capabilities/agents/base_state.py
rename to src/backend/core/capabilities/agents/base_constants.py
index 75cb8af0..e49125f6 100644
--- a/src/backend/core/capabilities/agents/base_state.py
+++ b/src/backend/core/capabilities/agents/base_constants.py
@@ -1,4 +1,4 @@
-"""base state responsibilities."""
+"""Limits and versioned-field lists for user-defined agents."""
from __future__ import annotations
diff --git a/src/backend/core/capabilities/agents/version_history.py b/src/backend/core/capabilities/agents/version_history.py
index 239b2d5a..a9e99ecb 100644
--- a/src/backend/core/capabilities/agents/version_history.py
+++ b/src/backend/core/capabilities/agents/version_history.py
@@ -1,6 +1,6 @@
-"""version history operations."""
+"""Version numbers and change history for user-defined agents."""
from __future__ import annotations
-from core.capabilities.agents.base_state import DEFAULT_AGENT_VERSION, VERSIONED_FIELDS
+from core.capabilities.agents.base_constants import DEFAULT_AGENT_VERSION, VERSIONED_FIELDS
from core.infra.time import utc_now
import re
from decimal import Decimal
diff --git a/src/backend/core/capabilities/catalog/__init__.py b/src/backend/core/capabilities/catalog/__init__.py
index d7e2362f..0ef93965 100644
--- a/src/backend/core/capabilities/catalog/__init__.py
+++ b/src/backend/core/capabilities/catalog/__init__.py
@@ -1 +1 @@
-"""catalog domain package."""
+"""The capability catalog: what is installed, overridden and visible to each user."""
diff --git a/src/backend/core/capabilities/catalog/api.py b/src/backend/core/capabilities/catalog/api.py
index 8a2ffdbb..e425de07 100644
--- a/src/backend/core/capabilities/catalog/api.py
+++ b/src/backend/core/capabilities/catalog/api.py
@@ -27,17 +27,14 @@
from typing import Any, Dict, List
from core.capabilities.catalog.common import _item, _normalize_kind
-from core.capabilities.catalog.loader import _write_catalog, ensure_default_catalog, invalidate_catalog_cache, load_catalog
+from core.capabilities.catalog.loader import _write_catalog, ensure_default_catalog, load_catalog
-# Re-export so existing ``from core.capabilities.catalog.api import invalidate_catalog_cache``
-# and ``from core.capabilities.catalog.api import ensure_default_catalog`` keep working.
__all__ = [
"get_catalog",
"is_enabled",
"get_enabled_ids",
"set_enabled",
"reorder_items",
- "invalidate_catalog_cache",
"ensure_default_catalog",
]
diff --git a/src/backend/core/capabilities/catalog/loader.py b/src/backend/core/capabilities/catalog/loader.py
index 67ad6ae0..42a3d628 100644
--- a/src/backend/core/capabilities/catalog/loader.py
+++ b/src/backend/core/capabilities/catalog/loader.py
@@ -11,6 +11,7 @@
import copy
import json
import logging
+from dataclasses import dataclass, field
from time import monotonic
from typing import Any, Dict, List
@@ -310,18 +311,14 @@ def load_catalog(*, include_runtime_details: bool = True) -> Dict[str, Any]:
return data
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class LoaderState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- CATALOG_CACHE: Dict[bool, Dict[str, Any]] = state_field(default_factory=lambda: {})
- CATALOG_CACHE_TIME: Dict[bool, float] = state_field(default_factory=lambda: {})
+ CATALOG_CACHE: Dict[bool, Dict[str, Any]] = field(default_factory=lambda: {})
+ CATALOG_CACHE_TIME: Dict[bool, float] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> LoaderState:
- return LoaderState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = LoaderState()
+
+
+def process_state() -> LoaderState:
+ return _STATE
diff --git a/src/backend/core/capabilities/catalog/resolver.py b/src/backend/core/capabilities/catalog/resolver.py
index 6d395a01..c87de751 100644
--- a/src/backend/core/capabilities/catalog/resolver.py
+++ b/src/backend/core/capabilities/catalog/resolver.py
@@ -8,6 +8,7 @@
from __future__ import annotations
import logging
+from dataclasses import dataclass, field
from threading import Lock
from time import monotonic
from typing import Any, Dict, List, Optional, Tuple
@@ -25,7 +26,6 @@
# user_id -> (expires_at, (skills, agents, mcps))
-
# ── Context helpers (extract typed lists from a runtime context dict) ────────
@@ -331,7 +331,7 @@ def resolve_all_runtime_enabled(
# Dedup: if a privately-installed skill / MCP has already been set global by an admin (same underlying entry_name), drop the private
# copy — otherwise the same capability would be loaded twice (global id=entry, private id=entry-fingerprint), which is wasteful and also lets
# the model see two identically-named, identical-content tools/skills. Same semantics as the dedup on the /v1/catalog display side.
- from core.capabilities.skills.installation import base_entry_name as base_entry_name
+ from core.capabilities.skills.installation import base_entry_name
if owned_skills:
_global_skills = set(skills)
@@ -368,18 +368,14 @@ def invalidate_capability_cache(user_id: Optional[str] = None) -> None:
process_state().capability_cache.pop(user_id, None)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class ResolverState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- capability_cache_lock: object = state_field(default_factory=lambda: Lock())
- capability_cache: Dict[str, Tuple[float, Tuple[Optional[List[str]], Optional[List[str]], Optional[List[str]]]]] = state_field(default_factory=lambda: {})
+ capability_cache_lock: object = field(default_factory=lambda: Lock())
+ capability_cache: Dict[str, Tuple[float, Tuple[Optional[List[str]], Optional[List[str]], Optional[List[str]]]]] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> ResolverState:
- return ResolverState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = ResolverState()
+
+
+def process_state() -> ResolverState:
+ return _STATE
diff --git a/src/backend/core/capabilities/catalog/runtime.py b/src/backend/core/capabilities/catalog/runtime.py
index 92c50174..b26d32cd 100644
--- a/src/backend/core/capabilities/catalog/runtime.py
+++ b/src/backend/core/capabilities/catalog/runtime.py
@@ -10,6 +10,7 @@
import copy
import logging
+from dataclasses import dataclass, field
from threading import Lock
from time import monotonic
from typing import Any, Dict, List, Optional
@@ -35,8 +36,6 @@
_RUNTIME_DB_CACHE_TTL = 30.0
-
-
def invalidate_runtime_catalog_cache() -> None:
"""Clear cached public DB capability overlays."""
with process_state().runtime_db_cache_lock:
@@ -70,7 +69,6 @@ def merge_items_by_id(
return merged
-
def _public_db_skill_items(db: Session, *, include_runtime_details: bool) -> List[Dict[str, Any]]:
from core.db.models import AdminSkill
@@ -175,8 +173,6 @@ def _config_bool(db: Session, key: str, default: bool) -> bool:
return default
-
-
def _set_database_query_state(catalog: Dict[str, Any], enabled: bool) -> None:
for item in catalog.get("mcp") or []:
if not isinstance(item, dict) or item.get("id") != DB_UMBRELLA_ID:
@@ -292,18 +288,14 @@ def get_runtime_catalog(
return catalog
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class RuntimeState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- runtime_db_cache: Dict[bool, tuple[float, bool, List[Dict[str, Any]], List[Dict[str, Any]]]] = state_field(default_factory=lambda: {})
- runtime_db_cache_lock: object = state_field(default_factory=lambda: Lock())
+ runtime_db_cache: Dict[bool, tuple[float, bool, List[Dict[str, Any]], List[Dict[str, Any]]]] = field(default_factory=lambda: {})
+ runtime_db_cache_lock: object = field(default_factory=lambda: Lock())
-@state_cache(maxsize=1)
-def process_state() -> RuntimeState:
- return RuntimeState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = RuntimeState()
+
+
+def process_state() -> RuntimeState:
+ return _STATE
diff --git a/src/backend/core/capabilities/manifest_protocol.py b/src/backend/core/capabilities/manifest_protocol.py
index f16f3a5f..070d73c6 100644
--- a/src/backend/core/capabilities/manifest_protocol.py
+++ b/src/backend/core/capabilities/manifest_protocol.py
@@ -49,7 +49,6 @@ def canonical_hash(value: Any) -> str:
def token_claims(token: str) -> Dict[str, Any]:
"""Read routing labels only; server-side signature/session checks authorize use."""
try:
- import base64
raw = str(token).split(".")
if len(raw) != 3 or raw[0] not in ("dcap1", "dcap2"):
return {}
diff --git a/src/backend/core/capabilities/mcp/__init__.py b/src/backend/core/capabilities/mcp/__init__.py
index a2441c92..0509af9f 100644
--- a/src/backend/core/capabilities/mcp/__init__.py
+++ b/src/backend/core/capabilities/mcp/__init__.py
@@ -1 +1 @@
-"""mcp domain package."""
+"""MCP server installation, management and OAuth connections."""
diff --git a/src/backend/core/capabilities/mcp/builtin_specs.py b/src/backend/core/capabilities/mcp/builtin_specs.py
index 802cfb79..62a91d01 100644
--- a/src/backend/core/capabilities/mcp/builtin_specs.py
+++ b/src/backend/core/capabilities/mcp/builtin_specs.py
@@ -12,7 +12,6 @@
'- "各区今年 1–9 月固投同比"\n\n## 输出示例\n- 精确数值 + 数据口径标注\n'
"- 时间 / 地区 / 行业等多维度数据切片\n- 同比、环比、累计值自动计算\n"
),
- "is_stable": True,
"is_enabled": True,
"sort_order": 0,
"icon": "/home/mcp/knowledge.svg",
@@ -22,7 +21,6 @@
"display_name": "知识库检索",
"description": "从公有/私有知识库中语义检索政策文件、产业报告及用户上传文档,支持混合检索与重排序。",
"user_intro": None,
- "is_stable": False,
"is_enabled": True,
"sort_order": 1,
"icon": "/home/mcp/learning.svg",
@@ -32,7 +30,6 @@
"display_name": "互联网搜索",
"description": "检索公开网页、官方文档、新闻及财经资讯,支持多查询并发、来源去重和原文核验。",
"user_intro": None,
- "is_stable": True,
"is_enabled": True,
"sort_order": 2,
"icon": "/home/mcp/internet.svg",
@@ -42,7 +39,6 @@
"display_name": "网站信息抓取",
"description": "抓取指定网页 URL 的内容,提取正文文本或 Markdown,支持搜索引擎结果页解析。",
"user_intro": None,
- "is_stable": True,
"is_enabled": True,
"sort_order": 6,
"icon": "/home/mcp/source.svg",
diff --git a/src/backend/core/capabilities/mcp/management.py b/src/backend/core/capabilities/mcp/management.py
index 3e296d91..dfb0aaa8 100644
--- a/src/backend/core/capabilities/mcp/management.py
+++ b/src/backend/core/capabilities/mcp/management.py
@@ -3,15 +3,14 @@
from __future__ import annotations
import asyncio
-import hashlib
import ipaddress
-import json
import logging
import socket
-from typing import Any, Dict, Iterable, List
+from typing import Any, Dict, List
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
import httpx
+import httpx2
from core.db.models import AdminMcpServer
from core.infra.crypto import decrypt_secret, encrypt_secret
from core.infra.exceptions import BadRequestError
@@ -346,11 +345,17 @@ def refresh_mcp_caches() -> None:
async def probe_mcp_connectivity(
row: AdminMcpServer,
- db: Session | None = None,
+ *,
+ validate: bool = False,
oauth_provider: Any = None,
timeout_seconds: float | None = None,
) -> tuple[bool, str]:
- """Return whether an MCP server can connect and enumerate its tools."""
+ """Return whether an MCP server can connect and enumerate its tools.
+
+ ``row`` only supplies the connection config and receives ``tools_json``;
+ ``validate`` also runs the Domain Pack build check on the discovered tools,
+ on a session of its own.
+ """
from core.capabilities.mcp.service import McpServerConfigService
if timeout_seconds is None:
@@ -361,29 +366,22 @@ async def probe_mcp_connectivity(
)
cfg = McpServerConfigService.get_instance()._row_to_config(row)
+ cfg["discovery_timeout"] = timeout_seconds
if oauth_provider is not None:
cfg["oauth_provider"] = oauth_provider
async def _do_probe() -> None:
- from core.harness.registry.mcp_clients import make_client
+ from core.harness.registry.mcp_clients import discover_tools
if row.transport not in ("stdio", "streamable_http", "sse"):
raise RuntimeError(f"Unknown transport: {row.transport}")
- is_http = row.transport in ("streamable_http", "sse")
- # AgentScope's stateful HTTP MCP client owns an AnyIO cancel scope that
- # is bound to the task which opened it. Failed handshakes (notably 401)
- # can finalize the underlying async generator in another task, masking
- # the real HTTP error with "Attempted to exit cancel scope...". A
- # stateless client keeps the complete list_tools lifecycle in this task.
- client = make_client(row.server_id, cfg, is_stateful=not is_http)
+ resource = await discover_tools(row.server_id, cfg)
try:
- if not is_http:
- await client.connect()
- discovered = await client.list_tools()
- tools_meta = []
+ discovered = resource.descriptors
tools_meta = [snapshot_tool(tool) for tool in discovered or []]
row.tools_json = tools_meta
- if db is not None and tools_meta:
+ if validate and tools_meta:
+ from core.db.threads import run_in_session
from core.harness.registry.build_governance import validate_build
extra_config = row.extra_config or {}
@@ -401,7 +399,8 @@ async def _do_probe() -> None:
for tag in tags
if str(tag).strip()
)
- report = validate_build(db,
+ report = await run_in_session(
+ validate_build,
asset_type="tool",
name=row.display_name,
description=row.description or "",
@@ -413,14 +412,7 @@ async def _do_probe() -> None:
messages = "; ".join(item.message for item in report.errors)
raise RuntimeError(f"Domain Pack 构建校验失败:{messages}")
finally:
- # Stateless HTTP list_tools opens and closes its own connection.
- # Calling close() is unnecessary and can re-enter the SDK's
- # task-bound cancel scope. Stateful stdio still requires cleanup.
- if not is_http:
- try:
- await client.close()
- except BaseException as exc: # noqa: BLE001
- logger.debug("MCP probe client close failed: %s", exc)
+ await resource.close()
try:
await asyncio.wait_for(_do_probe(), timeout=timeout_seconds)
@@ -463,13 +455,13 @@ def _format_mcp_probe_exception(exc: BaseException) -> str:
if is_cancel_noise:
continue
- if isinstance(current, httpx.HTTPStatusError) and current.response is not None:
+ if isinstance(current, httpx2.HTTPStatusError) and current.response is not None:
status = current.response.status_code
reason = current.response.reason_phrase
if status in (401, 403):
return f"远端 MCP 返回 {status} {reason},请检查鉴权设置和 Token"
return f"远端 MCP 返回 HTTP {status} {reason}"
- if isinstance(current, httpx.ConnectError):
+ if isinstance(current, httpx2.ConnectError):
return "无法连接远端 MCP,请检查地址、网络和服务状态"
if message:
return f"{type(current).__name__}: {message}"
diff --git a/src/backend/core/capabilities/mcp/oauth.py b/src/backend/core/capabilities/mcp/oauth.py
index 31a02863..5fe43e84 100644
--- a/src/backend/core/capabilities/mcp/oauth.py
+++ b/src/backend/core/capabilities/mcp/oauth.py
@@ -1,4 +1,4 @@
-"""oauth responsibilities."""
+"""Browser OAuth install flow for remote MCP servers."""
from __future__ import annotations
from core.capabilities.mcp.management import materialize_mcp_http_connection as market_dependency_materialize_mcp_http_connection
import asyncio
@@ -10,19 +10,22 @@
from typing import Any, Dict, Optional
from urllib.parse import urlencode, urlparse
from core.config.settings import settings
-from core.db.engine import SessionLocal
from core.db.models import AdminMcpServer
from core.harness.registry.marketplace_catalog import marketplace_catalog
from core.infra.crypto import decrypt_secret, encrypt_secret
from core.infra.exceptions import BadRequestError, ResourceNotFoundError
from core.infra.ephemeral import get_ephemeral_state
from core.capabilities.mcp.management import probe_mcp_connectivity, validate_remote_mcp_url
-from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
+from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata
from sqlalchemy.orm import Session
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-from core.capabilities.mcp import oauth_state as oauth_state_module
-from core.capabilities.mcp import oauth_storage as oauth_storage_module
+from core.capabilities.mcp import oauth_storage
+
+
+FLOW_TTL_SECONDS = 10 * 60
+
+
+DISCOVERY_GRACE_SECONDS = 5.0
+
@dataclass
class OAuthInstallFlow:
@@ -42,6 +45,7 @@ class OAuthInstallFlow:
result: Dict[str, Any] = field(default_factory=dict)
callback_code: str = ""
callback_state: Optional[str] = None
+ callback_issuer: Optional[str] = None
ready: asyncio.Event = field(default_factory=asyncio.Event)
callback_received: asyncio.Event = field(default_factory=asyncio.Event)
@@ -67,7 +71,7 @@ async def store_flow_status(flow: OAuthInstallFlow) -> None:
await get_ephemeral_state().put(
flow_key(flow.flow_id),
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
- ttl=oauth_state_module.FLOW_TTL_SECONDS,
+ ttl=FLOW_TTL_SECONDS,
)
@@ -100,7 +104,7 @@ def public_oauth_callback_url(request: Any) -> str:
def purge_expired_flows() -> None:
- cutoff = time.time() - oauth_state_module.FLOW_TTL_SECONDS
+ cutoff = time.time() - FLOW_TTL_SECONDS
for flow_id, flow in list(process_state().flows.items()):
if flow.created_at < cutoff:
process_state().flows.pop(flow_id, None)
@@ -110,8 +114,15 @@ def get_version(db: Session, slug: str) -> tuple[Any, Any]:
return marketplace_catalog().mcp_version(db, slug)
+def _oauth_method(db: Session, slug: str, auth_method: str):
+ _, version = get_version(db, slug)
+ auth_config = marketplace_catalog().normalize_auth_config(
+ version.auth_config, list(version.auth_schema or [])
+ )
+ return next((row for row in auth_config["methods"] if row["id"] == auth_method), None)
+
+
async def start_oauth_install(
- db: Session,
*,
slug: str,
owner_user_id: Optional[str],
@@ -125,12 +136,10 @@ async def start_oauth_install(
"""Start a browser OAuth install and wait only until the authorize URL is known."""
+ from core.db.threads import run_in_session
+
purge_expired_flows()
- _, version = get_version(db, slug)
- auth_config = marketplace_catalog().normalize_auth_config(
- version.auth_config, list(version.auth_schema or [])
- )
- method = next((row for row in auth_config["methods"] if row["id"] == auth_method), None)
+ method = await run_in_session(_oauth_method, slug, auth_method)
if not method or method["type"] != "oauth2":
raise BadRequestError(message="该 MCP 不支持所选 OAuth 认证方式")
if method.get("client_id_required") and not client_id.strip():
@@ -154,7 +163,7 @@ async def start_oauth_install(
await store_flow_status(flow)
asyncio.create_task(run_flow(flow), name=f"mcp-oauth-{flow_id}")
with contextlib.suppress(asyncio.TimeoutError):
- await asyncio.wait_for(flow.ready.wait(), timeout=oauth_state_module.DISCOVERY_GRACE_SECONDS)
+ await asyncio.wait_for(flow.ready.wait(), timeout=DISCOVERY_GRACE_SECONDS)
if flow.status == "failed":
raise BadRequestError(message=flow.error or "无法启动 OAuth 登录")
return {
@@ -164,26 +173,46 @@ async def start_oauth_install(
}
-async def run_flow(flow: OAuthInstallFlow) -> None:
+def _flow_inputs(db, flow: "OAuthInstallFlow"):
+ """What the OAuth probe needs from the market listing, read in one session."""
+ item, version = get_version(db, flow.slug)
+ auth_config = marketplace_catalog().normalize_auth_config(
+ version.auth_config, list(version.auth_schema or [])
+ )
+ method = next(row for row in auth_config["methods"] if row["id"] == flow.auth_method)
+ headers = marketplace_catalog().installation_secrets(
+ version,
+ flow.credentials,
+ {},
+ auth_method=flow.auth_method,
+ )
+ candidate = AdminMcpServer(
+ server_id=f"oauth_probe_{uuid.uuid4().hex[:16]}",
+ display_name=item.display_name,
+ description=item.description or "",
+ transport=version.transport,
+ url=version.url,
+ headers=headers,
+ args=[],
+ env_vars={},
+ env_inherit=[],
+ is_enabled=False,
+ extra_config={},
+ )
+ runtime_url, _ = market_dependency_materialize_mcp_http_connection(version.url, headers)
+ return method, candidate, runtime_url
- db = SessionLocal()
+async def run_flow(flow: OAuthInstallFlow) -> None:
+ """Drive one OAuth install; it may wait minutes for the user and holds no session."""
+ from core.db.threads import run_in_session
+
try:
- item, version = get_version(db, flow.slug)
- auth_config = marketplace_catalog().normalize_auth_config(
- version.auth_config, list(version.auth_schema or [])
- )
- method = next(row for row in auth_config["methods"] if row["id"] == flow.auth_method)
- headers = marketplace_catalog().installation_secrets(
- version,
- flow.credentials,
- {},
- auth_method=flow.auth_method,
- )
- runtime_url, _ = market_dependency_materialize_mcp_http_connection(version.url, headers)
+ method, candidate, runtime_url = await run_in_session(_flow_inputs, flow)
await validate_remote_mcp_url(runtime_url, require_https=False)
metadata = OAuthClientMetadata(
+ application_type=("native" if urlparse(flow.callback_url).hostname in {"localhost", "127.0.0.1", "::1"} else "web"),
redirect_uris=[flow.callback_url],
token_endpoint_auth_method=("client_secret_post" if flow.client_secret else "none"),
grant_types=["authorization_code", "refresh_token"],
@@ -191,7 +220,7 @@ async def run_flow(flow: OAuthInstallFlow) -> None:
scope=" ".join(method.get("scopes") or []) or None,
client_name="HugAgentOS MCP Client",
)
- storage = oauth_storage_module.OAuthBundleStorage(
+ storage = oauth_storage.OAuthBundleStorage(
{
"server_url": runtime_url,
"client_metadata": metadata.model_dump(mode="json", exclude_none=True),
@@ -212,13 +241,13 @@ async def redirect_handler(url: str) -> None:
await store_flow_status(flow)
flow.ready.set()
- async def callback_handler() -> tuple[str, str | None]:
+ async def callback_handler() -> AuthorizationCodeResult:
deadline = time.monotonic() + 300.0
while time.monotonic() < deadline:
if flow.callback_received.is_set():
if flow.error:
raise RuntimeError(flow.error)
- return flow.callback_code, flow.callback_state
+ return AuthorizationCodeResult(code=flow.callback_code or "", state=flow.callback_state, iss=flow.callback_issuer)
raw = await get_ephemeral_state().get(callback_key(flow.flow_id))
if raw:
payload = json.loads(raw)
@@ -227,34 +256,20 @@ async def callback_handler() -> tuple[str, str | None]:
raise RuntimeError(callback_error)
encrypted_code = str(payload.get("code") or "")
callback_code = decrypt_secret(encrypted_code) if encrypted_code else ""
- return callback_code or "", payload.get("state")
+ return AuthorizationCodeResult(code=callback_code or "", state=payload.get("state"), iss=payload.get("iss"))
await asyncio.sleep(0.5)
raise TimeoutError("OAuth 登录回调超时")
- provider = oauth_storage_module.build_oauth_provider(
+ provider = oauth_storage.build_oauth_provider(
runtime_url,
storage.bundle,
storage=storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
)
- candidate = AdminMcpServer(
- server_id=f"oauth_probe_{uuid.uuid4().hex[:16]}",
- display_name=item.display_name,
- description=item.description or "",
- transport=version.transport,
- url=version.url,
- headers=headers,
- args=[],
- env_vars={},
- env_inherit=[],
- is_stable=False,
- is_enabled=False,
- extra_config={},
- )
ok, error = await probe_mcp_connectivity(
candidate,
- db,
+ validate=True,
oauth_provider=provider,
timeout_seconds=300.0,
)
@@ -271,7 +286,6 @@ async def callback_handler() -> tuple[str, str | None]:
flow.status = "installing"
await store_flow_status(flow)
flow.result = await marketplace_catalog().install_market_item(
- db,
flow.slug,
owner_user_id=flow.owner_user_id,
installed_by=flow.installed_by,
@@ -282,13 +296,11 @@ async def callback_handler() -> tuple[str, str | None]:
flow.status = "completed"
await store_flow_status(flow)
except BaseException as exc: # noqa: BLE001
- db.rollback()
flow.status = "failed"
flow.error = str(exc)
await store_flow_status(flow)
finally:
flow.ready.set()
- db.close()
async def complete_callback(
@@ -296,6 +308,7 @@ async def complete_callback(
*,
code: str = "",
state: Optional[str] = None,
+ iss: Optional[str] = None,
error: str = "",
) -> None:
flow = process_state().flows.get(flow_id)
@@ -308,16 +321,18 @@ async def complete_callback(
{
"code": encrypt_secret(code) if code else "",
"state": state,
+ "iss": iss,
"error": error,
},
ensure_ascii=False,
separators=(",", ":"),
),
- ttl=oauth_state_module.FLOW_TTL_SECONDS,
+ ttl=FLOW_TTL_SECONDS,
)
if flow:
flow.callback_code = code
flow.callback_state = state
+ flow.callback_issuer = iss
flow.error = error
flow.status = "failed" if error else "processing_callback"
flow.callback_received.set()
@@ -329,7 +344,7 @@ async def complete_callback(
await get_ephemeral_state().put(
flow_key(flow_id),
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
- ttl=oauth_state_module.FLOW_TTL_SECONDS,
+ ttl=FLOW_TTL_SECONDS,
)
@@ -358,14 +373,13 @@ async def get_flow_status(flow_id: str, *, owner_user_id: Optional[str]) -> Dict
}
-@state_dataclass
+@dataclass
class OauthState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- flows: Dict[str, OAuthInstallFlow] = state_field(default_factory=lambda: {})
+ flows: Dict[str, OAuthInstallFlow] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> OauthState:
- return OauthState()
+_STATE = OauthState()
-process_state()
+
+def process_state() -> OauthState:
+ return _STATE
diff --git a/src/backend/core/capabilities/mcp/oauth_state.py b/src/backend/core/capabilities/mcp/oauth_state.py
deleted file mode 100644
index 7f5681a7..00000000
--- a/src/backend/core/capabilities/mcp/oauth_state.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""oauth state responsibilities."""
-from __future__ import annotations
-
-
-FLOW_TTL_SECONDS = 10 * 60
-
-
-DISCOVERY_GRACE_SECONDS = 5.0
-
-
-all__ = [
- "OAuthBundleStorage",
- "build_oauth_provider",
- "cancel_flow",
- "complete_callback",
- "get_flow_status",
- "public_oauth_callback_url",
- "start_oauth_install",
-]
diff --git a/src/backend/core/capabilities/mcp/oauth_storage.py b/src/backend/core/capabilities/mcp/oauth_storage.py
index 9bf3c1e2..5bf6b3f3 100644
--- a/src/backend/core/capabilities/mcp/oauth_storage.py
+++ b/src/backend/core/capabilities/mcp/oauth_storage.py
@@ -1,4 +1,4 @@
-"""oauth storage responsibilities."""
+"""Encrypted token storage that backs the MCP SDK's OAuth provider."""
from __future__ import annotations
import json
import time
@@ -101,7 +101,6 @@ def build_oauth_provider(
storage=actual_storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
- timeout=300.0,
)
async def _validate_oauth_request(request) -> None:
diff --git a/src/backend/core/capabilities/mcp/service.py b/src/backend/core/capabilities/mcp/service.py
index fb5e4404..25ced2fd 100644
--- a/src/backend/core/capabilities/mcp/service.py
+++ b/src/backend/core/capabilities/mcp/service.py
@@ -1,7 +1,7 @@
"""MCP server configuration service (DB-driven, cached).
Reads MCP server configs from the admin_mcp_servers table and provides
-them in the same dict format that MCPConnectionPool and agent_factory
+them in the same dict format that run-owned MCP discovery and agent_factory
expect (compatible with the old MCP_SERVERS dict from mcp_config.py).
Thread-safe singleton with a 30s TTL cache.
@@ -24,12 +24,12 @@
_CACHE_TTL = 30.0
-def _rewrite_builtin_mcp_host(url: str) -> str:
+def rewrite_builtin_mcp_host(url: str) -> str:
"""Point built-in MCP URLs at the configured host.
Plugin-provided MCP servers (automation_task / skill_manager / site_publish)
hardcode the Docker service name in their manifests
- (``http://mcp:/mcp/``). That host only resolves inside the compose
+ (``http://mcp:/mcp``). That host only resolves inside the compose
network, so in the no-Docker local profile (``MCP_HOST=127.0.0.1``) the
plugin's tool calls would be unreachable. Rewrite **only** the literal ``mcp``
host to ``settings.server.mcp_host``: a no-op on compose (mcp_host == "mcp"),
@@ -55,7 +55,8 @@ def _rewrite_builtin_mcp_host(url: str) -> str:
port = {base: PORTS[sid] for sid, base in _BASE_PORTS.items()}.get(port, port)
netloc = f"{host}:{port}" if port else host
- return urlunparse(parts._replace(netloc=netloc))
+ path = "/mcp" if parts.path == "/mcp/" else parts.path
+ return urlunparse(parts._replace(netloc=netloc, path=path))
except Exception:
return url
@@ -93,7 +94,6 @@ def get_all_servers(
"env": {...}, # merged: env_inherit from OS + env_vars
"url": "...", # for HTTP/SSE
"headers": {...},
- "is_stable": True,
}
"""
if not use_cache:
@@ -170,7 +170,6 @@ def _row_to_config(self, row: AdminMcpServer) -> dict:
"""Convert a DB row to the config dict format."""
cfg: Dict[str, Any] = {
"transport": row.transport,
- "is_stable": row.is_stable,
# 来源元数据(桌面双端桥按组件基名做 logical 去重时消费;
# 连接层不读这两个键)。
"source_plugin": row.source_plugin,
@@ -196,7 +195,7 @@ def _row_to_config(self, row: AdminMcpServer) -> dict:
runtime_url, runtime_headers = materialize_mcp_http_connection(
row.url or "", plaintext_headers
)
- cfg["url"] = _rewrite_builtin_mcp_host(runtime_url)
+ cfg["url"] = rewrite_builtin_mcp_host(runtime_url)
if runtime_headers:
cfg["headers"] = runtime_headers
oauth_raw = plaintext_headers.get(mcp_oauth_bundle_storage_key())
@@ -311,11 +310,6 @@ def get_server(self, server_id: str) -> Optional[dict]:
servers = self.get_all_servers(enabled_only=False)
return servers.get(server_id)
- def get_stable_server_ids(self) -> Set[str]:
- """Return set of server_ids where is_stable=True."""
- servers = self.get_all_servers(enabled_only=True)
- return {k for k, v in servers.items() if v.get("is_stable")}
-
def invalidate_cache(self) -> None:
"""Clear cache so next call re-reads from DB."""
with self._lock:
@@ -344,8 +338,8 @@ def get_all_rows(self) -> List[AdminMcpServer]:
# has since deleted, and it is a no-op on any DB that already has the catalog.
#
# The URL is templated per-server from ``extensions.mcp_servers._ports`` + ``settings.server.
-# mcp_host`` at seed time, so compose gets ``http://mcp:/mcp/`` and local
-# gets ``http://127.0.0.1:/mcp/`` from the same definition. Only the
+# mcp_host`` at seed time, so compose gets ``http://mcp:/mcp`` and local
+# gets ``http://127.0.0.1:/mcp`` from the same definition. Only the
# built-in servers the ``mcp`` container / ``_launcher`` actually serves are
# included; plugin-provided servers (automation_task / skill_manager /
# site_publish) arrive via plugin install, and external-service ones (db_query /
@@ -440,7 +434,7 @@ def seed_builtin_mcp_servers_if_empty(db) -> List[str]:
it bootstraps a fresh ``create_all`` DB (local profile) without touching a DB
that alembic already seeded and without resurrecting admin-deleted rows.
"""
- from core.config.settings import settings
+ from core.config.mcp_config import mcp_http_url
from extensions.mcp_servers._ports import PORTS
existing = (
@@ -454,7 +448,6 @@ def seed_builtin_mcp_servers_if_empty(db) -> List[str]:
if existing is not None:
return []
- host = settings.server.mcp_host
seeded: List[str] = []
for spec in BUILTIN_MCP_SERVERS:
sid = spec["server_id"]
@@ -468,8 +461,7 @@ def seed_builtin_mcp_servers_if_empty(db) -> List[str]:
description=spec.get("description", ""),
user_intro=spec.get("user_intro"),
transport="streamable_http",
- url=f"http://{host}:{port}/mcp/",
- is_stable=spec.get("is_stable", True),
+ url=mcp_http_url(sid),
is_enabled=spec.get("is_enabled", True),
sort_order=spec.get("sort_order", 0),
icon=spec.get("icon"),
diff --git a/src/backend/core/capabilities/mcp/tool_metadata.py b/src/backend/core/capabilities/mcp/tool_metadata.py
index f7daf89e..0c1a220a 100644
--- a/src/backend/core/capabilities/mcp/tool_metadata.py
+++ b/src/backend/core/capabilities/mcp/tool_metadata.py
@@ -5,13 +5,7 @@
def snapshot_tool(tool):
- raw = getattr(tool, "_tool", tool)
- if hasattr(raw, "model_dump"):
- data = raw.model_dump(by_alias=True, mode="json")
- else:
- data = {"name": tool.name, "description": getattr(tool, "description", ""),
- "inputSchema": getattr(tool, "inputSchema", None) or getattr(tool, "input_schema", {})}
- return public_tool_schema(data)
+ return public_tool_schema(tool.model_dump(by_alias=True, mode="json"))
def tool_snapshot_hash(tools):
diff --git a/src/backend/core/capabilities/mcp/tool_schema_backfill.py b/src/backend/core/capabilities/mcp/tool_schema_backfill.py
index c09464d6..375f7bf9 100644
--- a/src/backend/core/capabilities/mcp/tool_schema_backfill.py
+++ b/src/backend/core/capabilities/mcp/tool_schema_backfill.py
@@ -16,7 +16,7 @@
import asyncio
import logging
-from typing import List, Optional
+from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -41,9 +41,8 @@ async def backfill_missing_tool_schemas(server_ids: Optional[List[str]] = None)
logged and skipped: an unreachable server keeps its display-only entries, and
the manifest client withholds those tools rather than offering broken ones.
"""
- from core.db.engine import SessionLocal
- from core.db.models import AdminMcpServer
from core.capabilities.mcp.management import probe_mcp_connectivity
+ from core.db.threads import run_in_session
from core.harness.registry import device_catalog
@@ -61,50 +60,61 @@ async def backfill_missing_tool_schemas(server_ids: Optional[List[str]] = None)
logger.debug("[mcp-schema] hybrid desktop: schemas come from the cloud manifest, skipping probe")
return 0
- db = SessionLocal()
- try:
- from core.capabilities.plugins.management.bundle_upgrade import refresh
- refresh(db)
- query = db.query(AdminMcpServer).filter(AdminMcpServer.is_enabled.is_(True))
- if server_ids:
- query = query.filter(AdminMcpServer.server_id.in_(list(server_ids)))
- rows = [row for row in query.all() if _needs_backfill(row.tools_json)]
- if not rows:
- return 0
-
- semaphore = asyncio.Semaphore(_MAX_CONCURRENCY)
- repaired: List[str] = []
-
- async def _probe(row) -> None:
- async with semaphore:
- try:
- ok, detail = await probe_mcp_connectivity(
- row, db, timeout_seconds=_PROBE_TIMEOUT_S
- )
- except Exception as exc: # noqa: BLE001 - one server must not stop the pass
- logger.warning(
- "[mcp-schema] probe raised for '%s': %s", row.server_id, exc
- )
- return
- if ok and not _needs_backfill(row.tools_json):
- repaired.append(row.server_id)
- else:
- logger.warning(
- "[mcp-schema] '%s' still has tools without a schema: %s",
- row.server_id,
- detail,
- )
-
- await asyncio.gather(*(_probe(row) for row in rows))
- if repaired:
- db.commit()
- logger.info(
- "[mcp-schema] tool schemas captured for %d server(s): %s",
- len(repaired),
- ", ".join(sorted(repaired)),
- )
- else:
- db.rollback()
- return len(repaired)
- finally:
- db.close()
+ rows = await run_in_session(_servers_to_backfill, server_ids)
+ if not rows:
+ return 0
+
+ semaphore = asyncio.Semaphore(_MAX_CONCURRENCY)
+ repaired: Dict[str, list] = {}
+
+ async def _probe(row) -> None:
+ async with semaphore:
+ try:
+ ok, detail = await probe_mcp_connectivity(
+ row, validate=True, timeout_seconds=_PROBE_TIMEOUT_S
+ )
+ except Exception as exc: # noqa: BLE001 - one server must not stop the pass
+ logger.warning(
+ "[mcp-schema] probe raised for '%s': %s", row.server_id, exc
+ )
+ return
+ if ok and not _needs_backfill(row.tools_json):
+ repaired[row.server_id] = row.tools_json
+ else:
+ logger.warning(
+ "[mcp-schema] '%s' still has tools without a schema: %s",
+ row.server_id,
+ detail,
+ )
+
+ await asyncio.gather(*(_probe(row) for row in rows))
+ if repaired:
+ await run_in_session(_store_schemas, repaired)
+ logger.info(
+ "[mcp-schema] tool schemas captured for %d server(s): %s",
+ len(repaired),
+ ", ".join(sorted(repaired)),
+ )
+ return len(repaired)
+
+
+def _servers_to_backfill(db, server_ids):
+ """Enabled servers whose tools lack schemas, detached for probing."""
+ from core.capabilities.plugins.management.bundle_upgrade import refresh
+ from core.db.models import AdminMcpServer
+
+ refresh(db)
+ query = db.query(AdminMcpServer).filter(AdminMcpServer.is_enabled.is_(True))
+ if server_ids:
+ query = query.filter(AdminMcpServer.server_id.in_(list(server_ids)))
+ rows = [row for row in query.all() if _needs_backfill(row.tools_json)]
+ db.expunge_all()
+ return rows
+
+
+def _store_schemas(db, repaired) -> None:
+ from core.db.models import AdminMcpServer
+
+ for row in db.query(AdminMcpServer).filter(AdminMcpServer.server_id.in_(list(repaired))):
+ row.tools_json = repaired[row.server_id]
+ db.commit()
diff --git a/src/backend/core/capabilities/plugins/management/admin_config.py b/src/backend/core/capabilities/plugins/management/admin_config.py
index 2540b66a..68facf01 100644
--- a/src/backend/core/capabilities/plugins/management/admin_config.py
+++ b/src/backend/core/capabilities/plugins/management/admin_config.py
@@ -1,4 +1,4 @@
-"""Plugin admin config responsibilities."""
+"""Read and write a plugin's admin-level configuration."""
from __future__ import annotations
diff --git a/src/backend/core/capabilities/plugins/management/cloud.py b/src/backend/core/capabilities/plugins/management/cloud.py
index cdddfcb6..fa464472 100644
--- a/src/backend/core/capabilities/plugins/management/cloud.py
+++ b/src/backend/core/capabilities/plugins/management/cloud.py
@@ -8,7 +8,7 @@
import uuid
from sqlalchemy import text
from pathlib import Path
-from core.capabilities import archive as archive
+from core.capabilities import archive
from core.capabilities.skills.binary_files import encode_upload
from core.capabilities.plugins.management.package import snapshot
from core.db.engine import SessionLocal
diff --git a/src/backend/core/capabilities/plugins/management/components.py b/src/backend/core/capabilities/plugins/management/components.py
index 2f679fcb..a0a66e32 100644
--- a/src/backend/core/capabilities/plugins/management/components.py
+++ b/src/backend/core/capabilities/plugins/management/components.py
@@ -1,4 +1,4 @@
-"""Plugin components responsibilities."""
+"""Upsert a plugin's skills and MCP servers as admin catalog rows."""
from __future__ import annotations
diff --git a/src/backend/core/capabilities/plugins/management/details.py b/src/backend/core/capabilities/plugins/management/details.py
index d02e7dbd..15288a9c 100644
--- a/src/backend/core/capabilities/plugins/management/details.py
+++ b/src/backend/core/capabilities/plugins/management/details.py
@@ -1,20 +1,14 @@
-"""Plugin details responsibilities."""
+"""Marketplace plugin details and per-skill exclusions."""
from __future__ import annotations
-import base64
import logging
from typing import Any, Dict, List, Optional
from core.harness.registry.marketplace_catalog import marketplace_catalog
-from core.infra.exceptions import BadRequestError, ResourceNotFoundError
+from core.infra.exceptions import BadRequestError
from core.capabilities.plugins.management import admin_config as plugin_admin_config
from core.capabilities.plugins.packaging.presentation import overlay_market_meta
-from core.harness.registry.marketplace_catalog import marketplace_catalog
-from core.capabilities.plugins.management import packages as plugin_packages
-from core.capabilities.plugins.packaging import sources as plugin_sources
-from core.capabilities.plugins.packaging.archive import extract_plugin_zip
-from core.capabilities.plugins.packaging.importer import normalize_plugin_dir
from core.capabilities.plugins.packaging.models import NormalizedPlugin, NormalizedSkill
from core.capabilities.skills.installation import strip_frontmatter as _strip_frontmatter
from sqlalchemy.orm import Session
diff --git a/src/backend/core/capabilities/plugins/management/installation.py b/src/backend/core/capabilities/plugins/management/installation.py
index 9369e3e6..74993792 100644
--- a/src/backend/core/capabilities/plugins/management/installation.py
+++ b/src/backend/core/capabilities/plugins/management/installation.py
@@ -1,4 +1,4 @@
-"""Plugin installation responsibilities."""
+"""Install plugins from the marketplace, a directory or a zip, and bootstrap the defaults."""
from __future__ import annotations
diff --git a/src/backend/core/capabilities/plugins/management/lifecycle.py b/src/backend/core/capabilities/plugins/management/lifecycle.py
index 5884aeb0..08ac1a2f 100644
--- a/src/backend/core/capabilities/plugins/management/lifecycle.py
+++ b/src/backend/core/capabilities/plugins/management/lifecycle.py
@@ -1,4 +1,4 @@
-"""Plugin lifecycle responsibilities."""
+"""Uninstall plugins and toggle them or their components, globally or per user."""
from __future__ import annotations
diff --git a/src/backend/core/capabilities/plugins/management/package.py b/src/backend/core/capabilities/plugins/management/package.py
index 0d11291e..ebd2e4bc 100644
--- a/src/backend/core/capabilities/plugins/management/package.py
+++ b/src/backend/core/capabilities/plugins/management/package.py
@@ -5,7 +5,7 @@
import tarfile
import tempfile
import zipfile
-from core.capabilities import archive as archive
+from core.capabilities import archive
from core.harness.capability_errors import IntegrityFailed
diff --git a/src/backend/core/capabilities/plugins/management/packages.py b/src/backend/core/capabilities/plugins/management/packages.py
index aaa7c7f1..7289ad0b 100644
--- a/src/backend/core/capabilities/plugins/management/packages.py
+++ b/src/backend/core/capabilities/plugins/management/packages.py
@@ -7,7 +7,6 @@
from typing import Any, Dict, Optional
from core.harness.registry.marketplace_catalog import marketplace_catalog
-from core.infra.exceptions import ResourceNotFoundError
from core.infra.time import utc_now
from core.capabilities.plugins.packaging.archive import extract_plugin_zip
from core.capabilities.plugins.packaging.importer import normalize_plugin_dir
diff --git a/src/backend/core/capabilities/plugins/management/projection.py b/src/backend/core/capabilities/plugins/management/projection.py
index 62be5f29..d613c001 100644
--- a/src/backend/core/capabilities/plugins/management/projection.py
+++ b/src/backend/core/capabilities/plugins/management/projection.py
@@ -1,4 +1,4 @@
-"""Plugin projection responsibilities."""
+"""Project plugin changes into the desktop store and refresh dependent caches."""
from __future__ import annotations
diff --git a/src/backend/core/capabilities/plugins/management/queries.py b/src/backend/core/capabilities/plugins/management/queries.py
index a54c11bf..c029e998 100644
--- a/src/backend/core/capabilities/plugins/management/queries.py
+++ b/src/backend/core/capabilities/plugins/management/queries.py
@@ -1,4 +1,4 @@
-"""Plugin queries responsibilities."""
+"""List installed plugins and read their full details."""
from __future__ import annotations
diff --git a/src/backend/core/capabilities/plugins/packaging/definitions.py b/src/backend/core/capabilities/plugins/packaging/definitions.py
index 83078676..8bf6da21 100644
--- a/src/backend/core/capabilities/plugins/packaging/definitions.py
+++ b/src/backend/core/capabilities/plugins/packaging/definitions.py
@@ -1,4 +1,4 @@
-"""Plugin definitions responsibilities."""
+"""Paths, size limits and metadata keys for plugin bundles."""
from __future__ import annotations
diff --git a/src/backend/core/capabilities/plugins/packaging/sources.py b/src/backend/core/capabilities/plugins/packaging/sources.py
index 037d4940..8a96c814 100644
--- a/src/backend/core/capabilities/plugins/packaging/sources.py
+++ b/src/backend/core/capabilities/plugins/packaging/sources.py
@@ -1,4 +1,4 @@
-"""Plugin sources responsibilities."""
+"""Locate plugin bundles on disk and derive namespaced component ids."""
from __future__ import annotations
@@ -10,7 +10,7 @@
from core.capabilities.plugins.packaging import definitions as plugin_definitions
from core.capabilities.plugins.packaging.importer import ext_or_top as _ext_or_top, manifest_extensions
-from core.capabilities.skills.installation import compute_install_id as compute_install_id
+from core.capabilities.skills.installation import compute_install_id
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/capabilities/plugins/resources/authentication.py b/src/backend/core/capabilities/plugins/resources/authentication.py
index 8722ded7..a7af7469 100644
--- a/src/backend/core/capabilities/plugins/resources/authentication.py
+++ b/src/backend/core/capabilities/plugins/resources/authentication.py
@@ -14,7 +14,7 @@ def capture(request):
headers["cookie"] = settings.session.cookie_name + "=" + token
return {"headers": headers, "path": request.url.path, "method": request.method}
-async def validate(context, user_id, db):
+async def validate(context, user_id):
from core.auth.backend import get_current_user
from core.config.local_mode import local_mode_enabled
headers = context.get("headers", {})
@@ -29,6 +29,6 @@ async def validate(context, user_id, db):
authorization = headers.get("authorization", "")
scheme, _, value = authorization.partition(" ")
credentials = HTTPAuthorizationCredentials(scheme=scheme, credentials=value) if value else None
- current = await get_current_user(request, credentials=credentials, db=db)
+ current = await get_current_user(request, credentials)
if str(current.user_id) != user_id:
raise HTTPException(403, "viewer_identity_changed")
diff --git a/src/backend/core/capabilities/plugins/resources/deadlines.py b/src/backend/core/capabilities/plugins/resources/deadlines.py
index 6f78eaea..fa529f5f 100644
--- a/src/backend/core/capabilities/plugins/resources/deadlines.py
+++ b/src/backend/core/capabilities/plugins/resources/deadlines.py
@@ -21,16 +21,16 @@ def invalidate(db, row):
return resource_owner().invalidate(db, row)
-async def arm(db, row, *, require_background=False):
- if row.application_key is None or row.chat_id is not None:
- return
+async def arm(resource_id, *, require_background=False):
+ """Arm the runner deadline of an application resource (no-op for chat resources)."""
from core.harness.registry.resource_owners import resource_owner
- return await resource_owner().arm(db, row, require_background=require_background)
+ return await resource_owner().arm(resource_id, require_background=require_background)
-async def restore(db, *, after="", limit=100):
- rows = (
- db.query(store.PluginResource)
+def _active_application_ids(db, after, limit):
+ return [
+ resource_id
+ for (resource_id,) in db.query(store.PluginResource.resource_id)
.filter(
store.PluginResource.application_key.is_not(None),
store.PluginResource.status == "active",
@@ -38,11 +38,16 @@ async def restore(db, *, after="", limit=100):
)
.order_by(store.PluginResource.resource_id)
.limit(limit)
- .all()
- )
- for row in rows:
+ ]
+
+
+async def restore(*, after="", limit=100):
+ from core.db.threads import run_in_session
+
+ resource_ids = await run_in_session(_active_application_ids, after, limit)
+ for resource_id in resource_ids:
try:
- await arm(db, row)
+ await arm(resource_id)
except HTTPException:
- db.rollback() # Existing janitor owns persisted closing cleanup.
- return rows[-1].resource_id if len(rows) == limit else ""
+ pass # Existing janitor owns persisted closing cleanup.
+ return resource_ids[-1] if len(resource_ids) == limit else ""
diff --git a/src/backend/core/capabilities/plugins/resources/lifecycle.py b/src/backend/core/capabilities/plugins/resources/lifecycle.py
index b5e4c071..77b2ec84 100644
--- a/src/backend/core/capabilities/plugins/resources/lifecycle.py
+++ b/src/backend/core/capabilities/plugins/resources/lifecycle.py
@@ -7,14 +7,12 @@
from contextlib import asynccontextmanager
import httpx
-from core.db.engine import SessionLocal
+from core.db.threads import run_in_session
from core.capabilities.plugins.resources import store
-async def reconcile(db, user_id=None, after="", limit=100):
- from core.capabilities.plugins.resources import service
-
+def _candidates(db, user_id, after, limit):
query = db.query(store.PluginResource).filter(
store.PluginResource.status.in_(["active", "starting", "warming", "warm", "closing"])
)
@@ -26,6 +24,26 @@ async def reconcile(db, user_id=None, after="", limit=100):
.limit(limit)
.all()
)
+ for row in rows:
+ db.expunge(row)
+ return rows
+
+
+def _validate_binding(db, resource_id, user_id):
+ from core.capabilities.plugins.resources import service
+
+ service.validate_binding(db, db.get(store.PluginResource, resource_id), user_id)
+
+
+async def reconcile(user_id=None, after="", limit=100):
+ """Close persisted workers that are expired, unbound or no longer answering.
+
+ Rows are read, and each binding is checked, on short sessions of their own;
+ the liveness probes and shutdowns in between hold no connection.
+ """
+ from core.capabilities.plugins.resources import service
+
+ rows = await run_in_session(_candidates, user_id, after, limit)
async def valid(row):
if row.status == "closing" or row.expires_at <= time.time():
@@ -33,7 +51,7 @@ async def valid(row):
if row.status in {"starting", "warming"}:
return True
try:
- service.validate_binding(db, row, row.user_id)
+ await run_in_session(_validate_binding, row.resource_id, row.user_id)
target = store.descriptor(row)
async with httpx.AsyncClient(
timeout=3, trust_env=False, headers=target["headers"]
@@ -47,15 +65,21 @@ async def valid(row):
for row, alive in zip(rows, results):
if not alive:
try:
- await service.close(db, row)
+ await service.close(row)
except Exception as exc:
- db.rollback()
logging.getLogger(__name__).warning(
"Resource cleanup failed for %s: %s", row.resource_id, type(exc).__name__
)
return rows[-1].resource_id if len(rows) == limit else ""
+def _collect(db):
+ from core.harness.registry.resource_owners import resource_owner
+
+ resource_owner().collect(db)
+ db.commit()
+
+
@asynccontextmanager
async def lifespan(app):
from core.capabilities.plugins.resources import prewarm
@@ -64,8 +88,7 @@ async def lifespan(app):
cursor = ""
while True:
- with SessionLocal() as db:
- cursor = await restore(db, after=cursor)
+ cursor = await restore(after=cursor)
if not cursor:
break
prewarm.resume()
@@ -79,10 +102,8 @@ async def sweep():
from core.harness.registry.resource_owners import resource_owner
owner_cursor = await resource_owner().sweep(owner_cursor)
- with SessionLocal() as db:
- cursor = await reconcile(db, after=cursor)
- resource_owner().collect(db)
- db.commit()
+ cursor = await reconcile(after=cursor)
+ await run_in_session(_collect)
except Exception as exc:
logging.getLogger(__name__).warning(
"Resource reconciliation failed: %s", type(exc).__name__
diff --git a/src/backend/core/capabilities/plugins/resources/prewarm.py b/src/backend/core/capabilities/plugins/resources/prewarm.py
index cef2d443..162895da 100644
--- a/src/backend/core/capabilities/plugins/resources/prewarm.py
+++ b/src/backend/core/capabilities/plugins/resources/prewarm.py
@@ -4,18 +4,17 @@
import logging
import threading
from contextlib import asynccontextmanager
+from dataclasses import dataclass, field
import httpx
+
+from core.db.threads import run_in_session
from fastapi import HTTPException
from sqlalchemy import or_
-from core.db import engine
from core.capabilities.plugins.resources import installation, service, store
logger = logging.getLogger(__name__)
-
-
-
def candidates(db, user_id):
from core.harness.registry import device_catalog
if device_catalog.active():
@@ -49,18 +48,23 @@ def schedule(provider, chat_id, user_id, sandbox_id):
process_state().jobs.pop(old, None)
process_state().jobs[key] = loop.create_task(_run(provider, chat_id, user_id, str(sandbox_id)))
+def _prewarm_targets(db, chat_id, user_id):
+ """``(slug, module_id, install_id)`` of every prewarmed module the chat's owner has."""
+ from core.db.models import ChatSession
+
+ chat = db.get(ChatSession, chat_id)
+ if chat is None or chat.user_id != user_id or chat.deleted_at is not None:
+ return []
+ return [(selected.slug, module["id"], selected.install_id) for selected, module in candidates(db, user_id)]
+
+
async def _run(provider, chat_id, user_id, sandbox_id):
from core.sandbox.sessions.binding import bind
with bind(sandbox_id):
try:
- from core.db.models import ChatSession
- with engine.SessionLocal() as db:
- chat = db.get(ChatSession, chat_id)
- if chat is None or chat.user_id != user_id or chat.deleted_at is not None:
- return
- for selected, module in candidates(db, user_id):
- await service.create(db, selected.slug, module["id"], user_id, chat_id,
- install_id=selected.install_id, _prewarm=True, _provider=provider)
+ for slug, module_id, install_id in await run_in_session(_prewarm_targets, chat_id, user_id):
+ await service.create(slug, module_id, user_id, chat_id,
+ install_id=install_id, _prewarm=True, _provider=provider)
except Exception as exc:
logger.info("Resource prewarm skipped for chat=%s: %s", chat_id, type(exc).__name__)
return False
@@ -98,6 +102,28 @@ async def stop(chat_id=None):
async with closing(chat_id):
return await _stop(chat_id)
+def _warm_targets(db, chat_id):
+ """``(resource_id, target)`` of every blank worker; target None when unreadable."""
+ query = db.query(store.PluginResource).filter(store.PluginResource.status.in_(("warm", "warming")))
+ if chat_id is not None:
+ query = query.filter_by(chat_id=chat_id)
+ targets = []
+ for row in query.all():
+ try:
+ targets.append((row.resource_id, store.descriptor(row)))
+ except HTTPException:
+ targets.append((row.resource_id, None))
+ return targets
+
+
+def _close_rows(db, resource_ids):
+ if resource_ids:
+ db.query(store.PluginResource).filter(
+ store.PluginResource.resource_id.in_(resource_ids)
+ ).update({"status": "closed"}, synchronize_session=False)
+ db.commit()
+
+
async def _stop(chat_id):
"""Stop starts and blank workers. False forbids returning a dirty sandbox to its pool."""
with process_state().guard:
@@ -120,43 +146,32 @@ async def _stop(chat_id):
pass
except Exception:
clean = False
- with engine.SessionLocal() as db:
- query = db.query(store.PluginResource).filter(store.PluginResource.status.in_(("warm", "warming")))
- if chat_id is not None:
- query = query.filter_by(chat_id=chat_id)
- for row in query.all():
+ targets = await run_in_session(_warm_targets, chat_id)
+ for resource_id, target in targets:
+ if target is None:
+ clean = False
+ continue
+ if target.get("url"):
try:
- target = store.descriptor(row)
- except HTTPException:
+ async with httpx.AsyncClient(timeout=3, trust_env=False, headers=target["headers"]) as client:
+ response = await client.post(target["url"] + "/shutdown", json={})
+ response.raise_for_status()
+ except httpx.HTTPError:
clean = False
- row.status = "closed"
- continue
- if target.get("url"):
- try:
- async with httpx.AsyncClient(timeout=3, trust_env=False, headers=target["headers"]) as client:
- response = await client.post(target["url"] + "/shutdown", json={})
- response.raise_for_status()
- except httpx.HTTPError:
- clean = False
- row.status = "closed"
- db.commit()
+ await run_in_session(_close_rows, [resource_id for resource_id, _ in targets])
return clean
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class PrewarmState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- guard: object = state_field(default_factory=lambda: threading.Lock())
- jobs: object = state_field(default_factory=lambda: {})
- closing: object = state_field(default_factory=lambda: {})
+ guard: object = field(default_factory=lambda: threading.Lock())
+ jobs: object = field(default_factory=lambda: {})
+ closing: object = field(default_factory=lambda: {})
shutdown: object = False
-@state_cache(maxsize=1)
-def process_state() -> PrewarmState:
- return PrewarmState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = PrewarmState()
+
+
+def process_state() -> PrewarmState:
+ return _STATE
diff --git a/src/backend/core/capabilities/plugins/resources/reservation.py b/src/backend/core/capabilities/plugins/resources/reservation.py
index 4238b3dd..cbc6b020 100644
--- a/src/backend/core/capabilities/plugins/resources/reservation.py
+++ b/src/backend/core/capabilities/plugins/resources/reservation.py
@@ -1,6 +1,5 @@
"""Serialize resource admission and one-time warm worker adoption per owner."""
-import asyncio
import os
import time
@@ -22,55 +21,47 @@ def lock_owner(db, user_id):
db.query(UserShadow).filter_by(user_id=user_id).with_for_update().one()
-async def adopt(db, selected, module_id, user_id, chat_id, *, prewarm, checkpoint_id):
- """Return an adopted resource, or permission to reserve under the held lock."""
- deadline = time.monotonic() + 90
- while True:
- db.commit()
- lock_owner(db, user_id)
- query = (
- db.query(store.PluginResource)
- .filter_by(
- user_id=user_id,
- chat_id=chat_id,
- install_id=selected.install_id,
- revision=selected.revision,
- slug=selected.slug,
- module_id=module_id,
- )
- .filter(store.PluginResource.expires_at > time.time())
- )
- if prewarm:
- if query.filter(store.PluginResource.status.in_(LIVE_STATUSES)).first():
- db.commit()
- return None, True
- return None, False
- row = (
- None
- if checkpoint_id
- else query.filter(store.PluginResource.status.in_(("warm", "warming")))
- .order_by(store.PluginResource.created_at)
- .first()
+def adopt_once(db, selected, module_id, user_id, chat_id, *, prewarm, checkpoint_id):
+ """Take the owner lock and look for a warm worker to adopt.
+
+ Returns ``("adopted", row)``, ``("exists", None)`` (prewarm: one is already
+ live), ``("wait", None)`` (one is still warming; the lock is released), or
+ ``("reserve", None)`` — permission to reserve under the lock still held.
+ """
+ lock_owner(db, user_id)
+ query = (
+ db.query(store.PluginResource)
+ .filter_by(
+ user_id=user_id,
+ chat_id=chat_id,
+ install_id=selected.install_id,
+ revision=selected.revision,
+ slug=selected.slug,
+ module_id=module_id,
)
- if row is None:
- return None, False
- if row.status == "warm":
- row.status = "active"
- row.expires_at = time.time() + int(os.getenv("PLUGIN_RESOURCE_TTL_SECONDS", "86400"))
+ .filter(store.PluginResource.expires_at > time.time())
+ )
+ if prewarm:
+ if query.filter(store.PluginResource.status.in_(LIVE_STATUSES)).first():
db.commit()
- return row, True
+ return "exists", None
+ return "reserve", None
+ row = (
+ None
+ if checkpoint_id
+ else query.filter(store.PluginResource.status.in_(("warm", "warming")))
+ .order_by(store.PluginResource.created_at)
+ .first()
+ )
+ if row is None:
+ return "reserve", None
+ if row.status == "warm":
+ row.status = "active"
+ row.expires_at = time.time() + int(os.getenv("PLUGIN_RESOURCE_TTL_SECONDS", "86400"))
db.commit()
- if time.monotonic() >= deadline:
- raise HTTPException(503, "resource_prewarm_timeout")
- await asyncio.sleep(0.05)
- db.expire_all()
- # Each wait releases the transaction: installation switches and revocation
- # must be noticed before the worker can be adopted.
- from core.capabilities.plugins.resources import installation
-
- current = installation.resolve(db, selected.slug, user_id, selected.install_id)
- if current.revision != selected.revision:
- raise HTTPException(409, "plugin_revision_changed")
+ return "adopted", row
+ db.commit()
+ return "wait", None
def check_quota(db, user_id):
diff --git a/src/backend/core/capabilities/plugins/resources/service.py b/src/backend/core/capabilities/plugins/resources/service.py
index d4e0fa51..8e717b24 100644
--- a/src/backend/core/capabilities/plugins/resources/service.py
+++ b/src/backend/core/capabilities/plugins/resources/service.py
@@ -1,5 +1,6 @@
"""Authorized resource lifecycle. Runtime behavior remains package-owned."""
+import asyncio
import json
import os
import secrets
@@ -12,6 +13,7 @@
from core.capabilities.plugins.resources import installation, store
from core.capabilities.plugins.resources.crypto import encrypt_secret
from core.capabilities.plugins.resources.transport import RuntimeTransport
+from core.db.threads import run_in_session
def authorized(db, resource_id, user_id):
@@ -34,67 +36,71 @@ def validate_binding(db, row, user_id):
return row
-async def create(
- db,
- slug,
- module_id,
- user_id,
- chat_id,
- *,
- resource_id=None,
- install_id=None,
- checkpoint_id=None,
- _prewarm=False,
- _provider=None,
- _application=None,
-):
- if resource_id:
- row = authorized(db, resource_id, user_id)
- if row.chat_id != chat_id or row.slug != slug or row.module_id != module_id:
- raise HTTPException(403, "resource_binding_mismatch")
- await request(row, "/state")
- return store.public(row)
- if _application is None:
+def detached(db, row):
+ """``row`` with its columns loaded, detached so it can be read after the session closes."""
+ db.refresh(row)
+ db.expunge(row)
+ return row
+
+
+def authorized_row(db, resource_id, user_id):
+ """``authorized`` for async callers: the row comes back detached."""
+ return detached(db, authorized(db, resource_id, user_id))
+
+
+def _authorized_public(db, resource_id, user_id):
+ return store.public(authorized(db, resource_id, user_id))
+
+
+def _existing(db, resource_id, user_id, chat_id, slug, module_id):
+ row = authorized(db, resource_id, user_id)
+ if row.chat_id != chat_id or row.slug != slug or row.module_id != module_id:
+ raise HTTPException(403, "resource_binding_mismatch")
+ return detached(db, row)
+
+
+def _admission_inputs(db, slug, module_id, user_id, chat_id, install_id, checkpoint_id, prewarm, application):
+ """Check the owner, resolve the installation and module, and build the runner config."""
+ if application is None:
from core.db.models import ChatSession
chat = db.get(ChatSession, chat_id)
if chat is None or chat.user_id != user_id or chat.deleted_at is not None:
raise HTTPException(403, "conversation_unavailable")
else:
- if chat_id is not None or checkpoint_id or _prewarm:
+ if chat_id is not None or checkpoint_id or prewarm:
raise HTTPException(403, "resource_owner_invalid")
from core.capabilities.plugins.resources.owners import application_binding
application_binding(
db,
user_id,
- _application["application_key"],
+ application["application_key"],
install_id,
- _application["revision"],
- _application["scope"],
+ application["revision"],
+ application["scope"],
module_id,
)
resolver = installation.resolve
- if _application:
+ if application:
from core.harness.registry.resource_owners import resource_owner
resolver = resource_owner().exact_installation
selected = resolver(db, slug, user_id, install_id)
- if _application and selected.install_id != install_id:
+ if application and selected.install_id != install_id:
raise HTTPException(403, "resource_installation_mismatch")
- if _application and selected.revision != _application["revision"]:
+ if application and selected.revision != application["revision"]:
raise HTTPException(409, "plugin_revision_changed")
module = find_module(selected.ui, module_id)
if module is None or not module.get("resource"):
raise HTTPException(404, "resource_module_unavailable")
- if _application and "resource.attach" not in module.get("grants", []):
+ if application and "resource.attach" not in module.get("grants", []):
raise HTTPException(403, "resource_attach_not_granted")
- token = secrets.token_urlsafe(32)
from core.capabilities.plugins.resources.configuration import runtime_configuration
config = {
**runtime_configuration(module["resource"]),
- "token": token,
+ "token": secrets.token_urlsafe(32),
"idle_seconds": int(os.getenv("PLUGIN_RESOURCE_IDLE_SECONDS", "1800")),
}
if checkpoint_id:
@@ -111,87 +117,213 @@ async def create(
if state is None:
raise HTTPException(410, "checkpoint_expired")
config["checkpoint"] = json.loads(state)
- from core.capabilities.plugins.resources.lifecycle import reconcile
-
- await reconcile(db, user_id)
- from core.capabilities.plugins.resources.reservation import adopt, check_quota
-
- if _application is None:
- row, reused = await adopt(
- db, selected, module_id, user_id, chat_id, prewarm=_prewarm, checkpoint_id=checkpoint_id
- )
- else:
- from core.harness.registry.resource_owners import resource_owner
-
- row, reused = await resource_owner().reserve(db, selected, module_id, user_id, _application)
- if reused:
- return store.public(row)
- if reused:
- if row is None:
- return None
- from core.sandbox.factory import get_sandbox_provider
+ return selected, module, config
- provider = _provider or get_sandbox_provider()
- target = store.descriptor(row)
- if (
- target.get("sandbox_id") == await provider.current_sandbox_id(chat_id)
- and target.get("provider") == provider.name
- ):
- try:
- state = await request(row, "/state")
- if not state.get("closed"):
- alive = True
- else:
- alive = False
- except HTTPException:
- alive = False
- if alive:
- db.expire_all()
- row = authorized(db, row.resource_id, user_id)
- return store.public(row)
- await close(db, row)
- # A dead warm worker has performed no user actions; reserve a fresh one.
- from core.capabilities.plugins.resources.reservation import lock_owner
- lock_owner(db, user_id)
- check_quota(db, user_id)
+def _reserve(db, selected, module_id, user_id, chat_id, prewarm, application):
+ """Insert a starting row under the owner lock the caller already holds."""
+ from core.capabilities.plugins.resources.reservation import check_quota
+ from core.capabilities.plugins.resources.owners import runtime_session
from core.config.settings import settings
+ check_quota(db, user_id)
row = store.PluginResource(
- resource_id=_application["resource_id"] if _application else uuid.uuid4().hex,
+ resource_id=application["resource_id"] if application else uuid.uuid4().hex,
user_id=user_id,
chat_id=chat_id,
- application_key=_application["application_key"] if _application else None,
- instance_id=_application["instance_id"] if _application else None,
- request_digest=_application["request_digest"] if _application else None,
+ application_key=application["application_key"] if application else None,
+ instance_id=application["instance_id"] if application else None,
+ request_digest=application["request_digest"] if application else None,
install_id=selected.install_id,
revision=selected.revision,
- slug=slug,
+ slug=selected.slug,
module_id=module_id,
scope=(
- _application["scope"]
- if _application
+ application["scope"]
+ if application
else ("local" if settings.deploy.is_local else "cloud")
),
descriptor=encrypt_secret("{}"),
- status="warming" if _prewarm else "starting",
+ status="warming" if prewarm else "starting",
created_at=time.time(),
expires_at=time.time() + 90,
)
- from core.capabilities.plugins.resources.owners import runtime_session
-
session_id = runtime_session(row)
db.add(row)
db.commit()
+ return detached(db, row), session_id
+
+
+def _admit(db, selected, module_id, user_id, chat_id, prewarm, checkpoint_id, application, attempt, fresh):
+ """One admission attempt under the owner lock.
+
+ Returns ``("adopted", row)`` for a reusable worker, ``("exists", None)`` when
+ a prewarm finds one already live, ``("wait", None)`` while another start is
+ in flight, or ``("reserved", (row, session_id))`` for a new starting row.
+ """
+ from core.capabilities.plugins.resources.reservation import adopt_once, lock_owner
+
+ if attempt:
+ # Each wait releases the transaction: installation switches and revocation
+ # must be noticed before the worker can be adopted.
+ if application is None:
+ current = installation.resolve(db, selected.slug, user_id, selected.install_id)
+ if current.revision != selected.revision:
+ raise HTTPException(409, "plugin_revision_changed")
+ else:
+ from core.capabilities.plugins.resources.owners import application_binding
+
+ application_binding(
+ db,
+ user_id,
+ application["application_key"],
+ selected.install_id,
+ selected.revision,
+ application["scope"],
+ module_id,
+ )
+ if fresh:
+ lock_owner(db, user_id)
+ outcome, row = "reserve", None
+ elif application is None:
+ outcome, row = adopt_once(
+ db, selected, module_id, user_id, chat_id, prewarm=prewarm, checkpoint_id=checkpoint_id
+ )
+ else:
+ from core.harness.registry.resource_owners import resource_owner
+
+ outcome, row = resource_owner().reserve(db, selected, module_id, user_id, application)
+ if outcome == "reserve":
+ return "reserved", _reserve(db, selected, module_id, user_id, chat_id, prewarm, application)
+ if outcome == "adopted":
+ return outcome, detached(db, row)
+ return outcome, None
+
+
+def _mark(db, resource_id, status, descriptor=None):
+ row = db.get(store.PluginResource, resource_id)
+ row.status = status
+ if descriptor is not None:
+ # Retain the exact handle before cleanup so a failed stop is janitor-retryable.
+ row.descriptor = encrypt_secret(json.dumps(descriptor))
+ db.commit()
+
+
+def _activate(db, resource_id, user_id, descriptor, prewarm, application):
+ row = db.get(store.PluginResource, resource_id)
+ validate_binding(db, row, user_id)
+ updated = (
+ db.query(store.PluginResource)
+ .filter_by(resource_id=resource_id, status="warming" if prewarm else "starting")
+ .update(
+ {
+ "descriptor": encrypt_secret(json.dumps(descriptor)),
+ "status": "warm" if prewarm else "active",
+ "expires_at": time.time()
+ + (
+ 90
+ if application
+ else (
+ 300
+ if prewarm
+ else int(os.getenv("PLUGIN_RESOURCE_TTL_SECONDS", "86400"))
+ )
+ ),
+ },
+ synchronize_session=False,
+ )
+ )
+ db.commit()
+ if updated != 1:
+ raise HTTPException(410, "resource_start_cancelled")
+
+
+def _observe_started(db, resource_id):
+ from core.harness.registry.resource_owners import resource_owner
+
+ row = db.get(store.PluginResource, resource_id)
+ resource_owner().observe(db, row, "resource", "ok")
+ return store.public(row)
+
+
+async def _worker_alive(row, provider, chat_id):
+ target = store.descriptor(row)
+ if (
+ target.get("sandbox_id") != await provider.current_sandbox_id(chat_id)
+ or target.get("provider") != provider.name
+ ):
+ return False
+ try:
+ state = await request(row, "/state")
+ except HTTPException:
+ return False
+ return not state.get("closed")
+
+
+async def create(
+ slug,
+ module_id,
+ user_id,
+ chat_id,
+ *,
+ resource_id=None,
+ install_id=None,
+ checkpoint_id=None,
+ _prewarm=False,
+ _provider=None,
+ _application=None,
+):
+ """Start (or adopt) a resource worker for a conversation or an application.
+
+ Every database step runs on a session of its own; the owner lock is only
+ held inside one step, so no connection waits on a runner or the sandbox.
+ """
+ if resource_id:
+ row = await run_in_session(_existing, resource_id, user_id, chat_id, slug, module_id)
+ await request(row, "/state")
+ return store.public(row)
+ selected, module, config = await run_in_session(
+ _admission_inputs, slug, module_id, user_id, chat_id, install_id, checkpoint_id,
+ _prewarm, _application,
+ )
+ from core.capabilities.plugins.resources.lifecycle import reconcile
from core.sandbox.factory import get_sandbox_provider
- from core.sandbox.processes.interactive import launch
+ await reconcile(user_id)
provider = _provider or get_sandbox_provider()
+ deadline = time.monotonic() + (95 if _application else 90)
+ attempt, fresh = 0, False
+ while True:
+ outcome, value = await run_in_session(
+ _admit, selected, module_id, user_id, chat_id, _prewarm, checkpoint_id,
+ _application, attempt, fresh,
+ )
+ attempt += 1
+ if outcome == "exists":
+ return None
+ if outcome == "reserved":
+ break
+ if outcome == "adopted":
+ if _application is not None:
+ return store.public(value)
+ if await _worker_alive(value, provider, chat_id):
+ return await run_in_session(_authorized_public, value.resource_id, user_id)
+ await close(value)
+ # A dead warm worker has performed no user actions; reserve a fresh one.
+ fresh = True
+ continue
+ if time.monotonic() >= deadline:
+ raise HTTPException(
+ 503, "resource_start_pending" if _application else "resource_prewarm_timeout"
+ )
+ await asyncio.sleep(0.05)
+ row, session_id = value
+ from core.sandbox.processes.interactive import launch
+
try:
descriptor = await launch(provider, selected, module, user_id, session_id, config)
except BaseException as exc:
- row.status = "closed"
- db.commit()
+ await run_in_session(_mark, row.resource_id, "closed")
if not isinstance(exc, Exception):
raise
code = (
@@ -210,51 +342,18 @@ async def create(
)
raise HTTPException(503, "resource_runtime_unavailable: " + code) from exc
try:
- db.expire_all()
- validate_binding(db, row, user_id)
- updated = (
- db.query(store.PluginResource)
- .filter_by(
- resource_id=row.resource_id,
- status="warming" if _prewarm else "starting",
- )
- .update(
- {
- "descriptor": encrypt_secret(json.dumps(descriptor)),
- "status": "warm" if _prewarm else "active",
- "expires_at": time.time()
- + (
- 90
- if _application
- else (
- 300
- if _prewarm
- else int(os.getenv("PLUGIN_RESOURCE_TTL_SECONDS", "86400"))
- )
- ),
- },
- synchronize_session=False,
- )
- )
- db.commit()
- if updated != 1:
- raise HTTPException(410, "resource_start_cancelled")
- db.refresh(row)
+ await run_in_session(_activate, row.resource_id, user_id, descriptor, _prewarm, _application)
if _application:
from core.capabilities.plugins.resources.deadlines import arm
- await arm(db, row)
+ await arm(row.resource_id)
except BaseException:
- db.rollback()
if _application:
- # Retain the exact handle before cleanup so a failed stop is janitor-retryable.
- row.descriptor = encrypt_secret(json.dumps(descriptor))
- row.status = "closing"
- db.commit()
+ await run_in_session(_mark, row.resource_id, "closing", descriptor)
try:
- await close(db, row)
+ await close(row)
except Exception:
- db.rollback()
+ pass
else:
await provider.write_stdin(
descriptor["process_id"],
@@ -263,13 +362,9 @@ async def create(
chars="\x03",
yield_time_ms=1000,
)
- row.status = "closed"
- db.commit()
+ await run_in_session(_mark, row.resource_id, "closed")
raise
- from core.harness.registry.resource_owners import resource_owner
-
- resource_owner().observe(db, row, "resource", "ok")
- return store.public(row)
+ return await run_in_session(_observe_started, row.resource_id)
async def request(row, path, body=None, *, transport=None):
@@ -286,7 +381,20 @@ async def command(row, payload, *, actor, connection_id="", transport=None):
return await request(row, "/command", body, transport=transport)
-async def checkpoint(db, row, name, connection_id, *, transport=None):
+def _save_checkpoint(db, user_id, install_id, name, state):
+ record = store.PluginResourceCheckpoint(
+ checkpoint_id=uuid.uuid4().hex,
+ user_id=user_id,
+ install_id=install_id,
+ name=name[:80],
+ state_enc=encrypt_secret(json.dumps(state)),
+ )
+ db.add(record)
+ db.commit()
+ return {"checkpoint_id": record.checkpoint_id, "name": record.name}
+
+
+async def checkpoint(row, name, connection_id, *, transport=None):
if row.application_key is not None:
raise HTTPException(403, "application_checkpoint_not_granted")
state = await request(
@@ -301,23 +409,29 @@ async def checkpoint(db, row, name, connection_id, *, transport=None):
},
transport=transport,
)
- record = store.PluginResourceCheckpoint(
- checkpoint_id=uuid.uuid4().hex,
- user_id=row.user_id,
- install_id=row.install_id,
- name=name[:80],
- state_enc=encrypt_secret(json.dumps(state["state"])),
+ return await run_in_session(_save_checkpoint, row.user_id, row.install_id, name, state["state"])
+
+
+def _mark_closed(db, resource_id):
+ db.query(store.PluginResource).filter_by(resource_id=resource_id).update(
+ {"status": "closed"}, synchronize_session=False
)
- db.add(record)
+ db.query(store.PluginResourceTicket).filter_by(resource_id=resource_id).delete()
+ db.query(store.PluginResourceAssetTicket).filter_by(resource_id=resource_id).delete()
db.commit()
- return {"checkpoint_id": record.checkpoint_id, "name": record.name}
-async def close(db, row):
+async def close(row):
+ """Stop a resource worker, then retire its row and tickets.
+
+ ``row`` only supplies the worker's coordinates; the database work runs on a
+ session of its own after the network calls, so callers never hold a
+ connection across the shutdown.
+ """
if row.application_key is not None:
from core.harness.registry.resource_owners import resource_owner
- return await resource_owner().close(db, row)
+ return await resource_owner().close(row)
from core.capabilities.plugins.resources.owners import runtime_session
try:
@@ -343,7 +457,4 @@ async def close(db, row):
except Exception as fallback_error:
from core.infra.failures import record_best_effort_failure
record_best_effort_failure('core.capabilities.plugins.resources.service:close', fallback_error)
- row.status = "closed"
- db.query(store.PluginResourceTicket).filter_by(resource_id=row.resource_id).delete()
- db.query(store.PluginResourceAssetTicket).filter_by(resource_id=row.resource_id).delete()
- db.commit()
+ await run_in_session(_mark_closed, row.resource_id)
diff --git a/src/backend/core/capabilities/plugins/resources/stream.py b/src/backend/core/capabilities/plugins/resources/stream.py
index 01b742c7..8313867f 100644
--- a/src/backend/core/capabilities/plugins/resources/stream.py
+++ b/src/backend/core/capabilities/plugins/resources/stream.py
@@ -6,8 +6,9 @@
import uuid
import httpx
from fastapi import HTTPException, WebSocket
-from core.db.engine import SessionLocal
+from core.db.threads import run_in_session
from core.capabilities.plugins.resources import service, store
+from core.capabilities.plugins.resources.authentication import validate as validate_authentication
from core.capabilities.plugins.resources.transport import RuntimeTransport
MAX_PACKET = 8 * 1024 * 1024
@@ -34,7 +35,22 @@ async def packets(stream):
if len(buffer) > MAX_PACKET + 65540:
raise ValueError("frame_buffer_overflow")
+def _consume_ticket(db, ticket, resource_id, origin):
+ return store.consume(db, ticket, resource_id, origin, with_authentication=True)
+
+
+def _authorized_target(db, resource_id, user_id):
+ row = service.authorized_row(db, resource_id, user_id)
+ return row, store.descriptor(row)
+
+
async def connect(websocket: WebSocket, resource_id: str):
+ """Relay one viewer to a resource worker.
+
+ Each database step (ticket, authorization, checkpoint) runs to completion on
+ a session of its own before the next network wait, so a long-lived viewer
+ never pins a pooled connection.
+ """
origin = websocket.headers.get("origin", "")
await websocket.accept()
connection_id = uuid.uuid4().hex
@@ -42,16 +58,18 @@ async def connect(websocket: WebSocket, resource_id: str):
row = None
try:
initial = await asyncio.wait_for(websocket.receive_json(), 5)
- with SessionLocal() as db:
- attachment = store.consume(db, str(initial.get("ticket", "")), resource_id, origin, with_authentication=True)
- if attachment is None:
- await websocket.close(4401)
- return
- user_id, authentication = attachment
- from core.capabilities.plugins.resources.authentication import validate as validate_authentication
- await validate_authentication(authentication, user_id, db)
- row = service.authorized(db, resource_id, user_id)
- target = store.descriptor(row)
+ attachment = await run_in_session(_consume_ticket, str(initial.get("ticket", "")), resource_id, origin)
+ if attachment is None:
+ await websocket.close(4401)
+ return
+ user_id, authentication = attachment
+
+ async def authorize():
+ await validate_authentication(authentication, user_id)
+ return await run_in_session(service.authorized_row, resource_id, user_id)
+
+ await validate_authentication(authentication, user_id)
+ row, target = await run_in_session(_authorized_target, resource_id, user_id)
send_lock = asyncio.Lock()
async def send(data, binary=False):
async with send_lock:
@@ -71,19 +89,15 @@ async def receive():
payload = await websocket.receive_json()
if len(json.dumps(payload)) > 24 * 1024 * 1024:
raise ValueError("command_too_large")
- with SessionLocal() as db:
- await validate_authentication(authentication, user_id, db)
- current = service.authorized(db, resource_id, user_id)
+ current = await authorize()
try:
if payload.get("action") == "checkpoint":
- with SessionLocal() as db:
- data = await service.checkpoint(db, current, str(payload.get("params", {}).get("name", "账号")), connection_id, transport=transport)
+ data = await service.checkpoint(current, str(payload.get("params", {}).get("name", "账号")), connection_id, transport=transport)
else:
data = await service.command(current, payload, actor="user", connection_id=connection_id, transport=transport)
await send({"type": "result", "id": payload.get("id"), "ok": True, "data": data})
if payload.get("action") == "close":
- with SessionLocal() as db:
- await service.close(db, service.authorized(db, resource_id, user_id))
+ await service.close(await run_in_session(service.authorized_row, resource_id, user_id))
await websocket.close(4410)
return
except HTTPException as exc:
@@ -91,9 +105,7 @@ async def receive():
async def validate():
while True:
await asyncio.sleep(15)
- with SessionLocal() as db:
- await validate_authentication(authentication, user_id, db)
- service.authorized(db, resource_id, user_id)
+ await authorize()
async with RuntimeTransport(target) as transport:
tasks = [asyncio.create_task(fn()) for fn in (frames, receive, validate)]
try:
diff --git a/src/backend/core/capabilities/plugins/runtime/resolution.py b/src/backend/core/capabilities/plugins/runtime/resolution.py
index 8644ab75..9529a2f1 100644
--- a/src/backend/core/capabilities/plugins/runtime/resolution.py
+++ b/src/backend/core/capabilities/plugins/runtime/resolution.py
@@ -5,12 +5,13 @@
import logging
from typing import Any, Dict, List, Optional, Sequence
-logger = logging.getLogger(__name__)
from core.capabilities.plugins.runtime import activation_history as plugin_activation_history
from core.capabilities.plugins.runtime import models as plugin_runtime_models
+logger = logging.getLogger(__name__)
+
def _http_transport(cfg: Any) -> bool:
if not isinstance(cfg, dict):
diff --git a/src/backend/core/capabilities/plugins/runtime/sticky_selection.py b/src/backend/core/capabilities/plugins/runtime/sticky_selection.py
index 61811b46..722807db 100644
--- a/src/backend/core/capabilities/plugins/runtime/sticky_selection.py
+++ b/src/backend/core/capabilities/plugins/runtime/sticky_selection.py
@@ -6,12 +6,13 @@
import logging
from typing import Any, Dict, List, Optional, Set
-logger = logging.getLogger(__name__)
from core.capabilities.plugins.runtime import activation_history as plugin_activation_history
from core.capabilities.plugins.runtime import models as plugin_runtime_models
+logger = logging.getLogger(__name__)
+
def _cloud_sticky_selection(tokens, *, user_id, allow_aliases=False, unavailable_out=None):
"""Keep saved cloud selections tied to their exact account and installation."""
diff --git a/src/backend/core/capabilities/skills/backends/filesystem.py b/src/backend/core/capabilities/skills/backends/filesystem.py
index 1b88528e..79838555 100644
--- a/src/backend/core/capabilities/skills/backends/filesystem.py
+++ b/src/backend/core/capabilities/skills/backends/filesystem.py
@@ -6,7 +6,7 @@
from pathlib import Path
from typing import List
-from core.capabilities.skills.backends.protocol import SkillBackendProtocol, SkillFileInfo
+from core.capabilities.skills.backends.protocol import SkillFileInfo
class FilesystemBackend:
diff --git a/src/backend/core/capabilities/skills/deps/__init__.py b/src/backend/core/capabilities/skills/deps/__init__.py
index b839c0a0..a4fd4881 100644
--- a/src/backend/core/capabilities/skills/deps/__init__.py
+++ b/src/backend/core/capabilities/skills/deps/__init__.py
@@ -1 +1 @@
-"""deps domain package."""
+"""Detect, probe and request the runtime dependencies that skills declare."""
diff --git a/src/backend/core/capabilities/skills/deps/aggregator.py b/src/backend/core/capabilities/skills/deps/aggregator.py
index a50fce15..c5a2496c 100644
--- a/src/backend/core/capabilities/skills/deps/aggregator.py
+++ b/src/backend/core/capabilities/skills/deps/aggregator.py
@@ -3,12 +3,12 @@
Reads the `dependencies` JSON column from every enabled `admin_skills` row,
merges and deduplicates per ecosystem (pip / npm / apt), then writes:
- - `/docker/requirements-skills.txt` (pip)
- - `/docker/package-skills.json` (npm)
- - `/docker/apt-skills.txt` (apt)
+ - `/docker/sandbox/requirements-skills.txt` (pip)
+ - `/docker/sandbox/package-skills.json` (npm)
+ - `/docker/sandbox/apt-skills.txt` (apt)
-These files are picked up by `docker/Dockerfile.script-runner` and
-`docker/Dockerfile.opensandbox` during the rebuild flow.
+These files are picked up by `docker/sandbox/Dockerfile.script-runner` and
+`docker/sandbox/Dockerfile.opensandbox` during the rebuild flow.
"""
from __future__ import annotations
@@ -221,9 +221,9 @@ def write_manifests(
raise FileNotFoundError(f"repo_root not found: {repo_root}")
targets = {
- "pip": root / "docker" / "requirements-skills.txt",
- "npm": root / "docker" / "package-skills.json",
- "apt": root / "docker" / "apt-skills.txt",
+ "pip": root / "docker" / "sandbox" / "requirements-skills.txt",
+ "npm": root / "docker" / "sandbox" / "package-skills.json",
+ "apt": root / "docker" / "sandbox" / "apt-skills.txt",
}
changed: List[str] = []
for kind, path in targets.items():
diff --git a/src/backend/core/capabilities/skills/deps/prober.py b/src/backend/core/capabilities/skills/deps/prober.py
index 1adceb33..58ab84d1 100644
--- a/src/backend/core/capabilities/skills/deps/prober.py
+++ b/src/backend/core/capabilities/skills/deps/prober.py
@@ -7,7 +7,7 @@
- pip: ``importlib.metadata.distribution(name)`` queries the distribution in the sandbox Python interpreter (with PEP 503 normalization);
- apt: ``dpkg -s `` exit code;
-- npm: ``npm ls -g `` exit code (dependencies are baked in via ``npm install -g``, see docker/Dockerfile.opensandbox).
+- npm: ``npm ls -g `` exit code (dependencies are baked in via ``npm install -g``, see docker/sandbox/Dockerfile.opensandbox).
Returns the subset that is **still missing**. When the probe is uncertain (sandbox unreachable /
script error / output unparseable) it returns ``None``, and the caller handles it as
@@ -45,7 +45,7 @@ def _build_script(names: dict[str, list[str]]) -> str:
"""Generate the probe script to run inside the sandbox. Package names are embedded as JSON literals (the source is the detector, not free user input).
Key point: the sandbox's ``execute(language=python)`` runs the system ``/usr/bin/python3``, while the skill's pip
- dependencies are installed under ``$PY_BIN`` (see ``ENV PY_BIN=...`` in docker/Dockerfile.opensandbox +
+ dependencies are installed under ``$PY_BIN`` (see ``ENV PY_BIN=...`` in docker/sandbox/Dockerfile.opensandbox +
``$PY_BIN -m pip install -r requirements-skills.txt``). So pip must query distributions **against the $PY_BIN
interpreter**, not the currently running interpreter, otherwise everything is judged missing.
"""
diff --git a/src/backend/core/capabilities/skills/deps/requests.py b/src/backend/core/capabilities/skills/deps/requests.py
index 7abe9264..b58400c5 100644
--- a/src/backend/core/capabilities/skills/deps/requests.py
+++ b/src/backend/core/capabilities/skills/deps/requests.py
@@ -30,6 +30,7 @@
from sqlalchemy.orm import Session
from core.db.models import AdminSkill, SkillDependencyRequest
+from core.db.threads import run_in_session
from core.capabilities.skills.deps.prober import names_of, probe_still_missing
logger = logging.getLogger(__name__)
@@ -74,8 +75,27 @@ def _upsert_pending(db: Session, skill_id: str, *, user_id: Optional[str], missi
req.reason = None
+def _skill_exists(db: Session, skill_id: str) -> bool:
+ return db.query(AdminSkill.skill_id).filter(AdminSkill.skill_id == skill_id).first() is not None
+
+
+def _record_verdict(
+ db: Session, skill_id: str, owner_user_id: Optional[str], missing: dict
+) -> bool:
+ skill = db.query(AdminSkill).filter(AdminSkill.skill_id == skill_id).first()
+ if skill is None:
+ return False
+ if not missing:
+ _mark_ready(db, skill)
+ db.commit()
+ return False
+ skill.dep_status = "installing"
+ _upsert_pending(db, skill_id, user_id=owner_user_id, missing=missing)
+ db.commit()
+ return True
+
+
async def gate_skill_deps(
- db: Session,
skill_id: str,
*,
owner_user_id: Optional[str],
@@ -87,14 +107,11 @@ async def gate_skill_deps(
- All already present in the sandbox -> ``ready``, return False (do not bother the admin);
- Packages truly missing (or probe inconclusive, conservatively treated as missing) -> ``installing`` + pending, return True.
"""
- skill = db.query(AdminSkill).filter(AdminSkill.skill_id == skill_id).first()
- if skill is None:
- return False
-
declared = _extract_declared(dependencies)
if not owner_user_id or not declared:
- _mark_ready(db, skill)
- db.commit()
+ await run_in_session(_record_verdict, skill_id, owner_user_id, {})
+ return False
+ if not await run_in_session(_skill_exists, skill_id):
return False
probed = await probe_still_missing(declared)
@@ -105,17 +122,12 @@ async def gate_skill_deps(
else:
missing = {k: v for k, v in probed.items() if v}
- if not missing:
- _mark_ready(db, skill)
- db.commit()
+ pending = await run_in_session(_record_verdict, skill_id, owner_user_id, missing)
+ if pending:
+ logger.info("[skill-deps] %s flagged installing; missing=%s", skill_id, missing)
+ else:
logger.info("[skill-deps] %s deps already satisfied in sandbox; ready", skill_id)
- return False
-
- skill.dep_status = "installing"
- _upsert_pending(db, skill_id, user_id=owner_user_id, missing=missing)
- db.commit()
- logger.info("[skill-deps] %s flagged installing; missing=%s", skill_id, missing)
- return True
+ return pending
def list_pending(db: Session) -> list[dict[str, Any]]:
@@ -187,40 +199,31 @@ def get_reject_reason(db: Session, skill_id: str) -> Optional[str]:
return (req.reason if req else None) or None
-async def verify_pending_after_rebuild(db: Session, run_id: str) -> dict[str, int]:
- """After a successful sandbox rebuild: **re-probe** the dependencies of each pending request's skill, one by one.
-
- Truly installed (no more missing packages in the sandbox) -> set satisfied + restore
- the skill to ready; still missing -> keep pending and refresh the missing list (this
- rebuild did not install it). Inconclusive probes also stay pending (conservative).
- Returns ``{"satisfied": n, "still_pending": m}``. Idempotent: returns all zeros when
- there are no pending requests.
- """
+def _pending_declarations(db: Session) -> list[tuple[str, dict]]:
rows = (
db.query(SkillDependencyRequest)
.filter(SkillDependencyRequest.status == "pending")
.all()
)
- if not rows:
- return {"satisfied": 0, "still_pending": 0}
+ out = []
+ for r in rows:
+ skill = db.query(AdminSkill).filter(AdminSkill.skill_id == r.skill_id).first()
+ out.append((r.request_id, _extract_declared(skill.dependencies if skill else None)))
+ return out
+
+def _record_probes(db: Session, run_id: str, probes: dict[str, Optional[dict]]) -> dict[str, int]:
now = utc_now()
satisfied = 0
still_pending = 0
- for r in rows:
- skill = db.query(AdminSkill).filter(AdminSkill.skill_id == r.skill_id).first()
- declared = _extract_declared(skill.dependencies if skill else None)
- if not declared:
- # Skill no longer declares dependencies (edited/emptied) -> treat as satisfied.
- probed: Optional[dict] = {k: [] for k in _DEP_KINDS}
- else:
- probed = await probe_still_missing(declared)
-
- if probed is None:
+ for request_id, probed in probes.items():
+ r = db.get(SkillDependencyRequest, request_id)
+ if probed is None or r is None or r.status != "pending":
still_pending += 1
continue
missing = {k: v for k, v in probed.items() if v}
if not missing:
+ skill = db.query(AdminSkill).filter(AdminSkill.skill_id == r.skill_id).first()
r.status = "satisfied"
r.satisfied_at = now
r.satisfied_by_run_id = run_id
@@ -231,8 +234,31 @@ async def verify_pending_after_rebuild(db: Session, run_id: str) -> dict[str, in
r.missing = missing
still_pending += 1
db.commit()
+ return {"satisfied": satisfied, "still_pending": still_pending}
+
+
+async def verify_pending_after_rebuild(run_id: str) -> dict[str, int]:
+ """After a successful sandbox rebuild: **re-probe** the dependencies of each pending request's skill, one by one.
+
+ Truly installed (no more missing packages in the sandbox) -> set satisfied + restore
+ the skill to ready; still missing -> keep pending and refresh the missing list (this
+ rebuild did not install it). Inconclusive probes also stay pending (conservative).
+ Returns ``{"satisfied": n, "still_pending": m}``. Idempotent: returns all zeros when
+ there are no pending requests.
+ """
+ pending = await run_in_session(_pending_declarations)
+ if not pending:
+ return {"satisfied": 0, "still_pending": 0}
+
+ probes: dict[str, Optional[dict]] = {}
+ for request_id, declared in pending:
+ # Skill no longer declares dependencies (edited/emptied) -> treat as satisfied.
+ probes[request_id] = (
+ await probe_still_missing(declared) if declared else {k: [] for k in _DEP_KINDS}
+ )
+ result = await run_in_session(_record_probes, run_id, probes)
logger.info(
"[skill-deps] rebuild %s verified: satisfied=%d still_pending=%d",
- run_id, satisfied, still_pending,
+ run_id, result["satisfied"], result["still_pending"],
)
- return {"satisfied": satisfied, "still_pending": still_pending}
+ return result
diff --git a/src/backend/core/capabilities/skills/loader.py b/src/backend/core/capabilities/skills/loader.py
index 5534a7a0..500c17ad 100644
--- a/src/backend/core/capabilities/skills/loader.py
+++ b/src/backend/core/capabilities/skills/loader.py
@@ -1,4 +1,4 @@
-"""loader responsibilities."""
+"""Load skills from several sources, resolving conflicts by source priority."""
from __future__ import annotations
from core.harness.registry.local_publication import local_publication
import time
@@ -8,8 +8,9 @@
from core.capabilities.skills.config import get_enabled_skill_sources, skill_files_dir
from core.capabilities.skills.registry import AgentSkillMetadata, AgentSkillSpec, _load_skill_from_file, _load_skill_from_str, _load_skill_metadata_from_file, load_skill_metadata_from_str, parse_scripts_json
import logging
+from core.capabilities.skills import script_discovery
+
logger = logging.getLogger(__name__)
-from core.capabilities.skills import script_discovery as script_discovery_module
class MultiSourceSkillLoader:
"""Loads skills from multiple sources with priority-based conflict resolution.
@@ -135,7 +136,7 @@ def load_all_metadata(self) -> Dict[str, AgentSkillMetadata]:
metadata = _load_skill_metadata_from_file(skill_info.file_path)
# Add source information to skill_path for debugging
metadata_with_source = AgentSkillMetadata(
- id=script_discovery_module.bound_skill_id(skill_info, metadata.id),
+ id=script_discovery.bound_skill_id(skill_info, metadata.id),
name=metadata.name,
description=metadata.description,
version=metadata.version,
@@ -222,7 +223,7 @@ def load_skill_full(self, skill_id: str) -> Optional[AgentSkillSpec]:
# Auto-generate whitelist from .py files when _scripts.json is absent
if not executable_scripts:
- auto_scripts = script_discovery_module.auto_detect_scripts(ef, skill_info)
+ auto_scripts = script_discovery.auto_detect_scripts(ef, skill_info)
if auto_scripts:
executable_scripts = auto_scripts
logger.info(
@@ -234,7 +235,7 @@ def load_skill_full(self, skill_id: str) -> Optional[AgentSkillSpec]:
# Add source information to skill_path
spec_with_source = AgentSkillSpec(
- id=script_discovery_module.bound_skill_id(skill_info, spec.id),
+ id=script_discovery.bound_skill_id(skill_info, spec.id),
name=spec.name,
description=spec.description,
version=spec.version,
@@ -435,18 +436,10 @@ def register_skills_to_toolkit(
skill_dir = self.get_skill_dir(sid)
if skill_dir is None:
continue
- from core.harness.registry.run_capabilities import has_run_capabilities
-
- prepared = getattr(self, "capability_run", None)
- if prepared is not None or has_run_capabilities():
- # Native Toolkit deduplicates by Skill.name. Preserve the
- # resolved alias even if frontmatter keeps a source name or
- # the physical directory ends in a content revision.
- toolkit.register_agent_skill(
- skill_dir, runtime_name=sid, capability_run=prepared
- )
- else:
- toolkit.register_agent_skill(skill_dir)
+ from core.capabilities.skills.runtime_loader import RuntimeNamedSkillLoader
+ toolkit.skill_loaders.append(RuntimeNamedSkillLoader(
+ skill_dir, sid, getattr(self, "capability_run", None)
+ ))
count += 1
except Exception as e:
logger.warning("Failed to register skill '%s': %s", sid, e)
diff --git a/src/backend/core/capabilities/skills/loader_factory.py b/src/backend/core/capabilities/skills/loader_factory.py
index 3efee3b9..77a68da8 100644
--- a/src/backend/core/capabilities/skills/loader_factory.py
+++ b/src/backend/core/capabilities/skills/loader_factory.py
@@ -1,13 +1,12 @@
-"""loader factory responsibilities."""
+"""The process-wide skill loader instance."""
from __future__ import annotations
-from core.capabilities.skills import loader as loader_module_
+from core.capabilities.skills import loader
from typing import Optional
-from core.capabilities.skills import loader as loader_module
-global_loader: Optional[loader_module_.MultiSourceSkillLoader] = None
+global_loader: Optional[loader.MultiSourceSkillLoader] = None
-def get_skill_loader(reset: bool = False) -> loader_module_.MultiSourceSkillLoader:
+def get_skill_loader(reset: bool = False) -> loader.MultiSourceSkillLoader:
"""Get the global MultiSourceSkillLoader instance.
Args:
@@ -18,5 +17,5 @@ def get_skill_loader(reset: bool = False) -> loader_module_.MultiSourceSkillLoad
"""
global global_loader
if global_loader is None or reset:
- global_loader = loader_module_.MultiSourceSkillLoader()
+ global_loader = loader.MultiSourceSkillLoader()
return global_loader
diff --git a/src/backend/core/capabilities/skills/loader_state.py b/src/backend/core/capabilities/skills/loader_state.py
deleted file mode 100644
index 6f81be6c..00000000
--- a/src/backend/core/capabilities/skills/loader_state.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""loader state responsibilities."""
-from __future__ import annotations
-
-
-SCRIPT_EXTENSIONS = {".py", ".js", ".sh", ".r", ".R"}
diff --git a/src/backend/core/capabilities/skills/publication.py b/src/backend/core/capabilities/skills/publication.py
index 24fc6a6a..41370b2a 100644
--- a/src/backend/core/capabilities/skills/publication.py
+++ b/src/backend/core/capabilities/skills/publication.py
@@ -13,6 +13,7 @@
import tempfile
import threading
from contextlib import contextmanager
+from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from core.infra.file_lock import locked as file_lock
@@ -223,17 +224,13 @@ def _publish_selected(backend, user_id, private_only, wanted_shared, wanted_priv
(wanted_private if owner else wanted_shared).add(info.skill_id)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class PublicationState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- thread_lock: object = state_field(default_factory=lambda: threading.RLock())
+ thread_lock: object = field(default_factory=lambda: threading.RLock())
-@state_cache(maxsize=1)
-def process_state() -> PublicationState:
- return PublicationState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = PublicationState()
+
+
+def process_state() -> PublicationState:
+ return _STATE
diff --git a/src/backend/core/capabilities/skills/runtime_loader.py b/src/backend/core/capabilities/skills/runtime_loader.py
new file mode 100644
index 00000000..6abc1b34
--- /dev/null
+++ b/src/backend/core/capabilities/skills/runtime_loader.py
@@ -0,0 +1,115 @@
+"""Run-scoped skill loaders with authorized names and bounded desktop I/O."""
+from __future__ import annotations
+import asyncio
+import logging
+import os
+from dataclasses import replace
+from functools import lru_cache
+from pathlib import Path
+import frontmatter
+from agentscope.skill import LocalSkillLoader, SkillLoaderBase, Skill
+from core.capabilities.skills.config import model_facing_skill_dir
+logger = logging.getLogger(__name__)
+
+def _parse_runtime_skill(text):
+ content = frontmatter.loads(text)
+ name, description = content.get("name"), content.get("description")
+ if not name or not description:
+ return None
+ return str(name), str(description), content.content
+
+
+_cached_runtime_skill = lru_cache(maxsize=256)(_parse_runtime_skill)
+
+
+class RuntimeNamedSkillLoader(SkillLoaderBase):
+ """Read frozen files and expose a deployment-appropriate directory."""
+
+ def __init__(self, directory: str, runtime_name: str, capability_run=None) -> None:
+ self.directory = directory
+ self.runtime_name = runtime_name
+ self.capability_run = capability_run
+ self._physical_loader = LocalSkillLoader(directory)
+
+ def _list_desktop_skill(self):
+ from core.harness.registry.capability_runtime import validate
+ from core.harness.capability_errors import CapabilityError
+
+ # Keep authorization outside the parse-error handling: it must fail the
+ # whole enumeration, never quietly omit a revoked or modified skill.
+ try:
+ validate(self.capability_run, only_skill=self.runtime_name)
+ except (OSError, CapabilityError):
+ # 被撤销或改动过的单个技能只从枚举里消失,不牵连这一轮其它工具。
+ return []
+ try:
+ entry = Path(self.directory) / "SKILL.md"
+ updated_at = entry.stat().st_mtime
+ text = entry.read_text(encoding="utf-8")
+ parsed = (
+ _cached_runtime_skill(text) if len(text) <= 262144 else _parse_runtime_skill(text)
+ )
+ if parsed is None:
+ return []
+ _, description, markdown = parsed
+ return [
+ Skill(
+ name=self.runtime_name,
+ description=description,
+ dir=model_facing_skill_dir(self.runtime_name, self.directory),
+ markdown=markdown,
+ updated_at=updated_at,
+ )
+ ]
+ except Exception as exc:
+ logger.warning("Failed to load skill metadata: %s", type(exc).__name__)
+ return []
+
+ async def list_skills(self):
+ # AgentScope's general filesystem scanner crosses the executor boundary
+ # eight times for one known file. Batch that I/O in one worker while
+ # preserving its parser, fields and sandbox alias.
+ if os.name == "nt" and self.capability_run is not None:
+ return await asyncio.to_thread(self._list_desktop_skill)
+ from core.harness.registry.capability_runtime import validate
+
+ if self.capability_run is not None:
+ # Re-check only this loader's own skill (authorization + fresh hash),
+ # not the whole run. N loaders each re-hashing all N skills — twice
+ # per assembly — was O(N²) filesystem work and the dominant cost of
+ # desktop agent setup; scoping to one skill makes it O(N).
+ from core.harness.capability_errors import CapabilityError
+
+ try:
+ await asyncio.to_thread(validate, self.capability_run, only_skill=self.runtime_name)
+ except (CapabilityError, OSError):
+ return []
+ physical = await self._physical_loader.list_skills()
+ return [
+ replace(skill, name=self.runtime_name, dir=model_facing_skill_dir(self.runtime_name, self.directory))
+ for skill in physical
+ ]
+
+
+class ParallelRuntimeSkillLoader(SkillLoaderBase):
+ """Enumerate independent desktop loaders with bounded I/O concurrency."""
+
+ def __init__(self, loaders):
+ self._loaders = tuple(loaders)
+
+ async def list_skills(self):
+ limit = asyncio.Semaphore(4)
+
+ async def load(loader):
+ async with limit:
+ return await loader.list_skills()
+
+ results = await asyncio.gather(
+ *(load(loader) for loader in self._loaders), return_exceptions=True
+ )
+ skills = []
+ for result in results:
+ if isinstance(result, BaseException):
+ raise result
+ skills.extend(result)
+ return skills
diff --git a/src/backend/core/capabilities/skills/script_discovery.py b/src/backend/core/capabilities/skills/script_discovery.py
index 41a3b1a2..3add1837 100644
--- a/src/backend/core/capabilities/skills/script_discovery.py
+++ b/src/backend/core/capabilities/skills/script_discovery.py
@@ -1,9 +1,12 @@
-"""script discovery responsibilities."""
+"""Find the executable scripts a skill ships when it has no _scripts.json."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List
from core.capabilities.skills.backends import SkillFileInfo
-from core.capabilities.skills import loader_state as loader_state_module
+
+
+SCRIPT_EXTENSIONS = {".py", ".js", ".sh", ".r", ".R"}
+
def bound_skill_id(skill_info: SkillFileInfo, declared_id: str) -> str:
# Capability names come from the resolved view, independently of the file's
@@ -33,7 +36,7 @@ def auto_detect_scripts(
if fname.startswith("_"):
continue
ext = Path(fname).suffix.lower()
- if ext in loader_state_module.SCRIPT_EXTENSIONS:
+ if ext in SCRIPT_EXTENSIONS:
candidates.append(fname)
# From filesystem (filesystem skills without extra_files)
@@ -43,7 +46,7 @@ def auto_detect_scripts(
if (
p.is_file()
and not p.name.startswith("_")
- and p.suffix.lower() in loader_state_module.SCRIPT_EXTENSIONS
+ and p.suffix.lower() in SCRIPT_EXTENSIONS
):
candidates.append(p.name)
diff --git a/src/backend/core/chat/compaction/__init__.py b/src/backend/core/chat/compaction/__init__.py
index 224dc295..75837be2 100644
--- a/src/backend/core/chat/compaction/__init__.py
+++ b/src/backend/core/chat/compaction/__init__.py
@@ -1 +1 @@
-"""compaction domain package."""
+"""Context compaction for long chats: budgeting, summarising and checkpointing."""
diff --git a/src/backend/core/chat/compaction/coordinator.py b/src/backend/core/chat/compaction/coordinator.py
index 95d550fb..b3a2a02b 100644
--- a/src/backend/core/chat/compaction/coordinator.py
+++ b/src/backend/core/chat/compaction/coordinator.py
@@ -9,7 +9,7 @@
from core.config import settings as config_settings
from core.llm.context import compaction as C
-from core.llm.context import budget as budget
+from core.llm.context import budget
from core.chat.compaction import summary as summary_model
from core.chat.compaction.history import normalize_rows
from core.chat.compaction.progress import finish_progress, report_outcome, start_progress
diff --git a/src/backend/core/chat/compaction/history.py b/src/backend/core/chat/compaction/history.py
index 63956257..6ab148eb 100644
--- a/src/backend/core/chat/compaction/history.py
+++ b/src/backend/core/chat/compaction/history.py
@@ -7,7 +7,7 @@
from core.chat import inflight
from core.chat.context import build_effective_user_message
from core.llm.context import compaction as C
-from core.llm.context.ir_state import SESSION_CONTEXT_META_KEY
+from core.llm.context.ir_constants import SESSION_CONTEXT_META_KEY
from core.llm.runtime.model_steps import assistant_row_replay, record_assistant_step
from core.chat.references import render_reference_block
diff --git a/src/backend/core/chat/compaction/service.py b/src/backend/core/chat/compaction/service.py
index 8d65f44c..c668eada 100644
--- a/src/backend/core/chat/compaction/service.py
+++ b/src/backend/core/chat/compaction/service.py
@@ -10,7 +10,7 @@
from core.chat import inflight
from core.config import settings as config_settings
from core.llm.context import compaction as C
-from core.models.context_window import AUTO_COMPACT_MAX_RATIO, resolve_model_context_window, usable_context_window
+from core.models.context_window import AUTO_COMPACT_MAX_RATIO, resolve_model_context_window
from core.llm.context.budget import estimate_context_budget, should_compact
from core.chat.compaction.history import normalize_rows
from core.chat.compaction.summary import _load_base_system_prompt, summarize_without_checkpoint
diff --git a/src/backend/core/chat/compaction_checkpoints.py b/src/backend/core/chat/compaction_checkpoints.py
index d6c11d83..8f9a1513 100644
--- a/src/backend/core/chat/compaction_checkpoints.py
+++ b/src/backend/core/chat/compaction_checkpoints.py
@@ -1,11 +1,11 @@
-"""compaction checkpoints operations."""
+"""Publish and read compaction checkpoints with lease and compare-and-swap fencing."""
from __future__ import annotations
import uuid
from dataclasses import replace
from typing import Any, Dict, List, Optional
from core.db.models import ChatCompactionState, ChatMessage
-from core.chat import compaction_types as compaction_types_module
-from core.chat import service_time as service_time_module
+from core.chat import compaction_types
+from core.infra.time import utc_now
def invalidate_compaction_lineage_for_rewrite(
self,
@@ -37,7 +37,7 @@ def invalidate_compaction_lineage_for_rewrite(
state.covered_seq = 0
state.lease_owner = None
state.lease_expires_at = None
- state.updated_at = service_time_module.utc_now()
+ state.updated_at = utc_now()
from core.llm.context.compaction import COMPACTION_CHECKPOINT_KIND
@@ -73,7 +73,7 @@ def add_compaction_checkpoint(
chat_id, owner=owner, lease_seconds=60
)
if snapshot is None:
- raise compaction_types_module.CompactionCASConflict(
+ raise compaction_types.CompactionCASConflict(
"no uncompacted source or compaction lease is busy"
)
if (
@@ -81,7 +81,7 @@ def add_compaction_checkpoint(
and snapshot.base_checkpoint_version != int(base_checkpoint_version)
):
self.release_compaction_lease(chat_id, owner=owner)
- raise compaction_types_module.CompactionCASConflict("checkpoint base changed")
+ raise compaction_types.CompactionCASConflict("checkpoint base changed")
if covered_seq is not None and int(covered_seq) != snapshot.covered_seq:
self.release_compaction_lease(chat_id, owner=owner)
raise ValueError("covered_seq must equal the acquired source watermark")
@@ -143,7 +143,7 @@ def checkpoint_covered_seq(checkpoint: Optional[ChatMessage]) -> int:
def commit_compaction_checkpoint(
self,
- snapshot: compaction_types_module.CompactionSnapshot,
+ snapshot: compaction_types.CompactionSnapshot,
*,
owner: str,
summary_text: str,
@@ -154,7 +154,7 @@ def commit_compaction_checkpoint(
from core.llm.context.compaction import COMPACTION_CHECKPOINT_KIND
if owner != snapshot.owner:
- raise compaction_types_module.CompactionCASConflict("snapshot owner mismatch")
+ raise compaction_types.CompactionCASConflict("snapshot owner mismatch")
message_id = f"cmpct_{uuid.uuid4().hex[:16]}"
next_version = snapshot.base_checkpoint_version + 1
manifest = dict(replacement_manifest)
@@ -192,7 +192,7 @@ def commit_compaction_checkpoint(
},
commit=False,
)
- now = service_time_module.utc_now()
+ now = utc_now()
updated = (
self.db.query(ChatCompactionState)
.filter(
@@ -216,7 +216,7 @@ def commit_compaction_checkpoint(
)
if updated != 1:
self.db.rollback()
- raise compaction_types_module.CompactionCASConflict("checkpoint base or lease changed")
+ raise compaction_types.CompactionCASConflict("checkpoint base or lease changed")
if snapshot.base_checkpoint_id:
previous = self.db.get(ChatMessage, snapshot.base_checkpoint_id)
diff --git a/src/backend/core/chat/compaction_snapshots.py b/src/backend/core/chat/compaction_snapshots.py
index 22a2938b..2b5eaa46 100644
--- a/src/backend/core/chat/compaction_snapshots.py
+++ b/src/backend/core/chat/compaction_snapshots.py
@@ -1,13 +1,13 @@
-"""compaction snapshots operations."""
+"""Capture the message snapshot a compaction summarises and hold its writer lease."""
from __future__ import annotations
import hashlib
import json
from datetime import timedelta
from typing import Optional
from core.db.models import ChatCompactionState, ChatMessage, ChatSession
-from core.chat import compaction_types as compaction_types_module
-from core.chat import service_time as service_time_module
+from core.chat import compaction_types
import logging
+from core.infra.time import as_utc, utc_now
logger = logging.getLogger(__name__)
def ensure_message_sequences(self, chat_id: str) -> None:
@@ -74,7 +74,7 @@ def ensure_compaction_state(self, chat_id: str) -> ChatCompactionState:
covered_seq=self._checkpoint_covered_seq(checkpoint)
if has_exact_watermark
else 0,
- updated_at=service_time_module.utc_now(),
+ updated_at=utc_now(),
)
self.db.add(state)
self.db.flush()
@@ -87,7 +87,7 @@ def acquire_compaction_snapshot(
*,
owner: str,
lease_seconds: int,
-) -> Optional[compaction_types_module.CompactionSnapshot]:
+) -> Optional[compaction_types.CompactionSnapshot]:
"""Capture an exact source watermark and acquire a short-lived lease.
Every exit path must end this transaction. The body takes a row lock on
@@ -118,13 +118,13 @@ def acquire_compaction_snapshot_impl(
*,
owner: str,
lease_seconds: int,
-) -> Optional[compaction_types_module.CompactionSnapshot]:
+) -> Optional[compaction_types.CompactionSnapshot]:
from core.llm.context.compaction import COMPACTION_CHECKPOINT_KIND
self._ensure_message_sequences(chat_id)
state = self._ensure_compaction_state(chat_id)
- now = service_time_module.utc_now()
- if state.lease_owner and (service_time_module.as_utc(state.lease_expires_at) or now) > now:
+ now = utc_now()
+ if state.lease_owner and (as_utc(state.lease_expires_at) or now) > now:
self.db.rollback()
return None
@@ -165,7 +165,7 @@ def acquire_compaction_snapshot_impl(
return None
source_rows = tuple(
- compaction_types_module.CompactionSourceRow(
+ compaction_types.CompactionSourceRow(
message_id=row.message_id,
chat_seq=int(row.chat_seq or 0),
role=row.role,
@@ -230,7 +230,7 @@ def acquire_compaction_snapshot_impl(
base_covered_seq = int(state.covered_seq or 0)
base_checkpoint_id = checkpoint.message_id if checkpoint is not None else None
self.db.commit()
- return compaction_types_module.CompactionSnapshot(
+ return compaction_types.CompactionSnapshot(
chat_id=chat_id,
owner=owner,
base_checkpoint_id=base_checkpoint_id,
@@ -256,7 +256,7 @@ def release_compaction_lease(self, chat_id: str, *, owner: str) -> bool:
{
ChatCompactionState.lease_owner: None,
ChatCompactionState.lease_expires_at: None,
- ChatCompactionState.updated_at: service_time_module.utc_now(),
+ ChatCompactionState.updated_at: utc_now(),
},
synchronize_session=False,
)
diff --git a/src/backend/core/chat/compaction_types.py b/src/backend/core/chat/compaction_types.py
index 402c24d6..f0219a50 100644
--- a/src/backend/core/chat/compaction_types.py
+++ b/src/backend/core/chat/compaction_types.py
@@ -1,4 +1,4 @@
-"""compaction types responsibilities."""
+"""Snapshot and conflict types shared by the compaction checkpoint protocol."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
diff --git a/src/backend/core/chat/followups.py b/src/backend/core/chat/followups.py
index e175efe4..b0fbd1ce 100644
--- a/src/backend/core/chat/followups.py
+++ b/src/backend/core/chat/followups.py
@@ -16,6 +16,7 @@
import logging
import re
import time
+from dataclasses import dataclass
from typing import Any, Optional
from core.config.settings import settings
@@ -214,8 +215,6 @@ def _clean_list(items: list) -> list[str]:
# ── Singleton ──────────────────────────────────────────────────────────
-
-
def get_followup_generator() -> FollowUpGenerator:
pass
if process_state().instance is None:
@@ -223,17 +222,13 @@ def get_followup_generator() -> FollowUpGenerator:
return process_state().instance
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class FollowupsState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
instance: FollowUpGenerator | None = None
-@state_cache(maxsize=1)
-def process_state() -> FollowupsState:
- return FollowupsState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = FollowupsState()
+
+
+def process_state() -> FollowupsState:
+ return _STATE
diff --git a/src/backend/core/chat/limits.py b/src/backend/core/chat/limits.py
index 852d33ef..6dc3146f 100644
--- a/src/backend/core/chat/limits.py
+++ b/src/backend/core/chat/limits.py
@@ -1,25 +1,33 @@
-"""limits responsibilities."""
+"""Length limits applied to message content and thinking before they are stored."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import logging
logger = logging.getLogger(__name__)
-from core.chat import service_state as service_state_module
+
+CONTENT_MAX = 100_000
+
+
+TRUNC_NOTE = "\n\n…(本条回复过长,已截断保存;完整过程见工具调用记录)"
+
+
+THINKING_MAX = 200_000
+
def clamp_message_content(content: str) -> str:
"""Apply the database content limit independently of ChatService wiring."""
text = content or ""
- if len(text) <= service_state_module.CONTENT_MAX:
+ if len(text) <= CONTENT_MAX:
return text
- keep = service_state_module.CONTENT_MAX - len(service_state_module.TRUNC_NOTE)
- logger.warning("[chat] message content truncated: %d -> %d chars", len(text), service_state_module.CONTENT_MAX)
+ keep = CONTENT_MAX - len(TRUNC_NOTE)
+ logger.warning("[chat] message content truncated: %d -> %d chars", len(text), CONTENT_MAX)
kept = text[:keep]
# 老格式的思考是以 … 内联在正文里的。截断点可能落在块中间,
# 闭合标签一丢,历史重建就把整段思考当正文渲染出来(思考泄露到页面上)。
close = ""
if kept.count("") > kept.count(close):
kept = kept[: keep - len(close)] + close
- return kept + service_state_module.TRUNC_NOTE
+ return kept + TRUNC_NOTE
def clamp_thinking(
@@ -35,11 +43,11 @@ def clamp_thinking(
"""
if not blocks:
return None, segments or None
- if sum(len(str(b.get("content") or "")) for b in blocks) <= service_state_module.THINKING_MAX:
+ if sum(len(str(b.get("content") or "")) for b in blocks) <= THINKING_MAX:
return blocks, segments or None
kept: List[Dict[str, Any]] = []
kept_indices: List[int] = []
- budget = service_state_module.THINKING_MAX
+ budget = THINKING_MAX
for offset, block in enumerate(reversed(blocks)):
text = str(block.get("content") or "")
if not text:
diff --git a/src/backend/core/chat/message_queries.py b/src/backend/core/chat/message_queries.py
index 9db3e35a..dbdb7e31 100644
--- a/src/backend/core/chat/message_queries.py
+++ b/src/backend/core/chat/message_queries.py
@@ -1,4 +1,4 @@
-"""message queries operations."""
+"""Read chat messages, with access checks."""
from __future__ import annotations
from typing import List, Optional, Tuple
from core.db.models import ChatMessage
diff --git a/src/backend/core/chat/message_writes.py b/src/backend/core/chat/message_writes.py
index f860b431..c2e69cd8 100644
--- a/src/backend/core/chat/message_writes.py
+++ b/src/backend/core/chat/message_writes.py
@@ -1,14 +1,13 @@
-"""message writes operations."""
+"""Add, upsert, stream-refresh and delete chat messages."""
from __future__ import annotations
-from core.infra.time import utc_now
+from core.infra.time import as_utc, utc_now
import uuid
from typing import Any, Dict, List, Optional
from core.db.models import ChatCompactionState, ChatMessage, ChatRun, ChatSession
from core.db.repository.chat import _strip_nul
from sqlalchemy import update
from core.ontology.revision import is_substantive_revision, normalize_revision_candidate
-from core.chat import limits as limits_module
-from core.chat import service_time as service_time_module
+from core.chat import limits
def add_message(
self,
@@ -28,7 +27,7 @@ def add_message(
) -> ChatMessage:
"""Add a message to a chat session."""
extra = dict(extra_data or {})
- kept_thinking, kept_segments = limits_module.clamp_thinking(thinking, extra.get("segments"))
+ kept_thinking, kept_segments = limits.clamp_thinking(thinking, extra.get("segments"))
if "segments" in extra:
extra["segments"] = kept_segments
message_data = {
@@ -36,7 +35,7 @@ def add_message(
"chat_id": chat_id,
"chat_seq": chat_seq,
"role": role,
- "content": limits_module.clamp_message_content(content),
+ "content": limits.clamp_message_content(content),
"model": model,
"thinking": kept_thinking,
"tool_calls": tool_calls,
@@ -90,14 +89,14 @@ def refresh_streaming_message(
if live_run is None:
return False
extra = dict(extra_data)
- kept_thinking, kept_segments = limits_module.clamp_thinking(thinking, extra.get("segments"))
+ kept_thinking, kept_segments = limits.clamp_thinking(thinking, extra.get("segments"))
if "segments" in extra:
extra["segments"] = kept_segments
result = self.db.execute(
update(ChatMessage)
.where(ChatMessage.message_id == message_id)
.values(
- content=limits_module.clamp_message_content(content),
+ content=limits.clamp_message_content(content),
thinking=_strip_nul(kept_thinking),
tool_calls=_strip_nul(tool_calls),
model_steps=_strip_nul(model_steps),
@@ -135,9 +134,9 @@ def upsert_message(
"""
existing = self.message_repo.get_by_id(message_id)
if existing is not None:
- update: Dict[str, Any] = {"content": limits_module.clamp_message_content(content)}
+ update: Dict[str, Any] = {"content": limits.clamp_message_content(content)}
extra = dict(extra_data) if extra_data is not None else None
- kept_thinking, kept_segments = limits_module.clamp_thinking(
+ kept_thinking, kept_segments = limits.clamp_thinking(
thinking, (extra or {}).get("segments")
)
if model is not None:
@@ -170,7 +169,7 @@ def upsert_message(
checkpoint = self.db.get(ChatMessage, state.active_checkpoint_id)
manifest = ((checkpoint.extra_data or {}).get("replacement_manifest") or {}) if checkpoint else {}
captured = (extra or {}).get("context_usage", {}).get("captured_at_ms", 0)
- checkpoint_ms = int(service_time_module.as_utc(checkpoint.created_at).timestamp() * 1000) if checkpoint else 0
+ checkpoint_ms = int(as_utc(checkpoint.created_at).timestamp() * 1000) if checkpoint else 0
if (manifest.get("phase") == "mid_turn" and type(captured) is int
and captured > checkpoint_ms):
self._invalidate_compaction_lineage_for_rewrite(
diff --git a/src/backend/core/chat/mode_seed.py b/src/backend/core/chat/mode_seed.py
index c6004080..6a90527f 100644
--- a/src/backend/core/chat/mode_seed.py
+++ b/src/backend/core/chat/mode_seed.py
@@ -1,11 +1,12 @@
-"""mode seed responsibilities."""
+"""Seed values for the built-in turbo chat mode."""
from __future__ import annotations
from typing import Any, Dict
from core.db.models.chat_mode import TOOL_SCOPE_RESTRICTED
import logging
-logger = logging.getLogger(__name__)
from core.chat import mode_types
+logger = logging.getLogger(__name__)
+
def turbo_seed_fields() -> Dict[str, Any]:
"""极速模式内置行的字段,取自历史 ``turbo.*`` SystemConfig 键。
diff --git a/src/backend/core/chat/mode_types.py b/src/backend/core/chat/mode_types.py
index 9796fcbb..9920e3cb 100644
--- a/src/backend/core/chat/mode_types.py
+++ b/src/backend/core/chat/mode_types.py
@@ -1,4 +1,4 @@
-"""mode types responsibilities."""
+"""The runtime contract a chat mode hands to agent assembly."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Tuple
diff --git a/src/backend/core/chat/mode_validation.py b/src/backend/core/chat/mode_validation.py
index 2d3c69bd..3a56c337 100644
--- a/src/backend/core/chat/mode_validation.py
+++ b/src/backend/core/chat/mode_validation.py
@@ -1,4 +1,4 @@
-"""mode validation responsibilities."""
+"""Normalise and validate chat mode fields from request bodies."""
from __future__ import annotations
from core.infra.time import utc_now
import uuid
diff --git a/src/backend/core/chat/modes.py b/src/backend/core/chat/modes.py
index a2ef5691..3d346d1b 100644
--- a/src/backend/core/chat/modes.py
+++ b/src/backend/core/chat/modes.py
@@ -1,6 +1,6 @@
-"""modes responsibilities."""
+"""Create, edit and resolve chat modes, including marketplace skill bindings."""
from __future__ import annotations
-from core.llm import prompt_kinds as prompt_kinds_module
+from core.llm import prompt_kinds
from core.infra.time import utc_now
import uuid
from typing import Any, Dict, List, Optional, Tuple
@@ -9,11 +9,15 @@
from sqlalchemy import or_
from sqlalchemy.orm import Session
import logging
-logger = logging.getLogger(__name__)
from core.chat import mode_seed
from core.chat import mode_types
from core.chat import mode_validation
-from core.chat import modes_state
+
+logger = logging.getLogger(__name__)
+
+
+MARKET_PREFIX = "market:"
+
class ChatModeService:
def __init__(self, db: Session):
@@ -186,7 +190,6 @@ def _owned_row(self, mode_id: str, owner_user_id: Optional[str]) -> ChatMode:
return row
-
# ── 可选能力清单 ────────────────────────────────────────────────────
def available_capabilities(self, owner_user_id: Optional[str]) -> Dict[str, Any]:
@@ -294,17 +297,17 @@ def available_capabilities(self, owner_user_id: Optional[str]) -> Dict[str, Any]
# 提示词 tab 清单:只发给管理端(owner=None)。普通用户看不到「提示词管理」,
# 给他一个分类下拉等于让他绑一个自己既看不到内容、也改不了正文的黑盒——
# 私有模式的提示词只有手写正文一条路。
- prompt_kinds: List[Dict[str, Any]] = []
+ kinds: List[Dict[str, Any]] = []
if owner_user_id is None:
try:
from core.llm import prompt_versions as _pvs
- prompt_kinds = prompt_kinds_module.all_kinds(self.db)
+ kinds = prompt_kinds.all_kinds(self.db)
except Exception: # noqa: BLE001
logger.debug("[chat_mode] 提示词 kind 清单读取失败", exc_info=True)
return {
- "prompt_kinds": prompt_kinds,
+ "prompt_kinds": kinds,
"mcp": resources.get("mcp_servers") or [],
"skills": skills,
"plugins": resources.get("plugins") or [],
@@ -339,7 +342,7 @@ def _market_options(
continue
skills.append(
{
- "id": f"{modes_state.MARKET_PREFIX}{slug}",
+ "id": f"{MARKET_PREFIX}{slug}",
"name": item.get("display_name") or slug,
"description": item.get("summary") or "",
"source": "market",
@@ -356,7 +359,7 @@ def _market_options(
continue
agents.append(
{
- "id": f"{modes_state.MARKET_PREFIX}{slug}",
+ "id": f"{MARKET_PREFIX}{slug}",
"name": item.get("display_name") or item.get("name") or slug,
"description": item.get("summary") or item.get("description") or "",
"source": "market",
@@ -376,7 +379,7 @@ def _market_options(
continue
plugins.append(
{
- "id": f"{modes_state.MARKET_PREFIX}{slug}",
+ "id": f"{MARKET_PREFIX}{slug}",
"name": item.get("name") or slug,
"description": item.get("description") or item.get("summary") or "",
"source": "market",
@@ -400,15 +403,15 @@ def _install_market_refs(
("plugin_ids", "plugin"),
):
ids = out.get(key)
- if not isinstance(ids, list) or not any(str(i).startswith(modes_state.MARKET_PREFIX) for i in ids):
+ if not isinstance(ids, list) or not any(str(i).startswith(MARKET_PREFIX) for i in ids):
continue
resolved: List[str] = []
for raw in ids:
sid = str(raw)
- if not sid.startswith(modes_state.MARKET_PREFIX):
+ if not sid.startswith(MARKET_PREFIX):
resolved.append(sid)
continue
- slug = sid[len(modes_state.MARKET_PREFIX):]
+ slug = sid[len(MARKET_PREFIX):]
try:
if installer == "skill":
from core.harness.registry.marketplace_catalog import marketplace_catalog
diff --git a/src/backend/core/chat/modes_state.py b/src/backend/core/chat/modes_state.py
deleted file mode 100644
index 1989e823..00000000
--- a/src/backend/core/chat/modes_state.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""modes state responsibilities."""
-from __future__ import annotations
-
-
-MARKET_PREFIX = "market:"
diff --git a/src/backend/core/chat/operations/admission.py b/src/backend/core/chat/operations/admission.py
index 15d49a92..0e3e3e32 100644
--- a/src/backend/core/chat/operations/admission.py
+++ b/src/backend/core/chat/operations/admission.py
@@ -17,6 +17,7 @@
from core.chat.schemas import ChatRequest
from core.infra.logging import get_logger, trace_id_var
from core.chat.sequencer import ChatBusyError, ChatSequencer
+from core.db.threads import run_in_session
from fastapi import HTTPException
from sqlalchemy.orm import Session
@@ -72,10 +73,9 @@ def _link_attachments(db: Session, request: ChatRequest, user_id: str) -> None:
db.commit()
-async def start_response_run(
- request: ChatRequest, user: UserContext, db: Session
-) -> StartedResponse:
- from core.auth.agent_api import begin_agent_api_call, bind_agent_api_run, fail_agent_api_call, prepare_agent_api_request
+def _prepare(db: Session, request: ChatRequest, user: UserContext):
+ """Apply the credential's scope; open the agent-API call log; resolve the user."""
+ from core.auth.agent_api import begin_agent_api_call, prepare_agent_api_request
# Scope and API-session provenance come only from the authenticated key.
# prepare logs its own rejected scoped requests before it raises.
@@ -92,144 +92,207 @@ async def start_response_run(
if scope
else None
)
- try:
- chat_session_context.ensure_main_model_configured()
- user_id = chat_context.resolve_db_user_id(
- db, chat_session_context.authenticated_user_id(user)
+ return request, scope, call_id
+
+
+def _resolve_user(db: Session, user: UserContext) -> str:
+ chat_session_context.ensure_main_model_configured()
+ return chat_context.resolve_db_user_id(db, chat_session_context.authenticated_user_id(user))
+
+
+def _resolve_targets(db: Session, request: ChatRequest, user_id: str, scope):
+ """Target agent, explicit capability invocation, effective message and model."""
+ if scope:
+ # Admission already verified this exact DB-backed agent. Never
+ # enumerate the owner's catalog or parse delegation commands here.
+ from core.db.models import UserAgent
+
+ agent = db.get(UserAgent, scope["agent_id"])
+ if agent is None:
+ raise HTTPException(status_code=403, detail={"code": "agent_unavailable"})
+ agent_name = agent.name
+ request._resolved_agent_profile = "local"
+ execution_message, command = request.message, None
+ else:
+ request, agent_name, execution_message, command = (
+ chat_agent_targets._resolve_chat_agent_targets(db, request, user_id)
)
- from core.harness.registry.evaluation import authorize
+ request = chat_invocation.resolve_explicit_capability_invocation(db, request, user_id)
+ message = chat_context.build_effective_user_message(
+ execution_message,
+ request.quoted_follow_up,
+ chat_request_context._resolve_reference_block(db, request, user_id),
+ )
+ provider_id = chat_session_context.resolve_selected_model_provider_id(db, request, user_id)
+ model_name = chat_session_context.resolve_actual_chat_model_name(request, provider_id)
+ return request, agent_name, command, message, provider_id, model_name
- await authorize(request, user_id, agent_scoped=bool(scope))
- if scope:
- # Admission already verified this exact DB-backed agent. Never
- # enumerate the owner's catalog or parse delegation commands here.
- from core.db.models import UserAgent
-
- agent = db.get(UserAgent, scope["agent_id"])
- if agent is None:
- raise HTTPException(status_code=403, detail={"code": "agent_unavailable"})
- agent_name = agent.name
- request._resolved_agent_profile = "local"
- execution_message, command = request.message, None
- else:
- request, agent_name, execution_message, command = (
- chat_agent_targets._resolve_chat_agent_targets(db, request, user_id)
- )
- request = chat_invocation.resolve_explicit_capability_invocation(db, request, user_id)
- message = chat_context.build_effective_user_message(
- execution_message,
- request.quoted_follow_up,
- chat_request_context._resolve_reference_block(db, request, user_id),
+
+def _admit(
+ db: Session,
+ request: ChatRequest,
+ user: UserContext,
+ user_id: str,
+ scope,
+ call_id,
+ agent_name,
+ command,
+ provider_id,
+ model_name,
+ enabled,
+ settings,
+):
+ """Build the run context, accept the run and prepare its history in one transaction.
+
+ Returns the accepted run (detached), the user message id, the history and
+ the context. The pending writer is released again if anything here fails.
+ """
+ from core.auth.agent_api import bind_agent_api_run
+
+ skills, agents, mcps = enabled
+ if user.public_chat_visitor:
+ from core.chat.public import visitor_approval_mode
+
+ settings["tool_approval_mode"] = visitor_approval_mode(
+ db, user, settings.get("tool_approval_mode")
)
- provider_id = chat_session_context.resolve_selected_model_provider_id(db, request, user_id)
- model_name = chat_session_context.resolve_actual_chat_model_name(request, provider_id)
- (skills, agents, mcps), settings = await _read_preferences(request, user_id)
+ _check_project(db, request, user_id)
+ service = chat_services_ChatService_module.ChatService(db)
+ chat_session_context._ensure_chat_session(
+ service,
+ request.chat_id,
+ user_id,
+ request.message,
+ agent_id=request.agent_id,
+ agent_name=agent_name,
+ plan_chat=request.plan_chat,
+ workflow_chat=request.workflow_chat,
+ site_chat=request.site_chat,
+ project_id=request.project_id,
+ )
+ context = chat_request_context.build_ctx(
+ request,
+ user_id,
+ skills,
+ agents,
+ mcps,
+ memory_enabled=bool(settings.get("memory_enabled", False)) and not scope,
+ memory_write_enabled=bool(settings.get("memory_write_enabled", False)) and not scope,
+ reranker_enabled=bool(settings.get("reranker_enabled", False)),
+ model_provider_id=provider_id,
+ actual_model_name=model_name,
+ ontology_enabled=bool(settings.get("ontology_enabled", False)) and not scope,
+ ontology_pack_ids=settings.get("ontology_pack_ids") or None,
+ approval_mode=settings.get("tool_approval_mode"),
+ )
+ if user.api_key_project_id:
+ from core.projects.api import make_project_api_scope
+
+ context["project_api_scope"] = make_project_api_scope(user, request.chat_id)
+ payload = request.model_dump(exclude_none=True)
+ if user.api_key_project_id:
+ payload["project_api_scope"] = make_project_api_scope(user, request.chat_id)
+ # Authentication owns provenance; never accept a client-supplied origin.
+ payload["request_origin"] = "api" if user.api_key_id else "cloud"
+ if user.api_key_id:
+ payload["api_key_id"] = str(user.api_key_id)
+ if command:
+ explicit_command = {
+ "agent_id": command.agent_id,
+ "agent_name": command.agent_name,
+ "task": command.task,
+ }
+ context["explicit_subagent_command"] = explicit_command
+ payload["explicit_subagent_command"] = explicit_command
+ if provider_id:
+ payload["model_provider_id"] = provider_id
+ else:
+ payload.pop("model_provider_id", None)
+ if scope:
+ context["agent_api_scope"] = dict(scope)
+ payload["agent_api_scope"] = dict(scope)
+
+ sequencer = ChatSequencer(db)
+ try:
+ accepted = sequencer.accept_main_run(
+ chat_id=request.chat_id,
+ user_id=user_id,
+ user_content=request.message,
+ model=model_name,
+ user_extra_data=chat_request_context._build_user_extra_data(request, provider_id),
+ request_payload=payload,
+ )
+ except ChatBusyError as exc:
+ raise chat_admission.chat_busy_http_exception(exc) from exc
+ try:
if user.public_chat_visitor:
- from core.chat.public import visitor_approval_mode
+ from core.db.models import ChatSession
- settings["tool_approval_mode"] = visitor_approval_mode(
- db, user, settings.get("tool_approval_mode")
- )
- _check_project(db, request, user_id)
- service = chat_services_ChatService_module.ChatService(db)
- chat_session_context._ensure_chat_session(
- service,
- request.chat_id,
- user_id,
- request.message,
- agent_id=request.agent_id,
- agent_name=agent_name,
- plan_chat=request.plan_chat,
- workflow_chat=request.workflow_chat,
- site_chat=request.site_chat,
- project_id=request.project_id,
+ session = db.get(ChatSession, request.chat_id)
+ if session and not (session.extra_data or {}).get("public_chat_title_set"):
+ session.title = request.message.strip().split("\n", 1)[0][:80] or session.title
+ session.extra_data = {
+ **(session.extra_data or {}),
+ "public_chat_title_set": True,
+ }
+ db.flush()
+ # No history or attachment mutation before writer admission.
+ messages = chat_session_context.load_session_messages(service, request.chat_id, user_id)
+ from core.chat.plan_progress import clear_plan_progress
+
+ clear_plan_progress(request.chat_id)
+ _link_attachments(db, request, user_id)
+ if call_id:
+ bind_agent_api_run(db, call_id, accepted.run.run_id)
+ db.commit()
+ except BaseException as exc:
+ db.rollback()
+ sequencer.abandon_pending_run(accepted.run.run_id, reason=str(exc) or type(exc).__name__)
+ raise
+ db.refresh(accepted.run)
+ db.expunge(accepted.run)
+ return accepted.run, accepted.user_message.message_id, messages, context
+
+
+def _abandon(db: Session, run_id: str, reason: str) -> None:
+ ChatSequencer(db).abandon_pending_run(run_id, reason=reason)
+
+
+async def start_response_run(request: ChatRequest, user: UserContext) -> StartedResponse:
+ """Admit one agent response and launch its run.
+
+ Each database phase runs on a session of its own; the evaluation check, the
+ preference reads and the launch wait between them hold no connection.
+ """
+ from core.auth.agent_api import fail_agent_api_call
+ from core.harness.registry.evaluation import authorize
+
+ request, scope, call_id = await run_in_session(_prepare, request, user)
+ try:
+ user_id = await run_in_session(_resolve_user, user)
+ await authorize(request, user_id, agent_scoped=bool(scope))
+ request, agent_name, command, message, provider_id, model_name = await run_in_session(
+ _resolve_targets, request, user_id, scope
)
- context = chat_request_context.build_ctx(
- request,
- user_id,
- skills,
- agents,
- mcps,
- memory_enabled=bool(settings.get("memory_enabled", False)) and not scope,
- memory_write_enabled=bool(settings.get("memory_write_enabled", False)) and not scope,
- reranker_enabled=bool(settings.get("reranker_enabled", False)),
- model_provider_id=provider_id,
- actual_model_name=model_name,
- ontology_enabled=bool(settings.get("ontology_enabled", False)) and not scope,
- ontology_pack_ids=settings.get("ontology_pack_ids") or None,
- approval_mode=settings.get("tool_approval_mode"),
+ enabled, settings = await _read_preferences(request, user_id)
+ accepted_run, user_message_id, messages, context = await run_in_session(
+ _admit, request, user, user_id, scope, call_id, agent_name, command,
+ provider_id, model_name, enabled, settings,
)
- if user.api_key_project_id:
- from core.projects.api import make_project_api_scope
-
- context["project_api_scope"] = make_project_api_scope(user, request.chat_id)
- payload = request.model_dump(exclude_none=True)
- if user.api_key_project_id:
- payload["project_api_scope"] = make_project_api_scope(user, request.chat_id)
- # Authentication owns provenance; never accept a client-supplied origin.
- payload["request_origin"] = "api" if user.api_key_id else "cloud"
- if user.api_key_id:
- payload["api_key_id"] = str(user.api_key_id)
- if command:
- explicit_command = {
- "agent_id": command.agent_id,
- "agent_name": command.agent_name,
- "task": command.task,
- }
- context["explicit_subagent_command"] = explicit_command
- payload["explicit_subagent_command"] = explicit_command
- if provider_id:
- payload["model_provider_id"] = provider_id
- else:
- payload.pop("model_provider_id", None)
- if scope:
- context["agent_api_scope"] = dict(scope)
- payload["agent_api_scope"] = dict(scope)
-
try:
- with ChatSequencer(db).launching_main_run(
+ run = await admission.start_run(
+ accepted_run=accepted_run,
chat_id=request.chat_id,
user_id=user_id,
- user_content=request.message,
- model=model_name,
- user_extra_data=chat_request_context._build_user_extra_data(request, provider_id),
- request_payload=payload,
- ) as accepted:
- if user.public_chat_visitor:
- from core.db.models import ChatSession
-
- session = db.get(ChatSession, request.chat_id)
- if session and not (session.extra_data or {}).get("public_chat_title_set"):
- session.title = (
- request.message.strip().split("\n", 1)[0][:80] or session.title
- )
- session.extra_data = {
- **(session.extra_data or {}),
- "public_chat_title_set": True,
- }
- db.flush()
- # No history or attachment mutation before writer admission.
- messages = chat_session_context.load_session_messages(
- service, request.chat_id, user_id
- )
- from core.chat.plan_progress import clear_plan_progress
-
- clear_plan_progress(request.chat_id)
- _link_attachments(db, request, user_id)
- if call_id:
- bind_agent_api_run(db, call_id, accepted.run.run_id)
- run = await admission.start_run(
- accepted_run=accepted.run,
- chat_id=request.chat_id,
- user_id=user_id,
- session_messages=messages,
- effective_user_message=message,
- raw_user_message=request.message,
- context=context,
- model_name=model_name,
- )
- except ChatBusyError as exc:
- raise chat_admission.chat_busy_http_exception(exc) from exc
+ session_messages=messages,
+ effective_user_message=message,
+ raw_user_message=request.message,
+ context=context,
+ model_name=model_name,
+ )
+ except BaseException as exc:
+ await run_in_session(_abandon, accepted_run.run_id, str(exc) or type(exc).__name__)
+ raise
# Title work is auxiliary: it starts after first-message admission and
# never waits for the agent answer or blocks a successfully launched run.
from core.chat.titles import schedule_first_title
@@ -238,21 +301,17 @@ async def start_response_run(
schedule_first_title(request.chat_id, user_id, run.run_id)
except Exception as title_error:
logger.warning("chat_title_schedule_failed: %s", type(title_error).__name__)
- return StartedResponse(run.run_id, run.message_id, request.chat_id, accepted.user_message.message_id)
+ return StartedResponse(run.run_id, run.message_id, request.chat_id, user_message_id)
except Exception as exc:
if call_id:
status = exc.status_code if isinstance(exc, HTTPException) else 500
# Logs carry a bounded machine code, never raw model/tool errors or secrets.
detail = exc.detail if isinstance(exc, HTTPException) else None
code = detail.get("code") if isinstance(detail, dict) else None
- fail_agent_api_call(db, call_id, status, code or "request_rejected")
+ await run_in_session(fail_agent_api_call, call_id, status, code or "request_rejected")
if isinstance(exc, HTTPException):
raise
logger.exception("agent_response_start_failed", chat_id=request.chat_id)
raise HTTPException(
status_code=500, detail=chat_context.resolve_user_facing_error(exc)
) from exc
- finally:
- # Both HTTP transports must release the request transaction before
- # waiting on a worker that writes the same chat rows.
- chat_session_context.release_request_session(db)
diff --git a/src/backend/core/chat/operations/invocation.py b/src/backend/core/chat/operations/invocation.py
index ce2feeca..8dccd1fd 100644
--- a/src/backend/core/chat/operations/invocation.py
+++ b/src/backend/core/chat/operations/invocation.py
@@ -1,5 +1,4 @@
"""Resolve explicit skill, connector and plugin invocation selections."""
-from core.harness.registry import execution_account as skill_account
from typing import List
@@ -82,7 +81,7 @@ def _ids(raw) -> List[str]:
# authorization both refer to the same package.
if request.skill_id:
from core.db.models import AdminSkill
- from core.capabilities.skills.installation import compute_install_id as compute_install_id
+ from core.capabilities.skills.installation import compute_install_id
private_id = compute_install_id(request.skill_id, user_id)
private_row = (
@@ -117,7 +116,6 @@ def _ids(raw) -> List[str]:
)
from core.harness.registry.device_catalog import owned_identities
from core.harness.registry.run_capabilities import has_run_capabilities
- from core.capabilities.identity_rules import LOCAL_PROFILE
# An owned desktop installation may be missing files or temporarily
# disabled. Retain its selection for an explanatory answer, without
diff --git a/src/backend/core/chat/operations/session_context.py b/src/backend/core/chat/operations/session_context.py
index 23b3b863..19d5da8b 100644
--- a/src/backend/core/chat/operations/session_context.py
+++ b/src/backend/core/chat/operations/session_context.py
@@ -140,30 +140,3 @@ def load_session_messages(
if messages is None:
raise HTTPException(status_code=404, detail=f"Session {chat_id} not found")
return messages
-
-
-def release_request_session(db: Session) -> None:
- """End the request transaction before an SSE response is handed over.
-
- A streaming response outlives its handler: FastAPI keeps the request-scoped
- session (``Depends(get_db)``) open until the stream completes. Anything left
- open here is therefore a transaction — and any row lock in it — held for the
- entire turn. The background run then blocks on its own chat row when it
- commits the reply, so the run cannot finish, so the stream cannot finish, so
- this session is never released: a circular wait that only a client
- disconnect breaks.
-
- Committing rather than rolling back matches what the handler already did on
- every other exit: everything it meant to persist is committed by this point,
- so this only ends the read transaction the history load left behind.
- """
- try:
- db.commit()
- except Exception: # noqa: BLE001 - never turn cleanup into a failed request
- logger.warning("release_request_session_commit_failed", exc_info=True)
- try:
- db.rollback()
- except Exception: # noqa: BLE001
- logger.warning("release_request_session_rollback_failed", exc_info=True)
- finally:
- db.close()
diff --git a/src/backend/core/chat/operations/sessions.py b/src/backend/core/chat/operations/sessions.py
index 238a7da1..16002c18 100644
--- a/src/backend/core/chat/operations/sessions.py
+++ b/src/backend/core/chat/operations/sessions.py
@@ -256,5 +256,7 @@ def delete_chat(chat_id, user=None, db=None):
if not can_delete_session(db, user_id, session):
raise HTTPException(status_code=403, detail="共享会话仅创建者或项目管理员可删")
+ from core.llm.mcp.chat_sessions import invalidate_chat
+ invalidate_chat(chat_id)
chat_service.delete_session_force(chat_id, actor_user_id=user_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
diff --git a/src/backend/core/chat/public.py b/src/backend/core/chat/public.py
index 43b10b56..f429f328 100644
--- a/src/backend/core/chat/public.py
+++ b/src/backend/core/chat/public.py
@@ -7,7 +7,7 @@
from core.db.models import ContentBlock, UserApiKey, UserShadow
from core.infra.crypto import decrypt_secret, encrypt_secret
-from core.auth.api_keys import ApiKeyService, resolve_api_key_identity
+from core.auth.api_keys import ApiKeyService
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
diff --git a/src/backend/core/chat/runtime/chunks.py b/src/backend/core/chat/runtime/chunks.py
index da520a38..544b62a5 100644
--- a/src/backend/core/chat/runtime/chunks.py
+++ b/src/backend/core/chat/runtime/chunks.py
@@ -16,10 +16,11 @@
)
from core.infra.logging import get_logger
-logger = get_logger(__name__)
from core.chat.runtime import completion, snapshots, steering
+logger = get_logger(__name__)
+
async def consume_chunk(turn, chunk):
chunk_type = chunk.get("type")
@@ -78,6 +79,9 @@ async def consume_chunk(turn, chunk):
await turn.emit(build_tool_call_start_event(chunk, turn.chat_id))
elif chunk_type == "tool_call_delta":
await turn.emit(build_tool_call_delta_event(chunk, turn.chat_id))
+ elif chunk_type == "tool_progress":
+ # Live status for a running tool card; the result supersedes it, so it is not persisted.
+ await turn.emit({**chunk, "chat_id": turn.chat_id})
elif chunk_type == "tool_result":
step = chunk.get("model_step")
if isinstance(step, dict):
diff --git a/src/backend/core/chat/runtime/completion.py b/src/backend/core/chat/runtime/completion.py
index 913f8d07..cae41a44 100644
--- a/src/backend/core/chat/runtime/completion.py
+++ b/src/backend/core/chat/runtime/completion.py
@@ -17,10 +17,11 @@
from core.llm.runtime import workspace as _workspace_mod
from core.files.artifacts.service import persist_artifacts as _persist_artifacts
-logger = get_logger(__name__)
from core.chat.runtime import snapshots
+logger = get_logger(__name__)
+
async def complete_turn(turn, chunk):
snapshots._flush_thinking(turn)
diff --git a/src/backend/core/chat/runtime/followups.py b/src/backend/core/chat/runtime/followups.py
index 1a0335e6..07943726 100644
--- a/src/backend/core/chat/runtime/followups.py
+++ b/src/backend/core/chat/runtime/followups.py
@@ -6,7 +6,7 @@
import typing as _typing
from typing import Any, Dict, List, Optional, Tuple
-import core.db.engine as engine
+from core.db.threads import run_in_session
import core.runs.settings as settings
import core.chat.service as services_ChatService_module
from core.infra.logging import get_logger
@@ -28,10 +28,11 @@ async def _bg() -> None:
timeout=10,
)
if questions:
- with engine.SessionLocal() as bg_db:
- services_ChatService_module.ChatService(bg_db).update_message_extra_data(
+ await run_in_session(
+ lambda db: services_ChatService_module.ChatService(db).update_message_extra_data(
msg_id, {"follow_up_questions": questions}
)
+ )
except Exception as exc:
logger.warning("background_followup_failed", error=str(exc))
diff --git a/src/backend/core/chat/runtime/job_wakeup.py b/src/backend/core/chat/runtime/job_wakeup.py
index 358bbd60..b91630d8 100644
--- a/src/backend/core/chat/runtime/job_wakeup.py
+++ b/src/backend/core/chat/runtime/job_wakeup.py
@@ -29,9 +29,9 @@
import core.chat.runtime.admission as admission
from core.chat.plan_progress import load_plan_progress, plan_is_unfinished, render_plan_checklist
-from core.db.engine import SessionLocal
+from core.db.threads import run_db, run_in_session
from core.db.models import Job
-from core.llm.context.ir_state import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
+from core.llm.context.ir_constants import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
from core.llm.context.items import make_text_context_item, session_context_metadata
from sqlalchemy.orm.attributes import flag_modified
@@ -136,6 +136,46 @@ def _progress_prompt(
return "\n".join(lines)
+def _progress_wake_target(db, job_id: str, stats, budget_left, stalled):
+ """``(chat_id, user_id, model_name, prompt)`` when a running job should report, else None."""
+ job = db.query(Job).filter(Job.job_id == job_id).first()
+ if job is None or not job.chat_id:
+ return None
+ if job.status != "running":
+ return None
+ meta = dict(job.extra_data or {})
+ start_params = dict(meta.get("start_params") or {})
+ if not start_params.get("wake_on_finish"):
+ return None # wait=True 的调用方本来就阻塞在那儿等,不需要播报
+ prompt = _progress_prompt(job, stats, budget_left, stalled)
+ return job.chat_id, job.user_id, start_params.get("model_name"), prompt
+
+
+def _claim_finish_wake(db, job_id: str):
+ """Mark a finished job as woken; ``(chat_id, user_id, model_name, prompt)`` or None."""
+ job = db.query(Job).filter(Job.job_id == job_id).with_for_update().first()
+ if job is None or not job.chat_id:
+ return None
+ if job.status not in ("completed", "failed", "cancelled", "interrupted"):
+ return None
+ meta = dict(job.extra_data or {})
+ if meta.get("woken_at"):
+ return None # 已经叫过了
+ start_params = dict(meta.get("start_params") or {})
+ if not start_params.get("wake_on_finish"):
+ return None # 只有 wait=False 提交的作业才需要唤醒
+ from core.jobs.store import JobService
+
+ stats = JobService(db).stats(job_id)
+ prompt = _wake_prompt(job, stats, load_plan_progress(job.chat_id, db))
+ target = (job.chat_id, job.user_id, start_params.get("model_name"), prompt)
+ meta["woken_at"] = True
+ job.extra_data = meta
+ flag_modified(job, "extra_data")
+ db.commit()
+ return target
+
+
async def wake_on_job_progress(
job_id: str, *, stats: Dict[str, Any], budget_left: Dict[str, Any], stalled: bool
) -> bool:
@@ -144,24 +184,15 @@ async def wake_on_job_progress(
三道闸:作业得还在跑、得是 ``wait=False`` 提交的、会话里不能已有在跑的 run。
最后一道最重要——智能体正忙时再入队一轮,只会让两轮互相打架。
"""
- with SessionLocal() as db:
- job = db.query(Job).filter(Job.job_id == job_id).first()
- if job is None or not job.chat_id:
- return False
- if job.status != "running":
- return False
- meta = dict(job.extra_data or {})
- if not dict(meta.get("start_params") or {}).get("wake_on_finish"):
- return False # wait=True 的调用方本来就阻塞在那儿等,不需要播报
- chat_id = job.chat_id
- user_id = job.user_id
- model_name = dict(meta.get("start_params") or {}).get("model_name")
- prompt = _progress_prompt(job, stats, budget_left, stalled)
+ target = await run_in_session(_progress_wake_target, job_id, stats, budget_left, stalled)
+ if target is None:
+ return False
+ chat_id, user_id, model_name, prompt = target
try:
from core.runs.runtime import get_active_run_for_chat
- if get_active_run_for_chat(chat_id, user_id) is not None:
+ if await run_db(get_active_run_for_chat, chat_id, user_id) is not None:
logger.info("[job-wake] skip progress wake, chat busy chat=%s job=%s", chat_id, job_id)
return False
except Exception as fallback_error: # noqa: BLE001 —— 探测失败就当它不忙,宁可多叫一次也别哑掉
@@ -185,31 +216,10 @@ async def wake_on_job_progress(
async def wake_on_job_finish(job_id: str) -> bool:
"""作业终态后叫醒会话。返回是否真的发起了唤醒。"""
- with SessionLocal() as db:
- job = db.query(Job).filter(Job.job_id == job_id).with_for_update().first()
- if job is None or not job.chat_id:
- return False
- if job.status not in ("completed", "failed", "cancelled", "interrupted"):
- return False
- meta = dict(job.extra_data or {})
- if meta.get("woken_at"):
- return False # 已经叫过了
- start_params = dict(meta.get("start_params") or {})
- if not start_params.get("wake_on_finish"):
- return False # 只有 wait=False 提交的作业才需要唤醒
- chat_id = job.chat_id
- user_id = job.user_id
- model_name = start_params.get("model_name")
-
- from core.jobs.store import JobService
-
- stats = JobService(db).stats(job_id)
- prompt = _wake_prompt(job, stats, load_plan_progress(chat_id, db))
-
- meta["woken_at"] = True
- job.extra_data = meta
- flag_modified(job, "extra_data")
- db.commit()
+ target = await run_in_session(_claim_finish_wake, job_id)
+ if target is None:
+ return False
+ chat_id, user_id, model_name, prompt = target
try:
await _enqueue_followup_run(
@@ -226,6 +236,61 @@ async def wake_on_job_finish(job_id: str) -> bool:
return True
+def _accept_wake_run(db, chat_id: str, user_id: str, message: str, wake_kind: str):
+ """Persist the wake message and its accepted run; return what starting it needs."""
+ from core.capabilities.catalog.resolver import resolve_all_runtime_enabled
+ from core.chat.sequencer import ChatSequencer
+ from core.chat.service import ChatService
+ from core.chat.operations.session_context import load_session_messages as _load_session_messages
+
+ chat_service = ChatService(db)
+ enabled = resolve_all_runtime_enabled(db, user_id)
+ # 会话模式要跟着唤醒轮一起走。作业只可能诞生在工作流模式里,而 workflow_mode
+ # 决定了 run_job 注不注册、批量提示词注不注入——唤醒轮丢掉它,模型就会看到
+ # 一条"用 run_job(action='resume') 续跑"的系统消息,却在工具面里找不到
+ # run_job,只能弃用作业、把 N 个工作项拖回主循环逐项重做(成本二次方增长,
+ # 前端也再没有后台作业卡片)。会话元数据里的 workflow_chat 是这件事的真源,
+ # 用户消息路径读的也是它(见 chats.py 的 ctx 组装)。
+ _sess = chat_service.get_session(chat_id, user_id)
+ workflow_chat = bool(dict(getattr(_sess, "extra_data", None) or {}).get("workflow_chat"))
+ # 唤醒消息按 user 角色落库:它要能进历史、能被模型看见,走和普通消息完全一样的链路。
+ # 但它是**写给模型的系统指令**,不是用户说的话——`hidden_in_chat` 让消息列表接口
+ # 把它从聊天记录里滤掉(模型侧的历史另走 load_session_history,不受影响)。
+ # 没有这个标记,用户一刷新页面就会看到「[系统] 进度播报:……请只用一两句话转述」
+ # 这种内部提示词以自己的口吻贴在对话里。
+ wake_item = make_text_context_item(
+ message,
+ item_id=f"harness:job_wakeup:{wake_kind}",
+ kind=KIND_REMINDER,
+ origin=f"harness:job_wakeup:{wake_kind}",
+ trust="system",
+ created_seq=0,
+ priority=950,
+ token_budget=4_000,
+ truncation_policy=POLICY_NEVER,
+ )
+ wake_metadata = session_context_metadata(wake_item)
+ accepted = ChatSequencer(db).accept_main_run(
+ chat_id=chat_id,
+ user_id=user_id,
+ user_content=message,
+ user_extra_data={
+ "system_wake": wake_kind,
+ "hidden_in_chat": True,
+ SESSION_CONTEXT_META_KEY: wake_metadata,
+ },
+ request_payload={"chat_id": chat_id, "message": message, "kind": "job_wakeup"},
+ )
+ session_messages = _load_session_messages(chat_service, chat_id, user_id)
+ return accepted.run, session_messages, workflow_chat, enabled
+
+
+def _abandon_wake_run(db, run_id: str, reason: str) -> None:
+ from core.chat.sequencer import ChatSequencer
+
+ ChatSequencer(db).abandon_pending_run(run_id, reason=reason)
+
+
async def _enqueue_followup_run(
*,
chat_id: str,
@@ -239,56 +304,13 @@ async def _enqueue_followup_run(
这样前端不用任何改动:它本来就会在会话里看到新的一轮(断线还能按 run_id 续播)。
"""
from core.chat.context import build_runtime_context
- from core.capabilities.catalog.resolver import resolve_all_runtime_enabled
- from core.chat.sequencer import ChatSequencer
- from core.chat.service import ChatService
- from core.chat.operations.session_context import load_session_messages as _load_session_messages
accepted_run_id: Optional[str] = None
try:
- with SessionLocal() as db:
- chat_service = ChatService(db)
- skills, agents, mcps = resolve_all_runtime_enabled(db, user_id)
- # 会话模式要跟着唤醒轮一起走。作业只可能诞生在工作流模式里,而 workflow_mode
- # 决定了 run_job 注不注册、批量提示词注不注入——唤醒轮丢掉它,模型就会看到
- # 一条"用 run_job(action='resume') 续跑"的系统消息,却在工具面里找不到
- # run_job,只能弃用作业、把 N 个工作项拖回主循环逐项重做(成本二次方增长,
- # 前端也再没有后台作业卡片)。会话元数据里的 workflow_chat 是这件事的真源,
- # 用户消息路径读的也是它(见 chats.py 的 ctx 组装)。
- _sess = chat_service.get_session(chat_id, user_id)
- workflow_chat = bool(
- dict(getattr(_sess, "extra_data", None) or {}).get("workflow_chat")
- )
- # 唤醒消息按 user 角色落库:它要能进历史、能被模型看见,走和普通消息完全一样的链路。
- # 但它是**写给模型的系统指令**,不是用户说的话——`hidden_in_chat` 让消息列表接口
- # 把它从聊天记录里滤掉(模型侧的历史另走 load_session_history,不受影响)。
- # 没有这个标记,用户一刷新页面就会看到「[系统] 进度播报:……请只用一两句话转述」
- # 这种内部提示词以自己的口吻贴在对话里。
- wake_item = make_text_context_item(
- message,
- item_id=f"harness:job_wakeup:{wake_kind}",
- kind=KIND_REMINDER,
- origin=f"harness:job_wakeup:{wake_kind}",
- trust="system",
- created_seq=0,
- priority=950,
- token_budget=4_000,
- truncation_policy=POLICY_NEVER,
- )
- wake_metadata = session_context_metadata(wake_item)
- accepted = ChatSequencer(db).accept_main_run(
- chat_id=chat_id,
- user_id=user_id,
- user_content=message,
- user_extra_data={
- "system_wake": wake_kind,
- "hidden_in_chat": True,
- SESSION_CONTEXT_META_KEY: wake_metadata,
- },
- request_payload={"chat_id": chat_id, "message": message, "kind": "job_wakeup"},
- )
- accepted_run_id = accepted.run.run_id
- session_messages = _load_session_messages(chat_service, chat_id, user_id)
+ run, session_messages, workflow_chat, (skills, agents, mcps) = await run_in_session(
+ _accept_wake_run, chat_id, user_id, message, wake_kind
+ )
+ accepted_run_id = run.run_id
context = build_runtime_context(
model_name=model_name,
@@ -300,7 +322,7 @@ async def _enqueue_followup_run(
)
context["workflow_chat"] = workflow_chat
await admission.start_run(
- accepted_run=accepted.run,
+ accepted_run=run,
chat_id=chat_id,
user_id=user_id,
session_messages=session_messages,
@@ -311,9 +333,5 @@ async def _enqueue_followup_run(
)
except Exception as exc:
if accepted_run_id:
- with SessionLocal() as cleanup_db:
- ChatSequencer(cleanup_db).abandon_pending_run(
- accepted_run_id,
- reason=str(exc),
- )
+ await run_in_session(_abandon_wake_run, accepted_run_id, str(exc))
raise
diff --git a/src/backend/core/chat/runtime/recovery.py b/src/backend/core/chat/runtime/recovery.py
index 40a1c570..7321d5a1 100644
--- a/src/backend/core/chat/runtime/recovery.py
+++ b/src/backend/core/chat/runtime/recovery.py
@@ -13,6 +13,7 @@
import core.runs.tasks as tasks
from core.infra.logging import get_logger
from core.runs.journal import RecoveryDecision
+from core.db.threads import run_in_session
logger = get_logger(__name__)
@@ -24,7 +25,7 @@ async def recover_orphan_runs() -> int:
# Desktop channel runs require a live device lease and a scoped I/O adapter.
# Neither survives process restart. Fail closed before any tool-effect recovery.
- with engine.SessionLocal() as db:
+ def _orphaned_channel_runs(db):
local_channel_runs = [
(run.run_id, run.chat_id)
for run in db.query(models.ChatRun)
@@ -33,6 +34,8 @@ async def recover_orphan_runs() -> int:
if (run.request_payload or {}).get("source") == "desktop_channel"
and run.run_id not in tasks.process_state().active_runs
]
+ return local_channel_runs
+ local_channel_runs = await run_in_session(_orphaned_channel_runs)
for run_id, chat_id in local_channel_runs:
if ownership.journal().cancel(
run_id, reason="desktop channel lease cannot survive process restart"
diff --git a/src/backend/core/chat/runtime/recovery_snapshot.py b/src/backend/core/chat/runtime/recovery_snapshot.py
index c828cc05..81edd404 100644
--- a/src/backend/core/chat/runtime/recovery_snapshot.py
+++ b/src/backend/core/chat/runtime/recovery_snapshot.py
@@ -6,7 +6,6 @@
from typing import Optional
import core.chat.runtime.handoff as handoff
-import core.db.engine as engine
import core.runs.events.writer as writer
import core.runs.ownership as ownership
import core.runs.runtime as runtime
@@ -14,6 +13,7 @@
import core.chat.service as services_ChatService_module
from core.infra.logging import get_logger
from core.runs.journal import RecoveryDecision
+from core.db.threads import run_in_session
logger = get_logger(__name__)
@@ -35,7 +35,7 @@ async def _commit_recovered_chat_snapshot(decision: RecoveryDecision) -> Optiona
from sqlalchemy.exc import SQLAlchemyError
try:
- with engine.SessionLocal() as db:
+ def _restore_context(db):
restored_context = validate_recovery_context(
db,
recovered_run,
@@ -43,9 +43,11 @@ async def _commit_recovered_chat_snapshot(decision: RecoveryDecision) -> Optiona
user_id=decision.user_id,
chat_id=decision.chat_id,
)
+ return restored_context
+ restored_context = await run_in_session(_restore_context)
worker_args = snapshot.get("worker_args")
if isinstance(worker_args, dict) and "context" in worker_args:
- with engine.SessionLocal() as db:
+ def _check_worker_context(db):
validate_recovery_context(
db,
recovered_run,
@@ -53,6 +55,7 @@ async def _commit_recovered_chat_snapshot(decision: RecoveryDecision) -> Optiona
user_id=decision.user_id,
chat_id=decision.chat_id,
)
+ await run_in_session(_check_worker_context)
except (ValueError, SQLAlchemyError):
paused = journal.needs_attention(
decision.run_id, reason="agent API recovery scope is unavailable or inconsistent"
diff --git a/src/backend/core/chat/runtime/steering.py b/src/backend/core/chat/runtime/steering.py
index fe4aed14..85ac6b62 100644
--- a/src/backend/core/chat/runtime/steering.py
+++ b/src/backend/core/chat/runtime/steering.py
@@ -14,10 +14,11 @@
from core.chat.context import now_iso
from core.infra.logging import get_logger
-logger = get_logger(__name__)
from core.chat.runtime import snapshots
+logger = get_logger(__name__)
+
async def apply_steer(turn, chunk):
snapshots._flush_thinking(turn)
diff --git a/src/backend/core/chat/runtime/worker.py b/src/backend/core/chat/runtime/worker.py
index 2862f114..82259abd 100644
--- a/src/backend/core/chat/runtime/worker.py
+++ b/src/backend/core/chat/runtime/worker.py
@@ -1,7 +1,7 @@
"""Own one chat worker lifecycle and coordinate its persistence phases."""
from __future__ import annotations
-from core.runs.tool_effects import errors as errors_module
+from core.runs.tool_effects import errors
import asyncio
import contextlib
@@ -11,7 +11,7 @@
from typing import Any, Dict, List, Optional
from core.harness.registry.chat_execution import stream_chat_workflow
-import core.db.engine as engine
+from core.db.threads import run_in_session
import core.harness.hooks as hooks
import core.llm.runtime.model_steps as model_steps
import core.runs.clock as clock
@@ -22,14 +22,25 @@
import core.runs.watchdog as watchdog
import core.chat.service as services_ChatService_module
import core.runs.journal as run_journal
-import core.runs.tool_effects.journal as tool_effect_ledger
from core.infra.logging import get_logger
-logger = get_logger(__name__)
from core.chat.runtime import chunks, pauses, snapshots
from core.chat.runtime.turn_state import TurnState
+logger = get_logger(__name__)
+
+
+def _pop_compaction_notice(db, chat_id: str) -> Optional[Dict[str, Any]]:
+ """Consume a pending compaction notice; return the state to show, or None."""
+ from core.chat.compaction.service import pop_compaction_notice
+ from core.chat.compaction.usage_projection import get_chat_context_state
+
+ chat_service = services_ChatService_module.ChatService(db)
+ if not pop_compaction_notice(chat_service, chat_id):
+ return None
+ return get_chat_context_state(chat_service, chat_id)["context_compaction"]
+
async def _run_workflow(
*,
@@ -186,18 +197,8 @@ async def _run_workflow(
# in the background → notify the user once in this turn's first frame.
# Failures are silent and must never affect the main conversation.
try:
- from core.chat.compaction.service import pop_compaction_notice
- from core.chat.compaction.usage_projection import get_chat_context_state
-
- with engine.SessionLocal() as _cn_db:
- _cn_service = services_ChatService_module.ChatService(_cn_db)
- _notify_compaction = pop_compaction_notice(_cn_service, turn.chat_id)
- _compaction_state = (
- get_chat_context_state(_cn_service, turn.chat_id)["context_compaction"]
- if _notify_compaction
- else None
- )
- if _notify_compaction:
+ _compaction_state = await run_in_session(_pop_compaction_notice, turn.chat_id)
+ if _compaction_state is not None:
await turn.emit(
{
"type": "compaction_notice",
@@ -311,7 +312,7 @@ async def _run_workflow(
terminal=True,
)
- except errors_module.ToolOutcomeUnknown as exc:
+ except errors.ToolOutcomeUnknown as exc:
await pauses._pause_for_tool_outcome(turn, exc)
except hooks.HookPaused as exc:
@@ -330,7 +331,7 @@ async def _run_workflow(
if nested_pause is not None:
await pauses._pause_for_hook(turn, nested_pause)
return
- nested_unknown = errors_module.find_tool_outcome_unknown(exc)
+ nested_unknown = errors.find_tool_outcome_unknown(exc)
if nested_unknown is not None:
await pauses._pause_for_tool_outcome(turn, nested_unknown)
return
diff --git a/src/backend/core/chat/sequencer.py b/src/backend/core/chat/sequencer.py
index ba8bd5ee..9aefb707 100644
--- a/src/backend/core/chat/sequencer.py
+++ b/src/backend/core/chat/sequencer.py
@@ -9,7 +9,6 @@
from __future__ import annotations
import uuid
-from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Optional
@@ -82,36 +81,6 @@ def get_active_run(self, chat_id: str) -> Optional[ActiveRunRef]:
return None
return ActiveRunRef(run_id=run.run_id, message_id=run.message_id, status=run.status)
- @contextmanager
- def launching_main_run(
- self,
- *,
- chat_id: str,
- user_id: str,
- user_content: str,
- request_payload: Dict[str, Any],
- model: Optional[str] = None,
- user_extra_data: Optional[Dict[str, Any]] = None,
- ):
- """Admit a main conversation and release its pending writer if launch fails.
-
- Preparation after admission and executor launch belong inside this scope.
- Waiting for the running worker belongs outside it.
- """
- accepted = self.accept_main_run(
- chat_id=chat_id,
- user_id=user_id,
- user_content=user_content,
- request_payload=request_payload,
- model=model,
- user_extra_data=user_extra_data,
- )
- try:
- yield accepted
- except BaseException as exc:
- self.abandon_pending_run(accepted.run.run_id, reason=str(exc) or type(exc).__name__)
- raise
-
def accept_main_run(
self,
*,
diff --git a/src/backend/core/chat/service.py b/src/backend/core/chat/service.py
index cc2faeed..0d9349ea 100644
--- a/src/backend/core/chat/service.py
+++ b/src/backend/core/chat/service.py
@@ -1,30 +1,20 @@
-"""service responsibilities."""
+"""Chat sessions and messages behind one service facade."""
from __future__ import annotations
from core.chat import session_operations
from core.chat import message_writes
from core.chat import message_queries
from core.chat import compaction_snapshots
from core.chat import compaction_checkpoints
-from core.infra.time import utc_now
-import hashlib
-import json
-import uuid
from dataclasses import replace
-from datetime import timedelta
from typing import Any, Dict, List, Optional, Tuple
-from core.auth.edition import ChatAccessLevel, resolve_chat_access
-from core.db.models import ChatCompactionState, ChatMessage, ChatRun, ChatSession
+from core.auth.edition import ChatAccessLevel
+from core.db.models import ChatCompactionState, ChatMessage, ChatSession
from core.db.repository import AuditLogRepository, ChatMessageRepository, ChatSessionRepository
-from core.db.repository.chat import _strip_nul
-from core.db.reserved_chats import reserved_chat_owner
-from sqlalchemy import update
-from core.ontology.revision import is_substantive_revision, normalize_revision_candidate
from sqlalchemy.orm import Session
import logging
+from core.chat import compaction_types
+
logger = logging.getLogger(__name__)
-from core.chat import compaction_types as compaction_types_module
-from core.chat import limits as limits_module
-from core.chat import service_time as service_time_module
class ChatService:
"""Service for chat-related operations."""
@@ -205,7 +195,7 @@ def acquire_compaction_snapshot(
*,
owner: str,
lease_seconds: int,
- ) -> Optional[compaction_types_module.CompactionSnapshot]:
+ ) -> Optional[compaction_types.CompactionSnapshot]:
return compaction_snapshots.acquire_compaction_snapshot(self, chat_id, owner=owner, lease_seconds=lease_seconds)
def _acquire_compaction_snapshot(
@@ -214,7 +204,7 @@ def _acquire_compaction_snapshot(
*,
owner: str,
lease_seconds: int,
- ) -> Optional[compaction_types_module.CompactionSnapshot]:
+ ) -> Optional[compaction_types.CompactionSnapshot]:
return compaction_snapshots.acquire_compaction_snapshot_impl(self, chat_id, owner=owner, lease_seconds=lease_seconds)
def release_compaction_lease(self, chat_id: str, *, owner: str) -> bool:
@@ -222,7 +212,7 @@ def release_compaction_lease(self, chat_id: str, *, owner: str) -> bool:
def commit_compaction_checkpoint(
self,
- snapshot: compaction_types_module.CompactionSnapshot,
+ snapshot: compaction_types.CompactionSnapshot,
*,
owner: str,
summary_text: str,
diff --git a/src/backend/core/chat/service_state.py b/src/backend/core/chat/service_state.py
deleted file mode 100644
index 420907db..00000000
--- a/src/backend/core/chat/service_state.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""service state responsibilities."""
-from __future__ import annotations
-
-
-CONTENT_MAX = 100_000
-
-
-TRUNC_NOTE = "\n\n…(本条回复过长,已截断保存;完整过程见工具调用记录)"
-
-
-THINKING_MAX = 200_000
diff --git a/src/backend/core/chat/service_time.py b/src/backend/core/chat/service_time.py
deleted file mode 100644
index aa394036..00000000
--- a/src/backend/core/chat/service_time.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""service time responsibilities."""
-from __future__ import annotations
-from datetime import datetime, timezone
-from typing import Optional
-
-def utc_now() -> datetime:
- return datetime.now(timezone.utc)
-
-
-def as_utc(value: Optional[datetime]) -> Optional[datetime]:
- if value is None:
- return None
- if value.tzinfo is None:
- return value.replace(tzinfo=timezone.utc)
- return value.astimezone(timezone.utc)
diff --git a/src/backend/core/chat/session_operations.py b/src/backend/core/chat/session_operations.py
index 3cd1156a..c68c6469 100644
--- a/src/backend/core/chat/session_operations.py
+++ b/src/backend/core/chat/session_operations.py
@@ -1,4 +1,4 @@
-"""session operations operations."""
+"""Create, list, update and delete chat sessions, with ownership and sharing checks."""
from __future__ import annotations
import uuid
from typing import Any, Dict, List, Optional, Tuple
diff --git a/src/backend/core/chat/titles.py b/src/backend/core/chat/titles.py
index 9b5b093b..cb1b2d89 100644
--- a/src/backend/core/chat/titles.py
+++ b/src/backend/core/chat/titles.py
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
+from dataclasses import dataclass, field
from core.db import engine as db_engine
from core.db.models import ChatMessage, ChatSession
@@ -10,7 +11,6 @@
logger = logging.getLogger(__name__)
-
def _claim(chat_id: str, user_id: str, run_id: str) -> str | None:
with db_engine.SessionLocal() as db:
row = db.query(ChatSession).filter_by(chat_id=chat_id, user_id=user_id).first()
@@ -110,17 +110,13 @@ def settled(done: asyncio.Task) -> None:
return task
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class TitlesState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- tasks: set[asyncio.Task] = state_field(default_factory=lambda: set())
+ tasks: set[asyncio.Task] = field(default_factory=lambda: set())
-@state_cache(maxsize=1)
-def process_state() -> TitlesState:
- return TitlesState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = TitlesState()
+
+
+def process_state() -> TitlesState:
+ return _STATE
diff --git a/src/backend/core/config/__init__.py b/src/backend/core/config/__init__.py
index 4bb4ed87..35218ba3 100644
--- a/src/backend/core/config/__init__.py
+++ b/src/backend/core/config/__init__.py
@@ -1,6 +1,5 @@
-# ``core.config`` holds static config + ``settings`` only (a clean L1 leaf).
-# The DB-backed config *services* live in ``core.services`` so config no longer
-# imports ``core.db`` at module level:
-# from core.services.model_config import ModelConfigService
+# ``core.config`` holds static config + ``settings`` only and never imports
+# ``core.db`` at module level. DB-backed config services live elsewhere:
+# from core.models.config import ModelConfigService
# from core.config.store.system_config import SystemConfigService
# from core.capabilities.mcp.service import McpServerConfigService
diff --git a/src/backend/core/config/environment.py b/src/backend/core/config/environment.py
index c8fbccae..5f981998 100644
--- a/src/backend/core/config/environment.py
+++ b/src/backend/core/config/environment.py
@@ -8,10 +8,7 @@
from __future__ import annotations
import os
-import tempfile
-from dataclasses import dataclass, field
from pathlib import Path
-from typing import Optional
from dotenv import dotenv_values
diff --git a/src/backend/core/config/identity_settings.py b/src/backend/core/config/identity_settings.py
index 1e81a1f5..50e9e938 100644
--- a/src/backend/core/config/identity_settings.py
+++ b/src/backend/core/config/identity_settings.py
@@ -3,9 +3,8 @@
import os
import tempfile
from dataclasses import dataclass, field
-from pathlib import Path
from typing import Optional
-from .environment import _env, _bool, _int, _float, _REPO_ROOT
+from .environment import _env, _bool, _int
@dataclass(frozen=True)
class AuthSettings:
@@ -135,8 +134,8 @@ class DatabaseSettings:
)
)
echo: bool = field(default_factory=lambda: _bool(_env("DB_ECHO", "false")))
- pool_size: int = field(default_factory=lambda: _int(_env("DB_POOL_SIZE", "20"), 20))
- pool_max_overflow: int = field(
- default_factory=lambda: _int(_env("DB_POOL_MAX_OVERFLOW", "10"), 10)
- )
+ # Threads per process that run blocking work for requests (sync routes and
+ # dependencies, ``core.db.threads.run_db``). The connection pool is derived
+ # from it in ``core.db.engine.thread_budget`` rather than configured apart.
+ worker_threads: int = field(default_factory=lambda: max(1, _int(_env("WORKER_THREADS", "40"), 40)))
pool_timeout: int = field(default_factory=lambda: _int(_env("DB_POOL_TIMEOUT", "30"), 30))
diff --git a/src/backend/core/config/mcp_config.py b/src/backend/core/config/mcp_config.py
index 231b8488..a6281c46 100755
--- a/src/backend/core/config/mcp_config.py
+++ b/src/backend/core/config/mcp_config.py
@@ -22,7 +22,7 @@
def mcp_http_url(server_id: str) -> str:
from core.config.settings import settings
- return f"http://{settings.server.mcp_host}:{_PORTS[server_id]}/mcp/"
+ return f"http://{settings.server.mcp_host}:{_PORTS[server_id]}/mcp"
def builtin_mcp_configs(location=None) -> Dict[str, dict]:
diff --git a/src/backend/core/config/product_settings.py b/src/backend/core/config/product_settings.py
index 50eaffb7..217ea86c 100644
--- a/src/backend/core/config/product_settings.py
+++ b/src/backend/core/config/product_settings.py
@@ -1,11 +1,8 @@
"""Environment-backed configuration values."""
from __future__ import annotations
import os
-import tempfile
from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Optional
-from .environment import _env, _bool, _int, _float, _REPO_ROOT
+from .environment import _env, _bool, _int
@dataclass(frozen=True)
class EditionSettings:
diff --git a/src/backend/core/config/runtime_settings.py b/src/backend/core/config/runtime_settings.py
index e555132d..117efe56 100644
--- a/src/backend/core/config/runtime_settings.py
+++ b/src/backend/core/config/runtime_settings.py
@@ -1,11 +1,9 @@
"""Environment-backed configuration values."""
from __future__ import annotations
import os
-import tempfile
from dataclasses import dataclass, field
from pathlib import Path
-from typing import Optional
-from .environment import _env, _bool, _int, _float, _REPO_ROOT, DEFAULT_CHAT_MODEL_ALIAS, _load_env_files
+from .environment import _env, _bool, _int, _float
@dataclass(frozen=True)
class LLMSettings:
@@ -187,29 +185,6 @@ class ServerSettings:
def is_prod(self) -> bool:
return self.env in ("prod", "production")
-@dataclass(frozen=True)
-class RateLimitSettings:
- enabled: bool = field(default_factory=lambda: _bool(_env("RATE_LIMIT_ENABLED", "true")))
- storage: str = field(default_factory=lambda: _env("RATE_LIMIT_STORAGE", "memory://"))
- cb_user_center_threshold: int = field(
- default_factory=lambda: _int(_env("CB_USER_CENTER_THRESHOLD", "5"), 5)
- )
- cb_user_center_timeout: int = field(
- default_factory=lambda: _int(_env("CB_USER_CENTER_TIMEOUT", "60"), 60)
- )
- cb_model_api_threshold: int = field(
- default_factory=lambda: _int(_env("CB_MODEL_API_THRESHOLD", "10"), 10)
- )
- cb_model_api_timeout: int = field(
- default_factory=lambda: _int(_env("CB_MODEL_API_TIMEOUT", "30"), 30)
- )
- cb_storage_threshold: int = field(
- default_factory=lambda: _int(_env("CB_STORAGE_THRESHOLD", "5"), 5)
- )
- cb_storage_timeout: int = field(
- default_factory=lambda: _int(_env("CB_STORAGE_TIMEOUT", "60"), 60)
- )
-
@dataclass(frozen=True)
class RoutingSettings:
strategy: str = field(
diff --git a/src/backend/core/config/sandbox_settings.py b/src/backend/core/config/sandbox_settings.py
index 92fe0822..d4ab7070 100644
--- a/src/backend/core/config/sandbox_settings.py
+++ b/src/backend/core/config/sandbox_settings.py
@@ -1,11 +1,8 @@
"""Environment-backed configuration values."""
from __future__ import annotations
import os
-import tempfile
from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Optional
-from .environment import _env, _bool, _int, _float, _REPO_ROOT
+from .environment import _env, _bool, _int
@dataclass(frozen=True)
class SandboxSettings:
@@ -118,7 +115,7 @@ class SandboxSettings:
opensandbox_snapshot_wait_timeout_s: int = field(
default_factory=lambda: _int(_env("OPENSANDBOX_SNAPSHOT_WAIT_TIMEOUT_S", "120"), 120)
)
- # Skill dependencies come only from the image bake (docker/Dockerfile.opensandbox); new dependencies go through the admin-panel rebuild.
+ # Skill dependencies come only from the image bake (docker/sandbox/Dockerfile.opensandbox); new dependencies go through the admin-panel rebuild.
# The all-modes code-execution capability switch has moved out of settings:
# the sole control source is sandbox.code_capability_enable in the Config
diff --git a/src/backend/core/config/settings.py b/src/backend/core/config/settings.py
index 5192cde9..cdc8c4b9 100644
--- a/src/backend/core/config/settings.py
+++ b/src/backend/core/config/settings.py
@@ -1,11 +1,8 @@
"""Environment-backed configuration values."""
from __future__ import annotations
import os
-import tempfile
from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Optional
-from .environment import _env as env_value, _bool as env_bool, _int as env_int, _float, _REPO_ROOT, DEFAULT_CHAT_MODEL_ALIAS, _load_env_files
+from .environment import _env as env_value, _bool as env_bool, _int as env_int, DEFAULT_CHAT_MODEL_ALIAS
from .identity_settings import (
AuthSettings,
@@ -21,7 +18,6 @@
KnowledgeBaseSettings,
RedisSettings,
ServerSettings,
- RateLimitSettings,
RoutingSettings,
CompactionSettings,
PromptSettings,
@@ -52,7 +48,6 @@ class AppSettings:
kb: KnowledgeBaseSettings = field(default_factory=KnowledgeBaseSettings)
redis: RedisSettings = field(default_factory=RedisSettings)
server: ServerSettings = field(default_factory=ServerSettings)
- rate_limit: RateLimitSettings = field(default_factory=RateLimitSettings)
routing: RoutingSettings = field(default_factory=RoutingSettings)
compaction: CompactionSettings = field(default_factory=CompactionSettings)
prompt: PromptSettings = field(default_factory=PromptSettings)
diff --git a/src/backend/core/config/store/__init__.py b/src/backend/core/config/store/__init__.py
index f2a2b964..8e4f6e51 100644
--- a/src/backend/core/config/store/__init__.py
+++ b/src/backend/core/config/store/__init__.py
@@ -1 +1 @@
-"""store domain package."""
+"""System configuration stored in the database, with environment fallbacks."""
diff --git a/src/backend/core/config/store/config_keys.py b/src/backend/core/config/store/config_keys.py
index 48ba20f9..695edc32 100644
--- a/src/backend/core/config/store/config_keys.py
+++ b/src/backend/core/config/store/config_keys.py
@@ -1,7 +1,7 @@
-"""config keys responsibilities."""
+"""Map environment variables to the system-config keys that override them."""
from __future__ import annotations
from typing import Optional
-from core.config.store import system_config_state
+from core.config.store import system_config_constants
def get_config_key_for_env(env_var: str) -> Optional[str]:
"""Return the system-config key that backs ``env_var``, if any.
@@ -9,4 +9,4 @@ def get_config_key_for_env(env_var: str) -> Optional[str]:
Public lookup helper so callers (e.g. ``runtime_env``) don't need to import
the internal reverse map.
"""
- return system_config_state.ENV_TO_CONFIG_KEY.get(env_var)
+ return system_config_constants.ENV_TO_CONFIG_KEY.get(env_var)
diff --git a/src/backend/core/config/store/execution_options.py b/src/backend/core/config/store/execution_options.py
index 9294510f..df1a4220 100644
--- a/src/backend/core/config/store/execution_options.py
+++ b/src/backend/core/config/store/execution_options.py
@@ -1,7 +1,7 @@
-"""execution options responsibilities."""
+"""Execution switches read from system config: code capability, turbo mode, planning."""
from __future__ import annotations
from core.config.store import system_config
-from core.config.store import system_config_state
+from core.config.store import system_config_constants
def code_capability_enabled() -> bool:
"""Master switch for the main agent's code execution capability (single source of truth at runtime).
@@ -24,13 +24,13 @@ def turbo_mcp_server_ids() -> frozenset[str]:
(逗号分隔)。刻意不与能力目录(catalog)的启停求交集——极速模式的工具面
是独立的产品契约。配置异常或解析为空时回退到内置检索三件套。
"""
- default = ",".join(sorted(system_config_state.DEFAULT_TURBO_MCP_SERVER_IDS))
+ default = ",".join(sorted(system_config_constants.DEFAULT_TURBO_MCP_SERVER_IDS))
try:
raw = system_config.SystemConfigService.get_instance().get("turbo.mcp_server_ids", default) or default
except Exception: # noqa: BLE001 — conservatively fall back to the built-in trio
- return system_config_state.DEFAULT_TURBO_MCP_SERVER_IDS
+ return system_config_constants.DEFAULT_TURBO_MCP_SERVER_IDS
ids = frozenset(part.strip() for part in str(raw).split(",") if part.strip())
- return ids or system_config_state.DEFAULT_TURBO_MCP_SERVER_IDS
+ return ids or system_config_constants.DEFAULT_TURBO_MCP_SERVER_IDS
def turbo_skill_ids() -> tuple[str, ...]:
diff --git a/src/backend/core/config/store/runtime_env.py b/src/backend/core/config/store/runtime_env.py
index 20cd69d5..07a56cba 100644
--- a/src/backend/core/config/store/runtime_env.py
+++ b/src/backend/core/config/store/runtime_env.py
@@ -10,7 +10,6 @@
import logging
import os
-from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/config/store/system_config.py b/src/backend/core/config/store/system_config.py
index 2a0308fc..0f5fc1e0 100644
--- a/src/backend/core/config/store/system_config.py
+++ b/src/backend/core/config/store/system_config.py
@@ -1,4 +1,4 @@
-"""system config responsibilities."""
+"""Thread-safe service that resolves service configs from the database with env fallback."""
from __future__ import annotations
import os
import threading
@@ -6,8 +6,9 @@
from typing import Optional
from core.harness.registry.system_configuration import system_configuration_store
import logging
+from core.config.store import system_config_constants
+
logger = logging.getLogger(__name__)
-from core.config.store import system_config_state
class SystemConfigService:
"""Thread-safe singleton that resolves service configs from DB with env fallback."""
@@ -37,7 +38,7 @@ def get(self, key: str, default: str | None = None) -> str | None:
if key in self._cache and self._cache[key] is not None:
return self._cache[key]
# env fallback
- env_key = system_config_state.CONFIG_KEY_TO_ENV.get(key)
+ env_key = system_config_constants.CONFIG_KEY_TO_ENV.get(key)
if env_key:
env_val = os.getenv(env_key)
if env_val is not None:
@@ -85,16 +86,15 @@ def invalidate_cache(self) -> None:
def _maybe_refresh(self) -> None:
now = time.monotonic()
- if now - self._cache_ts < system_config_state.CACHE_TTL_SECONDS and self._cache_meta:
+ if now - self._cache_ts < system_config_constants.CACHE_TTL_SECONDS and self._cache_meta:
return
with self._cache_lock:
- if now - self._cache_ts < system_config_state.CACHE_TTL_SECONDS and self._cache_meta:
+ if now - self._cache_ts < system_config_constants.CACHE_TTL_SECONDS and self._cache_meta:
return
self._load_from_db()
self._cache_ts = time.monotonic()
-
def _load_from_db(self) -> None:
try:
values, metadata = system_configuration_store().load()
@@ -113,7 +113,7 @@ def get_service_env_overlay(self) -> dict[str, str]:
Only includes keys that have a non-None value (from DB or env fallback).
"""
overlay: dict[str, str] = {}
- for config_key, env_key in system_config_state.CONFIG_KEY_TO_ENV.items():
+ for config_key, env_key in system_config_constants.CONFIG_KEY_TO_ENV.items():
val = self.get(config_key)
if val is not None:
overlay[env_key] = val
diff --git a/src/backend/core/config/store/system_config_state.py b/src/backend/core/config/store/system_config_constants.py
similarity index 99%
rename from src/backend/core/config/store/system_config_state.py
rename to src/backend/core/config/store/system_config_constants.py
index 44643ec7..698d9458 100644
--- a/src/backend/core/config/store/system_config_state.py
+++ b/src/backend/core/config/store/system_config_constants.py
@@ -1,4 +1,4 @@
-"""system config state responsibilities."""
+"""Cache lifetime and defaults used by the system config service."""
from __future__ import annotations
from core.config.edition import CONFIG_KEY_TO_ENV as EDITION_CONFIG_KEY_TO_ENV
from core.config.edition import SEED_CONFIGS as EDITION_SEED_CONFIGS
diff --git a/src/backend/core/db/__init__.py b/src/backend/core/db/__init__.py
index a8ad7c28..d9283f7e 100644
--- a/src/backend/core/db/__init__.py
+++ b/src/backend/core/db/__init__.py
@@ -1,2 +1,26 @@
-from core.db.engine import get_db, init_db, Base # noqa: F401
-from core.db.models import * # noqa: F401,F403
+"""Database package facade.
+
+Leaf modules (engine, utc_datetime, ...) must stay importable without loading
+every ORM model: edition model extensions import them while ``core.db.models``
+is importing those extensions. Model names resolve lazily on first access.
+"""
+
+import importlib
+import importlib.util
+
+from core.db.engine import Base, get_db, init_db # noqa: F401
+
+
+def __getattr__(name):
+ if name.startswith("__"):
+ raise AttributeError(name)
+ if importlib.util.find_spec(f"{__name__}.{name}") is not None:
+ # ``from core.db import `` must not drag in every model.
+ return importlib.import_module(f"{__name__}.{name}")
+ models = importlib.import_module("core.db.models")
+ try:
+ value = getattr(models, name)
+ except AttributeError:
+ raise AttributeError(f"module 'core.db' has no attribute {name!r}") from None
+ globals()[name] = value
+ return value
diff --git a/src/backend/core/db/chat_search.py b/src/backend/core/db/chat_search.py
index b3b258b3..d1fa1ac9 100644
--- a/src/backend/core/db/chat_search.py
+++ b/src/backend/core/db/chat_search.py
@@ -6,7 +6,7 @@
until after the authorized page has been selected.
"""
-from sqlalchemy import Text, and_, case, func, or_, select, null
+from sqlalchemy import Text, and_, case, or_, select, null
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.sql.functions import FunctionElement
from core.db.reserved_chats import ordinary_chat_filter
diff --git a/src/backend/core/db/edition.py b/src/backend/core/db/edition.py
index 868d2e7e..b258a048 100644
--- a/src/backend/core/db/edition.py
+++ b/src/backend/core/db/edition.py
@@ -2,6 +2,8 @@
from core.db.personal_model_fields import ProjectEditionFields, project_edition_table_args, ArtifactEditionFields, artifact_edition_table_args, UserAgentEditionFields, user_agent_edition_table_args, ChatSessionEditionFields, chat_session_edition_table_args, MarketplaceListingEditionFields, EDITION_MODEL_EXPORTS
from core.db.personal_schema import ce_create_all, ce_reconcile_schema
+EDITION_MODEL_NAMES = ()
+
def personal_artifact_scope_fields() -> dict[str, None]:
return {}
diff --git a/src/backend/core/db/engine.py b/src/backend/core/db/engine.py
index 339781e2..57153719 100644
--- a/src/backend/core/db/engine.py
+++ b/src/backend/core/db/engine.py
@@ -2,6 +2,7 @@
import logging
import os
+from dataclasses import dataclass
from pathlib import Path
from typing import Generator
@@ -32,6 +33,40 @@ def _resolve_database_url() -> str:
return url
+@dataclass(frozen=True)
+class ThreadBudget:
+ """How many threads of one process can hold a database connection at once.
+
+ Database work runs on threads, never on the event loop, and a process has two
+ pools of them: anyio's default limiter (sync routes and dependencies, and
+ ``core.db.threads.run_db``) and the event loop's default executor
+ (``asyncio.to_thread``). The connection pool is one connection per thread of
+ both, plus a little overflow for the few dedicated threads outside them
+ (sub-agent runs, index workers, schedulers), which otherwise queue on
+ ``DB_POOL_TIMEOUT``. A pool larger than this only reserves Postgres
+ connections no thread can use; a smaller one makes threads wait for each
+ other inside a request.
+ """
+
+ request_threads: int
+ background_threads: int
+ overflow: int = 10
+
+ @property
+ def pool_size(self) -> int:
+ return self.request_threads + self.background_threads
+
+
+def thread_budget() -> ThreadBudget:
+ """The one place the per-process thread limits and the pool size come from."""
+ return ThreadBudget(
+ request_threads=settings.db.worker_threads,
+ # CPython's own default for the loop executor, made explicit so the
+ # pool is sized against the number the executor will really use.
+ background_threads=min(32, (os.cpu_count() or 1) + 4),
+ )
+
+
# Database URL from environment variable
DATABASE_URL = _resolve_database_url()
@@ -57,8 +92,8 @@ def _resolve_database_url() -> str:
else:
engine_kwargs["poolclass"] = NullPool
else:
- engine_kwargs["pool_size"] = settings.db.pool_size
- engine_kwargs["max_overflow"] = settings.db.pool_max_overflow
+ engine_kwargs["pool_size"] = thread_budget().pool_size
+ engine_kwargs["max_overflow"] = thread_budget().overflow
engine_kwargs["pool_timeout"] = settings.db.pool_timeout
# 等锁必须有上界:无上界的行锁等待会把持有它的工作线程(或误写成 async 的路由的
# 事件循环)无限期占住,另一侧又要等这条请求收尾才能释放锁,形成互相干等的死结。
diff --git a/src/backend/core/db/models/__init__.py b/src/backend/core/db/models/__init__.py
index c1dd0a24..dc5d9ca0 100644
--- a/src/backend/core/db/models/__init__.py
+++ b/src/backend/core/db/models/__init__.py
@@ -5,11 +5,7 @@
"""
from core.db.engine import Base
-from sqlalchemy import JSON, String
-from sqlalchemy.dialects.postgresql import INET, JSONB
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
+from core.db.models.column_types import INETType, JSONType
from core.db.edition import EDITION_MODEL_EXPORTS
from core.db.models.chat import ChatCompactionState, ChatMessage, ChatSession
@@ -68,9 +64,19 @@
)
from core.db.models.command import CommandTask
from core.db.models.job import JOB_LIVE_STATUSES, JOB_TERMINAL_STATUSES, Job, JobCall, JobItem
-from core.db.edition import edition_model_exports
-_edition_models = edition_model_exports()
-globals().update(_edition_models)
+from core.db.edition import EDITION_MODEL_NAMES, edition_model_exports
+globals().update(edition_model_exports())
+
+
+def __getattr__(name):
+ # Edition models are absent above only when the edition model package
+ # itself started this import; it has finished loading by first access.
+ if name in EDITION_MODEL_NAMES:
+ value = edition_model_exports()[name]
+ globals()[name] = value
+ return value
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
__all__ = [
'ChatMode',
@@ -146,13 +152,10 @@
'OntologyEnforcementEvent',
'OntologyReviewRun',
'OntologyDraft',
- *_edition_models,
+ *EDITION_MODEL_NAMES,
]
-
-
-
from .plugin_resource import PluginResource, PluginResourceTicket, PluginResourceCheckpoint
-__all__ = [name for name in __all__ if name in globals()]
+__all__ = [name for name in __all__ if name in globals() or name in EDITION_MODEL_NAMES]
diff --git a/src/backend/core/db/models/admin.py b/src/backend/core/db/models/admin.py
index 0826e387..20c4fca9 100644
--- a/src/backend/core/db/models/admin.py
+++ b/src/backend/core/db/models/admin.py
@@ -2,29 +2,18 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime, timezone
from core.db.engine import Base
-from core.db.edition import MarketplaceListingEditionFields
from sqlalchemy import (
- JSON,
Boolean,
CheckConstraint,
Column,
- ForeignKey,
Index,
Integer,
- Numeric,
String,
Text,
- UniqueConstraint,
- text,
)
-from sqlalchemy.dialects.postgresql import INET, JSONB
-from sqlalchemy.orm import mapped_column, relationship
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
+from core.db.models.column_types import JSONType
class AdminSkill(Base):
@@ -145,7 +134,6 @@ class AdminMcpServer(Base):
env_vars = Column(JSONType, default=dict)
env_inherit = Column(JSONType, default=list)
headers = Column(JSONType, default=dict)
- is_stable = Column(Boolean, nullable=False, default=True)
is_enabled = Column(Boolean, nullable=False, default=True)
sort_order = Column(Integer, nullable=False, default=0)
extra_config = Column(JSONType, default=dict)
@@ -172,21 +160,6 @@ class AdminMcpServer(Base):
)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
class InstalledPlugin(Base):
"""An installed plugin (bundle) — the single source of truth for uninstall/upgrade.
diff --git a/src/backend/core/db/models/agent.py b/src/backend/core/db/models/agent.py
index 51083f03..b8bbe71d 100644
--- a/src/backend/core/db/models/agent.py
+++ b/src/backend/core/db/models/agent.py
@@ -2,12 +2,10 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime, timezone
from core.db.engine import Base
from core.db.edition import UserAgentEditionFields, user_agent_edition_table_args
from sqlalchemy import (
- JSON,
BigInteger,
Boolean,
CheckConstraint,
@@ -18,13 +16,9 @@
Numeric,
String,
Text,
- UniqueConstraint,
)
-from sqlalchemy.dialects.postgresql import INET, JSONB
-from sqlalchemy.orm import mapped_column, relationship
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
+from sqlalchemy.orm import relationship
+from core.db.models.column_types import JSONType
class UserAgent(UserAgentEditionFields, Base):
@@ -94,9 +88,6 @@ class UserAgent(UserAgentEditionFields, Base):
)
-
-
-
class Plan(Base):
"""Plan mode - plans table"""
diff --git a/src/backend/core/db/models/artifact.py b/src/backend/core/db/models/artifact.py
index 48a8c69e..2a23c8f2 100644
--- a/src/backend/core/db/models/artifact.py
+++ b/src/backend/core/db/models/artifact.py
@@ -2,29 +2,20 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime, timezone
from core.db.engine import Base
from core.db.edition import ArtifactEditionFields, artifact_edition_table_args
from sqlalchemy import (
- JSON,
BigInteger,
- Boolean,
CheckConstraint,
Column,
ForeignKey,
Index,
- Integer,
- Numeric,
String,
Text,
- UniqueConstraint,
)
-from sqlalchemy.dialects.postgresql import INET, JSONB
-from sqlalchemy.orm import mapped_column, relationship
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
+from sqlalchemy.orm import relationship
+from core.db.models.column_types import JSONType
class Artifact(ArtifactEditionFields, Base):
diff --git a/src/backend/core/db/models/chat.py b/src/backend/core/db/models/chat.py
index 0748fd40..36e953d6 100644
--- a/src/backend/core/db/models/chat.py
+++ b/src/backend/core/db/models/chat.py
@@ -1,4 +1,4 @@
-"""chat responsibilities."""
+"""ORM models for chat sessions, messages and compaction state, plus sequence allocation."""
from __future__ import annotations
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
@@ -8,7 +8,7 @@
from sqlalchemy import JSON, BigInteger, Boolean, CheckConstraint, Column, Computed, ForeignKey, Index, Integer, String, Text, UniqueConstraint, event, case, func, select, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import deferred, relationship
-from core.db.models import chat_state as chat_state_module
+from core.db.models.column_types import JSONType
class ChatSession(ChatSessionEditionFields, Base):
"""Chat session table."""
@@ -31,7 +31,7 @@ class ChatSession(ChatSessionEditionFields, Base):
favorite = Column(Boolean, default=False)
archived = Column(Boolean, default=False)
deleted_at = Column(UTCDateTime(timezone=True))
- extra_data = Column("metadata", chat_state_module.JSONType, default={})
+ extra_data = Column("metadata", JSONType, default={})
# Project mode: the chat is mounted on a specific project (NULL = ordinary chat)
project_id = Column(
String(64),
@@ -118,8 +118,8 @@ class ChatMessage(Base):
# 拼进 content 落库的,结果思考把正文顶出 content 的 10 万字上限、截断切掉闭合
# 标签后整段漏成正文。NULL = 老消息,思考仍内联在 content 里。
# 展示顺序不在这里——它记在 ``metadata.segments``(见下)。
- thinking = Column(chat_state_module.JSONType)
- tool_calls = Column(chat_state_module.JSONType)
+ thinking = Column(JSONType)
+ tool_calls = Column(JSONType)
# Nullable derived preview; NULL safely falls back to the canonical result.
from core.db.history_projection import display_default
tool_calls_display = deferred(Column(
@@ -129,15 +129,15 @@ class ChatMessage(Base):
# 模型回放用的规范记录:按发生顺序的每一步(模型响应块 / 工具结果),见
# ``core/llm/runtime/model_steps.py``。上面三列是给界面看的投影,模型上下文从这一列恢复。
# NULL = 该行写入时还没有这份记录,回放退回 segments / 历史摘要。
- model_steps = Column(chat_state_module.JSONType)
- usage = Column(chat_state_module.JSONType)
- error = Column(chat_state_module.JSONType)
+ model_steps = Column(JSONType)
+ usage = Column(JSONType)
+ error = Column(JSONType)
# ``metadata.segments`` 记这条消息的展示顺序,形如
# ``[{"type":"text","text":str} | {"type":"thinking","index":int}
# | {"type":"tool","index":int}]``:顺序在实时流式的那一刻就已确定,原样记下,
# 刷新后照着渲染。正文片段内联,思考与工具卡片按下标引用上面两列,长推理不存两份。
# 缺失 = 段落表上线之前的老消息,展示退化成「正文 + 工具卡片」,不做顺序反推。
- extra_data = Column("metadata", chat_state_module.JSONType, default={})
+ extra_data = Column("metadata", JSONType, default={})
usage_is_history = Column(
Boolean, Computed("coalesce(CAST(metadata ->> 'forked_history' AS BOOLEAN), false)", persisted=True)
)
diff --git a/src/backend/core/db/models/chat_mode.py b/src/backend/core/db/models/chat_mode.py
index 996efc06..6dc193dd 100644
--- a/src/backend/core/db/models/chat_mode.py
+++ b/src/backend/core/db/models/chat_mode.py
@@ -20,11 +20,9 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime
from core.db.engine import Base
from sqlalchemy import (
- JSON,
Boolean,
CheckConstraint,
Column,
@@ -35,9 +33,8 @@
Text,
UniqueConstraint,
)
-from sqlalchemy.dialects.postgresql import JSONB
+from core.db.models.column_types import JSONType
-JSONType = JSON().with_variant(JSONB(), "postgresql")
#: 工具面范围。``all`` = 不额外收窄(标准模式:跟随 catalog 与用户自身授权);
#: ``restricted`` = 只装配本行显式列出的 MCP / 技能 / 插件(极速模式与自定义模式)。
diff --git a/src/backend/core/db/models/chat_resources.py b/src/backend/core/db/models/chat_resources.py
index f9ac9996..dff58a8c 100644
--- a/src/backend/core/db/models/chat_resources.py
+++ b/src/backend/core/db/models/chat_resources.py
@@ -1,18 +1,18 @@
-"""chat resources responsibilities."""
+"""ORM models for message feedback, sandbox snapshots and tool media blobs."""
from __future__ import annotations
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
from datetime import datetime, timezone
from core.db.engine import Base
from sqlalchemy import BigInteger, CheckConstraint, Column, ForeignKey, LargeBinary, Index, Integer, String, Text
-from core.db.models import chat_state as chat_state_module
+from core.db.models.column_types import BigIntPK, JSONType
class MessageFeedback(Base):
"""Message feedback table - stores like/dislike ratings and optional comments."""
__tablename__ = "message_feedback"
- feedback_id = Column(chat_state_module.BigIntPK, primary_key=True, autoincrement=True)
+ feedback_id = Column(BigIntPK, primary_key=True, autoincrement=True)
message_id = Column(
String(64),
ForeignKey("chat_messages.message_id", ondelete="CASCADE"),
@@ -73,7 +73,7 @@ class ChatSandboxSnapshot(Base):
) # created_at + SNAPSHOT_RETENTION_DAYS
size_bytes = Column(BigInteger) # for metrics, nullable
extra = Column(
- "metadata", chat_state_module.JSONType, default=dict
+ "metadata", JSONType, default=dict
) # reserved: image uri / pool kind / notes
__table_args__ = (
diff --git a/src/backend/core/db/models/chat_runs.py b/src/backend/core/db/models/chat_runs.py
index de5d8696..e0afa2f3 100644
--- a/src/backend/core/db/models/chat_runs.py
+++ b/src/backend/core/db/models/chat_runs.py
@@ -1,11 +1,11 @@
-"""chat runs responsibilities."""
+"""ORM models for chat runs, their operation journal and the steering queue."""
from __future__ import annotations
from core.db.utc_datetime import UTCDateTime
from datetime import datetime, timezone
from core.db.engine import Base
from sqlalchemy import BigInteger, CheckConstraint, Column, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import deferred
-from core.db.models import chat_state as chat_state_module
+from core.db.models.column_types import BigIntPK, JSONType
class ChatRun(Base):
"""Chat Run — decouples AI tasks from the HTTP connection lifecycle.
@@ -36,11 +36,11 @@ class ChatRun(Base):
writer_slot = Column(String(20))
status = Column(String(20), nullable=False, default="pending")
request_payload = Column(
- chat_state_module.JSONType
+ JSONType
) # serialized ChatRequest (for the worker to rebuild the context)
last_event_offset = Column(Integer, default=0, nullable=False)
error_message = Column(Text)
- usage = Column(chat_state_module.JSONType)
+ usage = Column(JSONType)
# Durable run-journal state. Redis remains an event projection only; these
# columns decide ownership, recovery and terminal CAS after a restart.
run_phase = Column(String(40), nullable=False, default="accepted")
@@ -50,7 +50,7 @@ class ChatRun(Base):
snapshot_version = Column(Integer, nullable=False, default=0)
# Holds the whole in-flight run payload (megabytes on long chats); loading it
# with every ChatRun row decodes that JSON again, so it is fetched on access.
- recovery_snapshot = deferred(Column(chat_state_module.JSONType))
+ recovery_snapshot = deferred(Column(JSONType))
last_operation_safety = Column(String(40), nullable=False, default="replayable")
failure_reason = Column(Text)
updated_at = Column(
@@ -89,7 +89,7 @@ class ChatRunOperation(Base):
__tablename__ = "chat_run_operations"
- operation_id = Column(chat_state_module.BigIntPK, primary_key=True, autoincrement=True)
+ operation_id = Column(BigIntPK, primary_key=True, autoincrement=True)
run_id = Column(
String(64), ForeignKey("chat_runs.run_id", ondelete="CASCADE"), nullable=False
)
@@ -99,7 +99,7 @@ class ChatRunOperation(Base):
safety = Column(String(40), nullable=False)
owner = Column(String(160))
snapshot_version = Column(Integer, nullable=False, default=0)
- payload = Column(chat_state_module.JSONType)
+ payload = Column(JSONType)
created_at = Column(
UTCDateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
diff --git a/src/backend/core/db/models/chat_state.py b/src/backend/core/db/models/column_types.py
similarity index 62%
rename from src/backend/core/db/models/chat_state.py
rename to src/backend/core/db/models/column_types.py
index 18a637e8..1306a123 100644
--- a/src/backend/core/db/models/chat_state.py
+++ b/src/backend/core/db/models/column_types.py
@@ -1,12 +1,9 @@
-"""chat state responsibilities."""
+"""Portable column types shared by every ORM model: JSONB/INET on PostgreSQL, plain types elsewhere."""
from __future__ import annotations
from sqlalchemy import JSON, BigInteger, Integer, String
from sqlalchemy.dialects.postgresql import INET, JSONB
JSONType = JSON().with_variant(JSONB(), "postgresql")
-
-
INETType = String(45).with_variant(INET(), "postgresql")
-
-
+# SQLite only auto-increments the INTEGER rowid, so a BIGINT key would stay NULL there.
BigIntPK = BigInteger().with_variant(Integer(), "sqlite")
diff --git a/src/backend/core/db/models/config.py b/src/backend/core/db/models/config.py
index 694beb1b..d16b5346 100644
--- a/src/backend/core/db/models/config.py
+++ b/src/backend/core/db/models/config.py
@@ -2,28 +2,20 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime, timezone
from core.db.engine import Base
from sqlalchemy import (
- JSON,
- BigInteger,
Boolean,
CheckConstraint,
Column,
ForeignKey,
Index,
Integer,
- Numeric,
String,
Text,
- UniqueConstraint,
)
-from sqlalchemy.dialects.postgresql import INET, JSONB
-from sqlalchemy.orm import mapped_column, relationship
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
+from sqlalchemy.orm import relationship
+from core.db.models.column_types import JSONType
class ModelProvider(Base):
diff --git a/src/backend/core/db/models/evolution.py b/src/backend/core/db/models/evolution.py
index 2112172e..f27ade76 100644
--- a/src/backend/core/db/models/evolution.py
+++ b/src/backend/core/db/models/evolution.py
@@ -12,12 +12,9 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime
from core.db.engine import Base
from sqlalchemy import (
- JSON,
- BigInteger,
Boolean,
CheckConstraint,
Column,
@@ -28,10 +25,7 @@
Text,
UniqueConstraint,
)
-from sqlalchemy.dialects.postgresql import JSONB
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-BigIntPK = BigInteger().with_variant(Integer(), "sqlite")
+from core.db.models.column_types import JSONType
class EvolutionEpisode(Base):
diff --git a/src/backend/core/db/models/identity.py b/src/backend/core/db/models/identity.py
index 86115a10..865bf8dc 100644
--- a/src/backend/core/db/models/identity.py
+++ b/src/backend/core/db/models/identity.py
@@ -2,29 +2,21 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime, timezone
from core.db.engine import Base
from sqlalchemy import (
- JSON,
- BigInteger,
Boolean,
CheckConstraint,
Column,
ForeignKey,
Index,
- Integer,
- Numeric,
String,
Text,
UniqueConstraint,
text,
)
-from sqlalchemy.dialects.postgresql import INET, JSONB
-from sqlalchemy.orm import mapped_column, relationship
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
+from sqlalchemy.orm import relationship
+from core.db.models.column_types import JSONType
class UserShadow(Base):
@@ -185,18 +177,6 @@ class UserApiKey(Base):
)
-
-
-
-
-
-
-
-
-
-
-
-
# Optional modules own their dependent rows; all cleanups share this transaction.
from core.db.user_cleanup import install_user_cleanup
diff --git a/src/backend/core/db/models/job.py b/src/backend/core/db/models/job.py
index 2c4da1a4..bd2f533f 100644
--- a/src/backend/core/db/models/job.py
+++ b/src/backend/core/db/models/job.py
@@ -18,14 +18,18 @@
from datetime import datetime, timezone
from sqlalchemy import (
- Column, String, Integer, BigInteger, Text, TIMESTAMP,
- ForeignKey, CheckConstraint, Index, JSON
+ Column,
+ String,
+ Integer,
+ BigInteger,
+ Text,
+ ForeignKey,
+ CheckConstraint,
+ Index,
)
-from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from core.db.engine import Base
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
+from core.db.models.column_types import JSONType
def _utcnow() -> datetime:
diff --git a/src/backend/core/db/models/knowledge.py b/src/backend/core/db/models/knowledge.py
index f0850358..17fddd7f 100644
--- a/src/backend/core/db/models/knowledge.py
+++ b/src/backend/core/db/models/knowledge.py
@@ -2,40 +2,20 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime, timezone
from core.db.engine import Base
from sqlalchemy import (
- JSON,
- BigInteger,
Boolean,
CheckConstraint,
Column,
ForeignKey,
Index,
Integer,
- Numeric,
String,
- Text,
UniqueConstraint,
)
-from sqlalchemy.dialects.postgresql import INET, JSONB
-from sqlalchemy.orm import mapped_column, relationship
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
-
-
-
-
-
-
-
-
-
-
-
-
+from sqlalchemy.orm import relationship
+from core.db.models.column_types import JSONType
class CatalogOverride(Base):
diff --git a/src/backend/core/db/models/logs.py b/src/backend/core/db/models/logs.py
index ff4b90de..f88dcf87 100644
--- a/src/backend/core/db/models/logs.py
+++ b/src/backend/core/db/models/logs.py
@@ -6,28 +6,17 @@
from core.db.engine import Base
from sqlalchemy import (
- JSON,
- BigInteger,
Boolean,
CheckConstraint,
Column,
ForeignKey,
Index,
Integer,
- Numeric,
String,
Text,
UniqueConstraint,
)
-from sqlalchemy.dialects.postgresql import INET, JSONB
-from sqlalchemy.orm import mapped_column, relationship
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
-# BigInteger autoincrement PK: SQLite only auto-increments the INTEGER rowid, not
-# BIGINT, so an unqualified BigInteger PK stays NULL on the no-Docker SQLite
-# profile. Fall back to Integer on SQLite (still BIGINT on Postgres).
-BigIntPK = BigInteger().with_variant(Integer(), "sqlite")
+from core.db.models.column_types import JSONType, BigIntPK
class ToolCallLog(Base):
diff --git a/src/backend/core/db/models/memory.py b/src/backend/core/db/models/memory.py
index 35134acf..89c33245 100644
--- a/src/backend/core/db/models/memory.py
+++ b/src/backend/core/db/models/memory.py
@@ -2,32 +2,19 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime, timezone
from core.db.engine import Base
from sqlalchemy import (
- JSON,
- BigInteger,
Boolean,
CheckConstraint,
Column,
- ForeignKey,
Index,
Integer,
- Numeric,
String,
Text,
UniqueConstraint,
)
-from sqlalchemy.dialects.postgresql import INET, JSONB
-from sqlalchemy.orm import mapped_column, relationship
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
-# BigInteger autoincrement PK: SQLite only auto-increments the INTEGER rowid, not
-# BIGINT, so an unqualified BigInteger PK stays NULL on the no-Docker SQLite
-# profile. Fall back to Integer on SQLite (still BIGINT on Postgres).
-BigIntPK = BigInteger().with_variant(Integer(), "sqlite")
+from core.db.models.column_types import JSONType, BigIntPK
class ProfileMemory(Base):
diff --git a/src/backend/core/db/models/ontology.py b/src/backend/core/db/models/ontology.py
index 435d890b..8f85169b 100644
--- a/src/backend/core/db/models/ontology.py
+++ b/src/backend/core/db/models/ontology.py
@@ -2,11 +2,9 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime
from core.db.engine import Base
from sqlalchemy import (
- JSON,
Boolean,
CheckConstraint,
Column,
@@ -18,9 +16,7 @@
UniqueConstraint,
text,
)
-from sqlalchemy.dialects.postgresql import JSONB
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
+from core.db.models.column_types import JSONType
class OntologyPack(Base):
diff --git a/src/backend/core/db/models/project.py b/src/backend/core/db/models/project.py
index fdc81cf7..d001185f 100644
--- a/src/backend/core/db/models/project.py
+++ b/src/backend/core/db/models/project.py
@@ -2,29 +2,19 @@
from core.db.utc_datetime import UTCDateTime
from core.infra.time import utc_now
-from datetime import datetime, timezone
from core.db.engine import Base
from core.db.edition import ProjectEditionFields, project_edition_table_args
from sqlalchemy import (
- JSON,
- BigInteger,
Boolean,
CheckConstraint,
Column,
ForeignKey,
Index,
- Integer,
- Numeric,
String,
Text,
- UniqueConstraint,
)
-from sqlalchemy.dialects.postgresql import INET, JSONB
-from sqlalchemy.orm import mapped_column, relationship
-
-JSONType = JSON().with_variant(JSONB(), "postgresql")
-INETType = String(45).with_variant(INET(), "postgresql")
+from core.db.models.column_types import JSONType
class Project(ProjectEditionFields, Base):
diff --git a/src/backend/core/db/repository/artifact.py b/src/backend/core/db/repository/artifact.py
index 90c2795e..ad0d45b8 100644
--- a/src/backend/core/db/repository/artifact.py
+++ b/src/backend/core/db/repository/artifact.py
@@ -6,7 +6,6 @@
"""
from core.infra.time import utc_now
-import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -120,9 +119,8 @@ def list_by_user_with_chat(
)
if personal_only:
- # Import lazily: ``core.services`` re-exports repositories, so importing
- # this edition seam while the repository package is initializing would
- # create a package-level cycle.
+ # Import lazily: this edition seam must not load while the repository
+ # package is still initializing.
from core.db.artifact_scope import personal_artifact_predicates
query = query.filter(*personal_artifact_predicates(Artifact))
diff --git a/src/backend/core/db/repository/catalog.py b/src/backend/core/db/repository/catalog.py
index d17f4baf..36d222e4 100644
--- a/src/backend/core/db/repository/catalog.py
+++ b/src/backend/core/db/repository/catalog.py
@@ -6,13 +6,10 @@
"""
from core.infra.time import utc_now
-import logging
from datetime import datetime
-from typing import Any, Dict, List, Optional
+from typing import Dict, List, Optional
-import sqlalchemy as sa
from core.db.models import CatalogOverride
-from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.orm import Session
diff --git a/src/backend/core/db/repository/chat.py b/src/backend/core/db/repository/chat.py
index 088af4e2..67ea5b28 100644
--- a/src/backend/core/db/repository/chat.py
+++ b/src/backend/core/db/repository/chat.py
@@ -6,7 +6,6 @@
"""
from core.infra.time import utc_now
-import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -14,7 +13,7 @@
from core.db.reserved_chats import ordinary_chat_filter, reserved_chat_error, reserved_chat_owner
from core.db.models import ChatMessage, ChatSession
from core.db.models.chat import reserve_chat_message_sequences
-from sqlalchemy import and_, desc, func, or_, select
+from sqlalchemy import desc, func, or_
from sqlalchemy.orm import Session
@@ -240,18 +239,6 @@ def count_visible_through_seq(self, chat_id: str, covered_seq: int) -> int:
stable sequence boundary in the visible history without exposing the
checkpoint row or its replacement payload.
"""
- return int(
- self.db.query(ChatMessage)
- .filter(
- ChatMessage.chat_id == chat_id,
- ChatMessage.role != "system",
- ChatMessage.chat_seq <= covered_seq,
- )
- .count()
- )
-
- def count_visible_through_seq(self, chat_id: str, covered_seq: int) -> int:
- """Count user-facing rows at or below an exact compaction watermark."""
return int(
self.db.query(ChatMessage)
.filter(
diff --git a/src/backend/core/db/repository/personal_audit.py b/src/backend/core/db/repository/personal_audit.py
index 98018da9..a82365d6 100644
--- a/src/backend/core/db/repository/personal_audit.py
+++ b/src/backend/core/db/repository/personal_audit.py
@@ -1,6 +1,6 @@
"""No-storage audit repository for the community edition."""
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List
class AuditLogRepository:
diff --git a/src/backend/core/db/repository/user.py b/src/backend/core/db/repository/user.py
index b34f2043..ae2c16ce 100644
--- a/src/backend/core/db/repository/user.py
+++ b/src/backend/core/db/repository/user.py
@@ -6,13 +6,10 @@
"""
from core.infra.time import utc_now
-import logging
from datetime import datetime
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Optional
-import sqlalchemy as sa
from core.db.models import LocalUser, UserShadow
-from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
diff --git a/src/backend/core/db/system_configuration.py b/src/backend/core/db/system_configuration.py
index f21808ca..1e988a97 100644
--- a/src/backend/core/db/system_configuration.py
+++ b/src/backend/core/db/system_configuration.py
@@ -4,7 +4,7 @@
from typing import Optional
from core.db.engine import SessionLocal
from core.db.models import SystemConfig
-from core.config.store import system_config_state
+from core.config.store import system_config_constants
logger = logging.getLogger(__name__)
@@ -62,7 +62,7 @@ def _ensure_seed_rows(self, db) -> None:
existing_keys = set(rows_by_key)
inserted_keys: set[str] = set()
inserted = 0
- for key, val, display, desc, group, secret in system_config_state.SEED_CONFIGS:
+ for key, val, display, desc, group, secret in system_config_constants.SEED_CONFIGS:
if key not in existing_keys:
row = SystemConfig(
config_key=key,
@@ -102,7 +102,7 @@ def _migrate_legacy_knowledge_config(
legacy edits can never overwrite provider-specific values.
"""
migrated = 0
- for field in system_config_state.PROVIDER_SCOPED_KNOWLEDGE_FIELDS:
+ for field in system_config_constants.PROVIDER_SCOPED_KNOWLEDGE_FIELDS:
destination_key = f"knowledge_base.dify.{field}"
if destination_key not in inserted_keys:
continue
diff --git a/src/backend/core/db/threads.py b/src/backend/core/db/threads.py
new file mode 100644
index 00000000..ea16595a
--- /dev/null
+++ b/src/backend/core/db/threads.py
@@ -0,0 +1,99 @@
+"""Run database work off the event loop.
+
+Every query is blocking I/O, so async code never touches a ``Session`` itself:
+it hands a sync function to :func:`run_db` (when it already holds a session,
+such as FastAPI's request session) or to :func:`run_in_session` (which opens and
+closes a session inside the worker thread). Network waits happen between those
+calls, never inside them, so no pooled connection is ever held across an
+``await``.
+
+Both run on anyio's default thread limiter, which :func:`apply_thread_budget`
+sizes from :func:`core.db.engine.thread_budget` — the same number the
+connection pool is derived from.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import functools
+from typing import Any, Awaitable, Callable, TypeVar
+
+import anyio.to_thread
+
+from core.db import engine
+
+T = TypeVar("T")
+
+
+async def run_db(fn: Callable[..., T], /, *args: Any, **kwargs: Any) -> T:
+ """Run blocking database code in a worker thread."""
+ return await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs))
+
+
+def off_loop(fn: Callable[..., T]) -> Callable[..., Awaitable[T]]:
+ """Expose a blocking function as a coroutine function that runs it in a worker thread.
+
+ For interfaces that must be async (agent tools, startup steps, registry
+ hooks) but whose body is plain database work: the wrapper keeps the
+ signature and docstring, so tool schemas built from it are unchanged.
+ """
+
+ @functools.wraps(fn)
+ async def wrapper(*args: Any, **kwargs: Any) -> T:
+ return await run_db(fn, *args, **kwargs)
+
+ return wrapper
+
+
+def _in_session(fn: Callable[..., T], args: tuple, kwargs: dict) -> T:
+ with engine.SessionLocal() as db:
+ return fn(db, *args, **kwargs)
+
+
+async def run_in_session(fn: Callable[..., T], /, *args: Any, **kwargs: Any) -> T:
+ """Run ``fn(db, *args, **kwargs)`` on a fresh session, closed before returning."""
+ return await run_db(_in_session, fn, args, kwargs)
+
+
+def _detached(db, value):
+ if value is not None and value in db:
+ db.refresh(value)
+ db.expunge(value)
+ return value
+
+
+def _call_repository(db, factory, name, args, kwargs):
+ return _detached(db, getattr(factory(db), name)(*args, **kwargs))
+
+
+class AsyncRepository:
+ """Await a session-bound repository's methods, each on a session of its own.
+
+ ``AsyncRepository(Repo)`` exposes ``await repo.method(...)`` for every
+ ``Repo(db).method(...)``; an ORM row it returns comes back loaded and
+ detached, so async services read it without holding a connection.
+ """
+
+ def __init__(self, factory: Callable[[Any], Any]):
+ self._factory = factory
+
+ def __getattr__(self, name: str) -> Callable[..., Awaitable[Any]]:
+ async def call(*args: Any, **kwargs: Any) -> Any:
+ return await run_in_session(_call_repository, self._factory, name, args, kwargs)
+
+ return call
+
+
+def apply_thread_budget() -> None:
+ """Size this process's thread pools to the budget the connection pool assumes.
+
+ Called once per worker on its own event loop, before it serves anything.
+ """
+ budget = engine.thread_budget()
+ anyio.to_thread.current_default_thread_limiter().total_tokens = budget.request_threads
+ asyncio.get_running_loop().set_default_executor(
+ concurrent.futures.ThreadPoolExecutor(
+ max_workers=budget.background_threads, thread_name_prefix="asyncio"
+ )
+ )
diff --git a/src/backend/core/evolution/assessment/__init__.py b/src/backend/core/evolution/assessment/__init__.py
index 21dc619d..c3a8e199 100644
--- a/src/backend/core/evolution/assessment/__init__.py
+++ b/src/backend/core/evolution/assessment/__init__.py
@@ -1 +1 @@
-"""assessment domain package."""
+"""Evaluate evolution candidates: replay benchmarks, metrics and similarity."""
diff --git a/src/backend/core/evolution/assessment/benchmark.py b/src/backend/core/evolution/assessment/benchmark.py
index 7f62622e..936ee3ad 100644
--- a/src/backend/core/evolution/assessment/benchmark.py
+++ b/src/backend/core/evolution/assessment/benchmark.py
@@ -41,7 +41,7 @@
import hashlib
import logging
from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
+from typing import Any, Dict, List, Optional, Sequence, Tuple
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/assessment/experiments.py b/src/backend/core/evolution/assessment/experiments.py
index cc6af77c..21eb841d 100644
--- a/src/backend/core/evolution/assessment/experiments.py
+++ b/src/backend/core/evolution/assessment/experiments.py
@@ -26,7 +26,7 @@
import logging
from dataclasses import dataclass, field
-from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
+from typing import Any, Dict, List, Optional, Sequence, Tuple
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/assessment/fault_injection.py b/src/backend/core/evolution/assessment/fault_injection.py
index 9aff5e9e..6535060c 100644
--- a/src/backend/core/evolution/assessment/fault_injection.py
+++ b/src/backend/core/evolution/assessment/fault_injection.py
@@ -38,7 +38,7 @@
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
-from core.evolution.evidence.credit import ALL_TARGETS, NON_ASSET_TARGETS, T_ENVIRONMENT, T_MEMORY, T_MODEL, T_NO_UPDATE, T_ONTOLOGY, T_RETRIEVAL, T_SKILL, T_ORCHESTRATION, CreditFeatures, assign_credit
+from core.evolution.evidence.credit import NON_ASSET_TARGETS, T_ENVIRONMENT, T_MEMORY, T_MODEL, T_NO_UPDATE, T_ONTOLOGY, T_RETRIEVAL, T_SKILL, T_ORCHESTRATION, CreditFeatures, assign_credit
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/assessment/similarity.py b/src/backend/core/evolution/assessment/similarity.py
index dc260ddc..8e033e94 100644
--- a/src/backend/core/evolution/assessment/similarity.py
+++ b/src/backend/core/evolution/assessment/similarity.py
@@ -30,9 +30,8 @@
import logging
import math
import re
-from collections import defaultdict
from dataclasses import dataclass, field
-from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
+from typing import Any, Dict, List, Optional, Sequence, Tuple
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/backfill.py b/src/backend/core/evolution/backfill.py
index 1f570e69..c831e481 100644
--- a/src/backend/core/evolution/backfill.py
+++ b/src/backend/core/evolution/backfill.py
@@ -154,7 +154,6 @@ def backfill_episodes(
start = end - timedelta(days=days)
stats = {"scanned": 0, "created": 0, "skipped": 0, "batches": 0}
- offset = 0
while True:
if max_batches is not None and stats["batches"] >= max_batches:
diff --git a/src/backend/core/evolution/contract.py b/src/backend/core/evolution/contract.py
index 98d61cac..231117c9 100644
--- a/src/backend/core/evolution/contract.py
+++ b/src/backend/core/evolution/contract.py
@@ -20,7 +20,7 @@
import hashlib
import json
-from dataclasses import asdict, dataclass, field
+from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Tuple
from core.infra.immutable import FrozenDict, freeze_json, thaw_json
diff --git a/src/backend/core/evolution/cycle/__init__.py b/src/backend/core/evolution/cycle/__init__.py
index d4986647..fbdf0bdd 100644
--- a/src/backend/core/evolution/cycle/__init__.py
+++ b/src/backend/core/evolution/cycle/__init__.py
@@ -1 +1 @@
-"""cycle domain package."""
+"""The evolution cycle: mine episodes, propose candidates, evaluate and release them."""
diff --git a/src/backend/core/evolution/cycle/activation.py b/src/backend/core/evolution/cycle/activation.py
index 13f0b74b..b92c3f66 100644
--- a/src/backend/core/evolution/cycle/activation.py
+++ b/src/backend/core/evolution/cycle/activation.py
@@ -1,14 +1,15 @@
-"""activation responsibilities."""
+"""Materialise an approved skill candidate at the release stage it has earned."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from core.db.engine import SessionLocal
import logging
+from core.evolution.cycle import activation_policy
+from core.evolution.cycle import activation_constants
+from core.evolution.cycle import activation_types
+from core.evolution.cycle import skill_document
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import activation_policy as activation_policy_module
-from core.evolution.cycle import activation_state as activation_state_module
-from core.evolution.cycle import activation_types as activation_types_module
-from core.evolution.cycle import skill_document as skill_document_module
def materialise_skill_candidate(
candidate_id: str,
@@ -39,33 +40,33 @@ def materialise_skill_candidate(
"""
from core.db.models import AdminSkill
from core.db.models.evolution import EvolutionCandidate, EvolutionRelease
- from core.evolution.cycle.release import ACTIVE, IN_FLIGHT_STAGES, REPLAY_PASSED, ROLLBACK_MANUAL, new_release_id
+ from core.evolution.cycle.release import ACTIVE, IN_FLIGHT_STAGES, REPLAY_PASSED, new_release_id
with SessionLocal() as db:
candidate = db.get(EvolutionCandidate, candidate_id)
if candidate is None:
- raise activation_types_module.ActivationError("candidate_not_found", f"候选不存在: {candidate_id}")
+ raise activation_types.ActivationError("candidate_not_found", f"候选不存在: {candidate_id}")
if candidate.target_kind != "skill":
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"unsupported_kind", f"暂不支持物化 {candidate.target_kind} 候选"
)
# Separation of duties. No force path — see the docstring.
if approver and approver == (candidate.proposer or ""):
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"self_approval_forbidden", "生成者不能激活自己提出的候选"
)
if not approver:
- raise activation_types_module.ActivationError("approver_required", "激活必须署名")
+ raise activation_types.ActivationError("approver_required", "激活必须署名")
- stage, traffic = activation_policy_module.entry_stage(
+ stage, traffic = activation_policy.entry_stage(
candidate, approver=approver, force=force, owner_user_id=owner_user_id
)
# Pace and freeze apply to anything that becomes live immediately. A
# shadow entry changes nothing users can see, so holding it back would
# only delay measurement.
if stage == ACTIVE and not owner_user_id:
- activation_policy_module.check_pace(db, tenant_id=tenant_id)
+ activation_policy.check_pace(db, tenant_id=tenant_id)
ir = candidate.ir or {}
changes = ir.get("changes") or []
@@ -96,14 +97,14 @@ def materialise_skill_candidate(
# 图谱证据自相矛盾的候选可以存在、可以进 shadow 观测,但不允许自动
# 走到 ACTIVE:冲突意味着「适用条件」本身不可信,必须先由人裁决。
if manifest and manifest.get("graph_conflict") and stage == ACTIVE:
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"graph_conflict_requires_review",
f"L3 图谱证据存在冲突,禁止直接激活:{manifest.get('graph_conflicts')}",
)
skill_id = candidate.target_asset_id
- if not skill_id.startswith(activation_state_module.EVOLUTION_PREFIX):
- skill_id = activation_state_module.EVOLUTION_PREFIX + skill_id.replace("auto-", "")
+ if not skill_id.startswith(activation_constants.EVOLUTION_PREFIX):
+ skill_id = activation_constants.EVOLUTION_PREFIX + skill_id.replace("auto-", "")
if owner_user_id:
# Distinct id per owner: a personal activation must never overwrite
# the global asset, nor another user's copy of the same candidate.
@@ -121,7 +122,7 @@ def materialise_skill_candidate(
tools = [str(t) for t in (document.get("allowed_tools") or [])]
display_name = str(document.get("display_name") or "")
description = str(document.get("description") or "")
- body = skill_document_module.body_of(str(document.get("content") or ""))
+ body = skill_document.body_of(str(document.get("content") or ""))
violations = validate_draft(
skill_id=skill_id,
display_name=display_name,
@@ -130,7 +131,7 @@ def materialise_skill_candidate(
allowed_tools=tools,
)
if violations:
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"draft_failed_validation",
f"技能正文未通过机器校验,拒绝物化: {violations}",
)
@@ -150,14 +151,14 @@ def materialise_skill_candidate(
# ordering recurring across different tool sets is not.
rules_only = not tools and bool(constraints)
if rules_only:
- tools = skill_document_module.tools_from_constraints(constraints)
+ tools = skill_document.tools_from_constraints(constraints)
if not tools:
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"no_tool_sequence", "候选既未声明工具顺序、也未携带顺序约束,无法物化"
)
- display_name = skill_document_module.skill_label(tools, rules_only=rules_only)
- description = skill_document_module.skill_description(tools, constraints, rules_only=rules_only)
- content = skill_document_module.skill_markdown(
+ display_name = skill_document.skill_label(tools, rules_only=rules_only)
+ description = skill_document.skill_description(tools, constraints, rules_only=rules_only)
+ content = skill_document.skill_markdown(
skill_id=skill_id,
label=display_name,
description=description,
@@ -168,7 +169,7 @@ def materialise_skill_candidate(
rules_only=rules_only,
)
- previous = activation_policy_module.capture_previous(db, skill_id)
+ previous = activation_policy.capture_previous(db, skill_id)
# One release in flight per asset. Two concurrent ramps on the same skill
# make any measured effect unattributable and leave rollback without a
@@ -182,7 +183,7 @@ def materialise_skill_candidate(
.first()
)
if conflicting is not None:
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"release_in_flight",
f"该资产已有进行中的发布 {conflicting.release_id}({conflicting.stage})",
)
@@ -196,20 +197,20 @@ def materialise_skill_candidate(
row.skill_content = content
row.display_name = display_name
row.description = description
- row.version = activation_policy_module.next_version(previous)
- row.tags = sorted(set((previous or {}).get("tags", [])) | {activation_state_module.EVOLUTION_TAG})
+ row.version = activation_policy.next_version(previous)
+ row.tags = sorted(set((previous or {}).get("tags", [])) | {activation_constants.EVOLUTION_TAG})
# The distilled ordering, in the shape the runtime reads.
extra_files = {
**(previous or {}).get("extra_files", {}),
- "tool_sequence.json": activation_policy_module.json(tools),
+ "tool_sequence.json": activation_policy.json(tools),
# The transferable part. A consumer that only understands sequences
# still works; one that understands rules gets the wider benefit.
- "ordering_constraints.json": activation_policy_module.json(constraints),
+ "ordering_constraints.json": activation_policy.json(constraints),
}
if manifest:
# 机器读 manifest(边界/依赖/证据引用),Agent 读 SKILL.md —— 两者
# 职责分离。作用域以本次激活实际发生的为准,而不是候选申报的。
- extra_files["evolution_manifest.json"] = activation_policy_module.json(
+ extra_files["evolution_manifest.json"] = activation_policy.json(
{
**manifest,
"scope": "personal" if owner_user_id else str(
diff --git a/src/backend/core/evolution/cycle/activation_state.py b/src/backend/core/evolution/cycle/activation_constants.py
similarity index 67%
rename from src/backend/core/evolution/cycle/activation_state.py
rename to src/backend/core/evolution/cycle/activation_constants.py
index b0a236eb..1831d53a 100644
--- a/src/backend/core/evolution/cycle/activation_state.py
+++ b/src/backend/core/evolution/cycle/activation_constants.py
@@ -1,4 +1,4 @@
-"""activation state responsibilities."""
+"""Tags and config keys that mark evolution-generated assets."""
from __future__ import annotations
diff --git a/src/backend/core/evolution/cycle/activation_policy.py b/src/backend/core/evolution/cycle/activation_policy.py
index 3bc3c955..c04bec0a 100644
--- a/src/backend/core/evolution/cycle/activation_policy.py
+++ b/src/backend/core/evolution/cycle/activation_policy.py
@@ -1,9 +1,9 @@
-"""activation policy responsibilities."""
+"""Gates on activation: freeze switch, pacing, entry stage and rollback snapshots."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
-from core.evolution.cycle import activation_state as activation_state_module
-from core.evolution.cycle import activation_types as activation_types_module
+from core.evolution.cycle import activation_constants
+from core.evolution.cycle import activation_types
def capture_previous(db, skill_id: str) -> Optional[Dict[str, Any]]:
"""Snapshot the current state so rollback has a concrete target."""
@@ -36,7 +36,7 @@ def is_frozen(db) -> bool:
row = (
db.query(SystemConfig.config_value)
- .filter(SystemConfig.config_key == activation_state_module.FREEZE_CONFIG_KEY)
+ .filter(SystemConfig.config_key == activation_constants.FREEZE_CONFIG_KEY)
.first()
)
return bool(row) and str(row[0]).strip().lower() in ("1", "true", "yes", "on")
@@ -93,7 +93,7 @@ def check_pace(db, *, tenant_id: str, layer: str = "skill") -> None:
layer, tenant=tenant_id or "default", concurrent_activations=in_flight
)
if not allowed:
- raise activation_types_module.ActivationError("pace_limited", f"节奏闸门拒绝本次激活:{reason}")
+ raise activation_types.ActivationError("pace_limited", f"节奏闸门拒绝本次激活:{reason}")
def entry_stage(
@@ -126,12 +126,12 @@ def entry_stage(
# verification requirement is replay alone. Waiving it for a tier that
# also owes shadow and canary is not a shortcut, it is the whole gate.
if not force:
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"verification_incomplete",
f"候选尚未通过回放(当前 {candidate.status})",
)
if risk != RISK_LOW:
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"force_forbidden_for_risk",
f"force 只允许用于 low 风险候选,本候选为 {risk};"
f"需完成 {list(required_verification(risk))}",
@@ -182,6 +182,8 @@ def source_candidate_for(db, skill_id: str) -> str:
Memory demotions are recorded against that candidate, so it is the key that
finds them again when the skill is retired.
"""
+ from core.db.models.evolution import EvolutionRelease
+
row = (
db.query(EvolutionRelease)
.filter(
diff --git a/src/backend/core/evolution/cycle/activation_types.py b/src/backend/core/evolution/cycle/activation_types.py
index 3f06cbec..f9f6f31a 100644
--- a/src/backend/core/evolution/cycle/activation_types.py
+++ b/src/backend/core/evolution/cycle/activation_types.py
@@ -1,4 +1,4 @@
-"""activation types responsibilities."""
+"""Error raised when a candidate cannot be activated."""
from __future__ import annotations
diff --git a/src/backend/core/evolution/cycle/candidate_records.py b/src/backend/core/evolution/cycle/candidate_records.py
index 7112eb09..ee136888 100644
--- a/src/backend/core/evolution/cycle/candidate_records.py
+++ b/src/backend/core/evolution/cycle/candidate_records.py
@@ -1,7 +1,7 @@
-"""candidate records responsibilities."""
+"""Persist candidates and derive their attribution features."""
from __future__ import annotations
-from core.evolution.cycle import patterns as patterns_module
-from core.evolution.cycle import promotion_state as promotion_state_module
+from core.evolution.cycle import patterns
+from core.evolution.cycle import promotion_constants
from typing import Any, Dict, List, Optional
from core.db.engine import SessionLocal
from core.db.models.evolution import EvolutionCandidate
@@ -11,7 +11,7 @@
import logging
logger = logging.getLogger(__name__)
-def features_for(pattern: patterns_module.Pattern) -> CreditFeatures:
+def features_for(pattern: patterns.Pattern) -> CreditFeatures:
"""Translate a discovered pattern into attribution features.
Success-subsequence tool patterns no longer come through here: they are an
@@ -19,7 +19,7 @@ def features_for(pattern: patterns_module.Pattern) -> CreditFeatures:
verdict to route them through the failure attributor is the exact move
:func:`core.evolution.evidence.credit.aggregate_finding` exists to replace.
"""
- if pattern.kind == promotion_state_module.PATTERN_REPEATED_FAILURE:
+ if pattern.kind == promotion_constants.PATTERN_REPEATED_FAILURE:
return CreditFeatures(
outcome_verdict="failed",
outcome_confidence=0.8,
diff --git a/src/backend/core/evolution/cycle/composition_candidates.py b/src/backend/core/evolution/cycle/composition_candidates.py
index 3de39116..c3793835 100644
--- a/src/backend/core/evolution/cycle/composition_candidates.py
+++ b/src/backend/core/evolution/cycle/composition_candidates.py
@@ -1,11 +1,12 @@
-"""composition candidates responsibilities."""
+"""Retirement, merge, profile and sub-agent candidates."""
from __future__ import annotations
from typing import Any, Dict, List
from core.evolution.evidence.credit import T_ORCHESTRATION, T_SKILL, aggregate_finding, confidence_from_evidence
from core.evolution.ir import RISK_HIGH, RISK_MEDIUM, EvolutionIR, InvariantViolation, Rollback, validate_ir
import logging
+from core.evolution.cycle import candidate_records
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import candidate_records as candidate_records_module
def decremental_candidates(extras: Dict[str, Any], *, dry_run: bool) -> List[Dict[str, Any]]:
"""Retirement and merge findings, as candidates rather than as a list.
@@ -14,7 +15,7 @@ def decremental_candidates(extras: Dict[str, Any], *, dry_run: bool) -> List[Dic
capability produces no error — only a slow decline — so it is precisely the
direction that must not be automatic.
"""
- from core.evolution.cycle.promotion_state import ACTION_REWRITE
+ from core.evolution.cycle.promotion_constants import ACTION_REWRITE
decremental = extras.get("decremental") or {}
created: List[Dict[str, Any]] = []
@@ -68,7 +69,7 @@ def decremental_candidates(extras: Dict[str, Any], *, dry_run: bool) -> List[Dic
if dry_run:
created.append({"dry_run": True, "kind": "retirement", "asset_id": asset_id})
continue
- candidate_id = candidate_records_module.persist_candidate(
+ candidate_id = candidate_records.persist_candidate(
ir,
credit=decision.to_dict(),
proposer="decremental_engine",
@@ -98,7 +99,7 @@ def orchestration_candidates(
other route.
"""
from core.capabilities.catalog.api import get_enabled_ids
- from core.evolution.profiles.agent_profile_state import TARGET_KIND
+ from core.evolution.profiles.agent_profile_constants import TARGET_KIND
from core.evolution.profiles.agent_profile import builtin_profile, validate_profile
from core.evolution.generators.orchestration_gen import generate_orchestration_proposals, profile_from_proposals
@@ -162,7 +163,7 @@ def orchestration_candidates(
if dry_run:
created.append({"dry_run": True, "kind": "agent_profile", "task_type": task_type})
continue
- candidate_id = candidate_records_module.persist_candidate(
+ candidate_id = candidate_records.persist_candidate(
ir,
credit=decision.to_dict(),
proposer="orchestration_engine",
@@ -207,7 +208,7 @@ def orchestration_candidates(
if dry_run:
created.append({"dry_run": True, "kind": "subagent", "name": subagent.name})
continue
- candidate_id = candidate_records_module.persist_candidate(
+ candidate_id = candidate_records.persist_candidate(
ir,
credit=decision.to_dict(),
proposer="orchestration_engine",
diff --git a/src/backend/core/evolution/cycle/cycle_extras.py b/src/backend/core/evolution/cycle/cycle_extras.py
index 8decdb25..9852f995 100644
--- a/src/backend/core/evolution/cycle/cycle_extras.py
+++ b/src/backend/core/evolution/cycle/cycle_extras.py
@@ -1,10 +1,11 @@
-"""cycle extras responsibilities."""
+"""Run the cycle's secondary engines in one call."""
from __future__ import annotations
from typing import Any, Dict, Optional, Sequence
import logging
+from core.evolution.cycle import extra_findings
+from core.evolution.cycle import memory_findings
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import extra_findings as extra_findings_module
-from core.evolution.cycle import memory_findings as memory_findings_module
def run_extras(
views: Sequence[Dict[str, Any]],
@@ -19,9 +20,9 @@ def run_extras(
"""
report: Dict[str, Any] = {}
for name, fn in (
- ("intent", lambda: extra_findings_module.intent_findings(views)),
- ("decremental", lambda: extra_findings_module.decremental_findings(views)),
- ("memory", lambda: memory_findings_module.memory_findings(views, scan_findings=scan_findings)),
+ ("intent", lambda: extra_findings.intent_findings(views)),
+ ("decremental", lambda: extra_findings.decremental_findings(views)),
+ ("memory", lambda: memory_findings.memory_findings(views, scan_findings=scan_findings)),
):
try:
report[name] = fn()
diff --git a/src/backend/core/evolution/cycle/cycle_extras_state.py b/src/backend/core/evolution/cycle/cycle_extras_constants.py
similarity index 76%
rename from src/backend/core/evolution/cycle/cycle_extras_state.py
rename to src/backend/core/evolution/cycle/cycle_extras_constants.py
index a18d0b17..967d9902 100644
--- a/src/backend/core/evolution/cycle/cycle_extras_state.py
+++ b/src/backend/core/evolution/cycle/cycle_extras_constants.py
@@ -1,4 +1,4 @@
-"""cycle extras state responsibilities."""
+"""Thresholds for the intent, demotion and uplift engines."""
from __future__ import annotations
diff --git a/src/backend/core/evolution/cycle/episode_views.py b/src/backend/core/evolution/cycle/episode_views.py
index 2ac54ec0..25798316 100644
--- a/src/backend/core/evolution/cycle/episode_views.py
+++ b/src/backend/core/evolution/cycle/episode_views.py
@@ -1,9 +1,9 @@
-"""episode views responsibilities."""
+"""Flatten episodes and their trace events into the views the engines read."""
from __future__ import annotations
from typing import Any, Dict, List
from core.db.engine import SessionLocal
from core.db.models.evolution import EvolutionEpisode, EvolutionTraceEvent
-from core.evolution.profiles.user_settings_state import PRIVACY_PRIVATE
+from core.evolution.profiles.user_settings_constants import PRIVACY_PRIVATE
import logging
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/cycle/evaluation.py b/src/backend/core/evolution/cycle/evaluation.py
index 6e4fe8ed..8bccae99 100644
--- a/src/backend/core/evolution/cycle/evaluation.py
+++ b/src/backend/core/evolution/cycle/evaluation.py
@@ -1,9 +1,8 @@
-"""evaluation responsibilities."""
+"""Replay-evaluate candidates and record the results."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from core.db.engine import SessionLocal
from core.db.models.evolution import EvolutionCandidate
-from core.evolution.cycle.release import DRAFT
import logging
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/cycle/extra_findings.py b/src/backend/core/evolution/cycle/extra_findings.py
index 0f85a262..214feb8c 100644
--- a/src/backend/core/evolution/cycle/extra_findings.py
+++ b/src/backend/core/evolution/cycle/extra_findings.py
@@ -1,11 +1,11 @@
-"""extra findings responsibilities."""
+"""Intent, decremental and negative-transfer findings."""
from __future__ import annotations
-from core.evolution.cycle import promotion as promotion_module
-from core.evolution.cycle import retirement as retirement_module
+from core.evolution.cycle import promotion
+from core.evolution.cycle import retirement
import hashlib
from typing import Any, Dict, List, Optional, Sequence, Tuple
-from core.evolution.cycle import cycle_extras_state as cycle_extras_state_module
-from core.evolution.cycle import usage_evidence as usage_evidence_module
+from core.evolution.cycle import cycle_extras_constants
+from core.evolution.cycle import usage_evidence
def asset_id(prefix: str, seed: str) -> str:
return f"{prefix}-" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
@@ -21,18 +21,18 @@ def intent_findings(views: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
"""
from core.evolution.assessment.similarity import cluster_intents
- clusters, method = cluster_intents(views, min_support=cycle_extras_state_module.MIN_INTENT_OCCASIONS)
+ clusters, method = cluster_intents(views, min_support=cycle_extras_constants.MIN_INTENT_OCCASIONS)
findings: List[Dict[str, Any]] = []
for cluster in clusters:
- if cluster.occasions < cycle_extras_state_module.MIN_INTENT_OCCASIONS:
+ if cluster.occasions < cycle_extras_constants.MIN_INTENT_OCCASIONS:
# Recurrence inside one conversation is a user rephrasing after a
# bad answer, which is evidence about that answer, not about demand.
continue
- if cluster.plan_variety < cycle_extras_state_module.MIN_PLAN_VARIETY:
+ if cluster.plan_variety < cycle_extras_constants.MIN_PLAN_VARIETY:
# Same question, same plan, every time. Already effectively a skill;
# writing one down adds an asset to maintain and changes nothing.
continue
- if cluster.success_rate < cycle_extras_state_module.INTENT_MIN_SUCCESS_RATE:
+ if cluster.success_rate < cycle_extras_constants.INTENT_MIN_SUCCESS_RATE:
# Recurring *and* failing. Distilling the current plan would encode
# the failure. This belongs to attribution, not to promotion.
findings.append(
@@ -76,17 +76,16 @@ def decremental_findings(
is computed from that and never from the tools observed in episodes where
the skill happened to be loaded, which would pair every co-enabled skill.
"""
- from core.evolution.cycle import promotion as P
- stats = skill_stats if skill_stats is not None else usage_evidence_module.skill_usage_from_views(views)
+ stats = skill_stats if skill_stats is not None else usage_evidence.skill_usage_from_views(views)
overlaps: List[Tuple[str, str, int]] = []
if declared_tools is None:
- declared_tools = usage_evidence_module.declared_tools_for(sorted(stats))
+ declared_tools = usage_evidence.declared_tools_for(sorted(stats))
if declared_tools:
- overlaps = promotion_module.find_overlaps(declared_tools)
+ overlaps = promotion.find_overlaps(declared_tools)
- retirements = retirement_module.find_retirement_candidates(stats)
+ retirements = retirement.find_retirement_candidates(stats)
return {
"overlaps": [
{"a": a, "b": b, "shared_tools": shared} for a, b, shared in overlaps
@@ -108,14 +107,14 @@ def negative_transfer_for_activations(
shape of defect this rework exists to remove. So the split is computed here,
from the release ledger, once per activated asset.
"""
- activations = usage_evidence_module.recent_activations()
+ activations = usage_evidence.recent_activations()
if not activations:
return []
findings: List[Dict[str, Any]] = []
for asset_id, activated_at, related in activations:
split = [
- {**view, "after": usage_evidence_module.is_after(view.get("created_at"), activated_at)}
+ {**view, "after": usage_evidence.is_after(view.get("created_at"), activated_at)}
for view in views
if view.get("created_at") is not None
]
@@ -143,7 +142,6 @@ def negative_transfer_findings(
can catch: each change looks fine on the metric it was measured against, and
the damage shows up somewhere nobody was looking.
"""
- from core.evolution.cycle import promotion as P
by_type_before: Dict[str, List[bool]] = {}
by_type_after: Dict[str, List[bool]] = {}
@@ -156,7 +154,7 @@ def negative_transfer_findings(
def rates(source: Dict[str, List[bool]]) -> Dict[str, float]:
return {k: (sum(v) / len(v) if v else 0.0) for k, v in source.items()}
- findings = retirement_module.detect_negative_transfer(
+ findings = retirement.detect_negative_transfer(
asset_id=asset_id or "unspecified",
# Only *unrelated* task types are examined. A dip on the task the change
# targeted is an ordinary regression its own guardrail catches; the
diff --git a/src/backend/core/evolution/cycle/graph_candidates.py b/src/backend/core/evolution/cycle/graph_candidates.py
index 37269e23..78c8369b 100644
--- a/src/backend/core/evolution/cycle/graph_candidates.py
+++ b/src/backend/core/evolution/cycle/graph_candidates.py
@@ -1,15 +1,16 @@
-"""graph candidates responsibilities."""
+"""L3 graph evolution candidates and the dispatcher for the other engines."""
from __future__ import annotations
import hashlib
from typing import Any, Dict, List
from core.evolution.evidence.credit import T_MEMORY, aggregate_finding, confidence_from_evidence
from core.evolution.ir import RISK_MEDIUM, EvolutionIR, InvariantViolation, Rollback, validate_ir
import logging
+from core.evolution.cycle import candidate_records
+from core.evolution.cycle import composition_candidates
+from core.evolution.cycle import memory_candidates
+from core.evolution.cycle import prompt_candidates
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import candidate_records as candidate_records_module
-from core.evolution.cycle import composition_candidates as composition_candidates_module
-from core.evolution.cycle import memory_candidates as memory_candidates_module
-from core.evolution.cycle import prompt_candidates as prompt_candidates_module
def graph_evolution_candidates(
views: List[Dict[str, Any]], *, dry_run: bool
@@ -42,7 +43,7 @@ def graph_evolution_candidates(
try:
from core.memory.graph import list_graph_relations
- relations = candidate_records_module.run_async(list_graph_relations(user_id, limit=200))
+ relations = candidate_records.run_async(list_graph_relations(user_id, limit=200))
except Exception as exc: # noqa: BLE001
logger.debug("[evolution-loop] graph listing failed for %s: %s", user_id, exc)
continue
@@ -104,7 +105,7 @@ def graph_evolution_candidates(
"[evolution-loop] graph candidate rejected: %s", violation.code
)
else:
- candidate_id = candidate_records_module.persist_candidate(
+ candidate_id = candidate_records.persist_candidate(
ir,
credit=decision.to_dict(),
proposer="graph_engine",
@@ -125,16 +126,16 @@ def run_other_engines(views: List[Dict[str, Any]], *, dry_run: bool) -> Dict[str
"""
from core.evolution.cycle.cycle_extras import run_extras
- scan_findings = memory_candidates_module.memory_scan_findings(views)
+ scan_findings = memory_candidates.memory_scan_findings(views)
extras = run_extras(views, scan_findings=scan_findings)
produced: List[Dict[str, Any]] = []
for name, fn in (
- ("memory", lambda: memory_candidates_module.memory_candidates(extras, views, dry_run=dry_run)),
- ("intent", lambda: memory_candidates_module.intent_skill_candidates(extras, views, dry_run=dry_run)),
- ("decremental", lambda: composition_candidates_module.decremental_candidates(extras, dry_run=dry_run)),
- ("orchestration", lambda: composition_candidates_module.orchestration_candidates(views, dry_run=dry_run)),
- ("prompt", lambda: prompt_candidates_module.prompt_candidates(views, dry_run=dry_run)),
+ ("memory", lambda: memory_candidates.memory_candidates(extras, views, dry_run=dry_run)),
+ ("intent", lambda: memory_candidates.intent_skill_candidates(extras, views, dry_run=dry_run)),
+ ("decremental", lambda: composition_candidates.decremental_candidates(extras, dry_run=dry_run)),
+ ("orchestration", lambda: composition_candidates.orchestration_candidates(views, dry_run=dry_run)),
+ ("prompt", lambda: prompt_candidates.prompt_candidates(views, dry_run=dry_run)),
("graph", lambda: graph_evolution_candidates(views, dry_run=dry_run)),
):
try:
diff --git a/src/backend/core/evolution/cycle/loop.py b/src/backend/core/evolution/cycle/loop.py
index 9b140813..90cca13b 100644
--- a/src/backend/core/evolution/cycle/loop.py
+++ b/src/backend/core/evolution/cycle/loop.py
@@ -1,18 +1,19 @@
-"""loop responsibilities."""
+"""One fleet-wide evolution pass: discover, attribute, promote, validate, persist."""
from __future__ import annotations
-from core.evolution.cycle import ordering as ordering_module
-from core.evolution.cycle import patterns as patterns_module
-from core.evolution.cycle import promotion as promotion_module
-from core.evolution.cycle import promotion_state as promotion_state_module
+from core.evolution.cycle import ordering
+from core.evolution.cycle import patterns
+from core.evolution.cycle import promotion
+from core.evolution.cycle import promotion_constants
import hashlib
from typing import Any, Dict, List
from core.evolution.evidence.credit import T_SKILL, aggregate_finding, assign_credit, confidence_from_evidence
from core.evolution.ir import RISK_HIGH, RISK_MEDIUM
import logging
+from core.evolution.cycle import candidate_records
+from core.evolution.cycle import episode_views
+from core.evolution.cycle import graph_candidates
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import candidate_records as candidate_records_module
-from core.evolution.cycle import episode_views as episode_views_module
-from core.evolution.cycle import graph_candidates as graph_candidates_module
def run_evolution_cycle(*, limit: int = 500, dry_run: bool = False) -> Dict[str, Any]:
"""One fleet-wide pass: discover → attribute → promote → validate → persist.
@@ -20,7 +21,7 @@ def run_evolution_cycle(*, limit: int = 500, dry_run: bool = False) -> Dict[str,
Returns a report rather than raising: this runs as a background job and a
bad cycle must leave the product untouched.
"""
- if not candidate_records_module.cross_user_mining_available():
+ if not candidate_records.cross_user_mining_available():
logger.info("[evolution-loop] cross-user mining unavailable; personal only")
return {
"episodes": 0,
@@ -59,19 +60,19 @@ def run_evolution_cycle(*, limit: int = 500, dry_run: bool = False) -> Dict[str,
"readiness": readiness.to_dict(),
}
- views = episode_views_module.load_episode_views(limit=limit)
+ views = episode_views.load_episode_views(limit=limit)
report["episodes"] = len(views)
if not views:
return report
- patterns = patterns_module.discover_patterns(views)
- report["patterns"] = len(patterns)
+ discovered = patterns.discover_patterns(views)
+ report["patterns"] = len(discovered)
# Transferable ordering rules, extracted once across the whole corpus rather
# than per pattern. A rigid sequence only helps tasks whose tool set it
# happens to cover; the ordering rule behind it helps every task making the
# same mistake, which is the difference between memorising and learning.
- constraints = ordering_module.extract_ordering_constraints(views)
+ constraints = ordering.extract_ordering_constraints(views)
report["ordering_constraints"] = [c.to_dict() for c in constraints]
from core.evolution.generators.skill_candidate_engine import STATUS_CREATED, STATUS_DRY_RUN, STATUS_REJECTED, SkillCandidateSpec, create_skill_candidate
@@ -80,8 +81,8 @@ def run_evolution_cycle(*, limit: int = 500, dry_run: bool = False) -> Dict[str,
covered_tool_sets: List[List[str]] = []
by_episode_id = {str(v.get("episode_id") or ""): v for v in views}
- for pattern in patterns:
- if pattern.kind == promotion_state_module.PATTERN_SUCCESS_SUBSEQUENCE and pattern.tool_sequence:
+ for pattern in discovered:
+ if pattern.kind == promotion_constants.PATTERN_SUCCESS_SUBSEQUENCE and pattern.tool_sequence:
# An observation across many *successful* runs — aggregate decision,
# not failure attribution with invented features.
decision = aggregate_finding(
@@ -95,14 +96,14 @@ def run_evolution_cycle(*, limit: int = 500, dry_run: bool = False) -> Dict[str,
evidence_count=pattern.support,
)
else:
- decision = assign_credit(candidate_records_module.features_for(pattern))
+ decision = assign_credit(candidate_records.features_for(pattern))
if not decision.may_produce_candidate:
# No-update / unidentifiable / a non-asset cause. Refusing here is
# the mechanism that stops a KB gap being "fixed" by editing a skill.
report["no_update"] += 1
continue
- proposal = promotion_module.promote_tool_sequence_to_skill(
+ proposal = promotion.promote_tool_sequence_to_skill(
pattern,
episodes=views,
skill_credit=decision.scores.get("skill", 0.0),
@@ -130,7 +131,7 @@ def run_evolution_cycle(*, limit: int = 500, dry_run: bool = False) -> Dict[str,
# 任务都成立的通用组合。规则幸存者再过一道 LLM 裁决,拒绝即不产候选。
from core.evolution.assessment.sop_judge import adjudicate_sequence_sop
- verdict = candidate_records_module.run_async(
+ verdict = candidate_records.run_async(
adjudicate_sequence_sop(
representative=str(proposal.payload.get("intent") or ""),
objectives=[str(v.get("objective") or "") for v in supporting],
@@ -211,9 +212,9 @@ def run_evolution_cycle(*, limit: int = 500, dry_run: bool = False) -> Dict[str,
# tool sequence was unique. The rules are the part that generalises, so they
# get to stand on their own rather than only riding along on a sequence that
# happens to qualify.
- orphan_rules = candidate_records_module.uncovered_constraints(constraints, covered_tool_sets)
+ orphan_rules = candidate_records.uncovered_constraints(constraints, covered_tool_sets)
if orphan_rules:
- rule_decision = assign_credit(candidate_records_module.features_for_ordering_rules(orphan_rules))
+ rule_decision = assign_credit(candidate_records.features_for_ordering_rules(orphan_rules))
if not rule_decision.may_produce_candidate:
report["no_update"] += 1
else:
@@ -279,7 +280,7 @@ def run_evolution_cycle(*, limit: int = 500, dry_run: bool = False) -> Dict[str,
# candidate in the same table, behind the same security gate and the same
# review queue as the skill candidates above — a finding that stops at a
# report is a log line, not an engine.
- report.update(graph_candidates_module.run_other_engines(views, dry_run=dry_run))
+ report.update(graph_candidates.run_other_engines(views, dry_run=dry_run))
logger.info(
"[evolution-loop] cycle done: episodes=%d patterns=%d candidates=%d no_update=%d",
diff --git a/src/backend/core/evolution/cycle/memory_candidates.py b/src/backend/core/evolution/cycle/memory_candidates.py
index 591c99dd..9570e4ad 100644
--- a/src/backend/core/evolution/cycle/memory_candidates.py
+++ b/src/backend/core/evolution/cycle/memory_candidates.py
@@ -1,12 +1,13 @@
-"""memory candidates responsibilities."""
+"""Memory and intent candidates built from per-user memory scans."""
from __future__ import annotations
import hashlib
from typing import Any, Dict, List
from core.evolution.evidence.credit import T_MEMORY, T_SKILL, aggregate_finding, confidence_from_evidence
from core.evolution.ir import RISK_HIGH, RISK_MEDIUM, EvolutionIR, InvariantViolation, Rollback, validate_ir
import logging
+from core.evolution.cycle import candidate_records
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import candidate_records as candidate_records_module
def memory_scan_findings(views: List[Dict[str, Any]]) -> List[Any]:
"""Scan every contributing user's store.
@@ -29,7 +30,7 @@ def memory_scan_findings(views: List[Dict[str, Any]]) -> List[Any]:
findings: List[Any] = []
for user_id, refs in retrieved.items():
try:
- _, user_findings = candidate_records_module.run_async(scan_user_memories(user_id, retrieved_refs=refs))
+ _, user_findings = candidate_records.run_async(scan_user_memories(user_id, retrieved_refs=refs))
except Exception as exc: # noqa: BLE001
logger.warning("[evolution-loop] memory scan failed for %s: %s", user_id, exc)
continue
@@ -114,7 +115,7 @@ def memory_candidates(
if dry_run:
created.append({"dry_run": True, "kind": "memory", "user_id": user_id})
continue
- candidate_id = candidate_records_module.persist_candidate(
+ candidate_id = candidate_records.persist_candidate(
ir,
credit=decision.to_dict(),
proposer="memory_engine",
@@ -193,7 +194,7 @@ def intent_skill_candidates(
write_document=True,
)
outcome = create_skill_candidate(
- spec, credit=decision.to_dict(), dry_run=dry_run, run_async=candidate_records_module.run_async
+ spec, credit=decision.to_dict(), dry_run=dry_run, run_async=candidate_records.run_async
)
status = outcome.get("status")
if status == STATUS_NOT_WRITTEN:
@@ -256,7 +257,7 @@ def procedures_for_cluster(
try:
from core.memory.service import get_all_memories
- raw = candidate_records_module.run_async(get_all_memories(user_id, top_k=500))
+ raw = candidate_records.run_async(get_all_memories(user_id, top_k=500))
except Exception as exc: # noqa: BLE001
logger.debug("[evolution-loop] procedure lookup failed for %s: %s", user_id, exc)
continue
diff --git a/src/backend/core/evolution/cycle/memory_findings.py b/src/backend/core/evolution/cycle/memory_findings.py
index 379b380e..8ddaf33f 100644
--- a/src/backend/core/evolution/cycle/memory_findings.py
+++ b/src/backend/core/evolution/cycle/memory_findings.py
@@ -1,7 +1,7 @@
-"""memory findings responsibilities."""
+"""Reorganisation proposals for the memory layer."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence
-from core.evolution.cycle import cycle_extras_state as cycle_extras_state_module
+from core.evolution.cycle import cycle_extras_constants
def memory_findings(
views: Sequence[Dict[str, Any]],
@@ -76,10 +76,10 @@ def memory_findings(
# never deleted — a wrong reweight is recoverable and a wrong deletion is
# not.
for ref, count in retrieved.items():
- if count < cycle_extras_state_module.DEMOTE_MIN_HITS:
+ if count < cycle_extras_constants.DEMOTE_MIN_HITS:
continue
rate = helped.get(ref, 0) / count
- if rate <= cycle_extras_state_module.DEMOTE_SUCCESS_CEILING:
+ if rate <= cycle_extras_constants.DEMOTE_SUCCESS_CEILING:
ops.append(
MemoryOp(
operation=OP_REWEIGHT,
diff --git a/src/backend/core/evolution/cycle/ordering.py b/src/backend/core/evolution/cycle/ordering.py
index db6ba2ad..2df58c89 100644
--- a/src/backend/core/evolution/cycle/ordering.py
+++ b/src/backend/core/evolution/cycle/ordering.py
@@ -1,17 +1,17 @@
-"""ordering responsibilities."""
+"""Learn tool orderings that hold across successful episodes and apply them."""
from __future__ import annotations
from collections import defaultdict
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
-from core.evolution.cycle import patterns as patterns_module
-from core.evolution.cycle import promotion_state as promotion_state_module
-from core.evolution.cycle import promotion_types as promotion_types_module
+from core.evolution.cycle import patterns
+from core.evolution.cycle import promotion_constants
+from core.evolution.cycle import promotion_types
def extract_ordering_constraints(
episodes: Sequence[Dict[str, Any]],
*,
- min_support: int = promotion_state_module.MIN_SUPPORT,
- min_contexts: int = promotion_state_module.MIN_CONSTRAINT_CONTEXTS,
-) -> List[promotion_types_module.OrderingConstraint]:
+ min_support: int = promotion_constants.MIN_SUPPORT,
+ min_contexts: int = promotion_constants.MIN_CONSTRAINT_CONTEXTS,
+) -> List[promotion_types.OrderingConstraint]:
"""Find orderings that hold *invariantly* across successful episodes.
Conflicting evidence vetoes a constraint outright rather than letting the
@@ -28,7 +28,7 @@ def extract_ordering_constraints(
tools = [str(t) for t in (episode.get("tool_sequence") or [])]
if len(tools) < 2:
continue
- shape = patterns_module.pattern_signature(sorted(set(tools)))
+ shape = patterns.pattern_signature(sorted(set(tools)))
task_type = str(episode.get("task_type") or "unknown")
for i, earlier in enumerate(tools):
for later in tools[i + 1 :]:
@@ -37,7 +37,7 @@ def extract_ordering_constraints(
forward[(earlier, later)].add(shape)
by_task_type[(earlier, later)].add(task_type)
- constraints: List[promotion_types_module.OrderingConstraint] = []
+ constraints: List[promotion_types.OrderingConstraint] = []
for (a, b), shapes in forward.items():
validated = by_task_type[(a, b)]
contradicted = by_task_type.get((b, a), set())
@@ -61,7 +61,7 @@ def extract_ordering_constraints(
if support < min_support or len(shapes) < min_contexts:
continue
constraints.append(
- promotion_types_module.OrderingConstraint(
+ promotion_types.OrderingConstraint(
before=a,
after=b,
support=support,
@@ -77,7 +77,7 @@ def extract_ordering_constraints(
def apply_ordering_constraints(
tools: Sequence[str],
- constraints: Sequence[promotion_types_module.OrderingConstraint],
+ constraints: Sequence[promotion_types.OrderingConstraint],
task_type: Optional[str] = None,
) -> List[str]:
"""Reorder a task's tools to satisfy every applicable constraint.
diff --git a/src/backend/core/evolution/cycle/patterns.py b/src/backend/core/evolution/cycle/patterns.py
index 0f4740ac..56ffa95f 100644
--- a/src/backend/core/evolution/cycle/patterns.py
+++ b/src/backend/core/evolution/cycle/patterns.py
@@ -1,9 +1,9 @@
-"""patterns responsibilities."""
+"""Cluster episodes into recurring patterns."""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Sequence
-from core.evolution.cycle import promotion_state as promotion_state_module
+from core.evolution.cycle import promotion_constants
@dataclass
class Pattern:
@@ -21,7 +21,7 @@ class Pattern:
@property
def meets_support(self) -> bool:
- return self.support >= promotion_state_module.MIN_SUPPORT or self.single_shot
+ return self.support >= promotion_constants.MIN_SUPPORT or self.single_shot
def to_dict(self) -> Dict[str, Any]:
return {
@@ -66,10 +66,10 @@ def discover_patterns(episodes: List[Dict[str, Any]]) -> List[Pattern]:
failures = [e for e in group if e.get("verdict") == "failed"]
rate = len(successes) / len(group) if group else 0.0
- if len(successes) >= promotion_state_module.MIN_SUPPORT and rate >= promotion_state_module.SKILL_MIN_SUCCESS_RATE:
+ if len(successes) >= promotion_constants.MIN_SUPPORT and rate >= promotion_constants.SKILL_MIN_SUCCESS_RATE:
patterns.append(
Pattern(
- kind=promotion_state_module.PATTERN_SUCCESS_SUBSEQUENCE,
+ kind=promotion_constants.PATTERN_SUCCESS_SUBSEQUENCE,
signature=signature,
support=len(successes),
episode_ids=[e.get("episode_id", "") for e in successes],
@@ -84,10 +84,10 @@ def discover_patterns(episodes: List[Dict[str, Any]]) -> List[Pattern]:
),
)
)
- if len(failures) >= promotion_state_module.MIN_SUPPORT:
+ if len(failures) >= promotion_constants.MIN_SUPPORT:
patterns.append(
Pattern(
- kind=promotion_state_module.PATTERN_REPEATED_FAILURE,
+ kind=promotion_constants.PATTERN_REPEATED_FAILURE,
signature=signature,
support=len(failures),
episode_ids=[e.get("episode_id", "") for e in failures],
@@ -97,11 +97,11 @@ def discover_patterns(episodes: List[Dict[str, Any]]) -> List[Pattern]:
)
for signature, group in by_skill_seq.items():
- if len(group) >= promotion_state_module.WORKFLOW_MIN_COMBO_FREQUENCY:
+ if len(group) >= promotion_constants.WORKFLOW_MIN_COMBO_FREQUENCY:
successes = [e for e in group if e.get("verdict") == "success"]
patterns.append(
Pattern(
- kind=promotion_state_module.PATTERN_SUCCESS_SUBSEQUENCE,
+ kind=promotion_constants.PATTERN_SUCCESS_SUBSEQUENCE,
signature=signature,
support=len(group),
episode_ids=[e.get("episode_id", "") for e in group],
diff --git a/src/backend/core/evolution/cycle/profile_release.py b/src/backend/core/evolution/cycle/profile_release.py
index 57104fc0..795ca5b0 100644
--- a/src/backend/core/evolution/cycle/profile_release.py
+++ b/src/backend/core/evolution/cycle/profile_release.py
@@ -1,12 +1,13 @@
-"""profile release responsibilities."""
+"""Publish approved agent profiles and sub-agents."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from core.db.engine import SessionLocal
import logging
+from core.evolution.cycle import activation_policy
+from core.evolution.cycle import activation_types
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import activation_policy as activation_policy_module
-from core.evolution.cycle import activation_types as activation_types_module
def materialise_profile_candidate(
candidate_id: str, *, approver: str, tenant_id: str = "default"
@@ -23,26 +24,28 @@ def materialise_profile_candidate(
Rolling back is switching ``is_active`` off, which restores the built-in
assembly on the next run — no restart, because profiles are read per run.
"""
+ from core.db.models.evolution import EvolutionCandidate, EvolutionRelease
+
from core.db.models.evolution import EvolutionAgentProfile
from core.evolution.profiles.agent_profile import AgentProfile, validate_profile
- from core.evolution.profiles.agent_profile_state import TARGET_KIND
+ from core.evolution.profiles.agent_profile_constants import TARGET_KIND
from core.evolution.cycle.release import ACTIVE as _ACTIVE, new_release_id
if not approver:
- raise activation_types_module.ActivationError("approver_required", "激活必须署名")
+ raise activation_types.ActivationError("approver_required", "激活必须署名")
with SessionLocal() as db:
candidate = db.get(EvolutionCandidate, candidate_id)
if candidate is None:
- raise activation_types_module.ActivationError("candidate_not_found", f"候选不存在: {candidate_id}")
+ raise activation_types.ActivationError("candidate_not_found", f"候选不存在: {candidate_id}")
if candidate.target_kind != TARGET_KIND:
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"unsupported_kind", f"该入口只处理编排候选,收到 {candidate.target_kind}"
)
if approver == (candidate.proposer or ""):
- raise activation_types_module.ActivationError("self_approval_forbidden", "生成者不能激活自己提出的候选")
- if activation_policy_module.is_frozen(db):
- raise activation_types_module.ActivationError("layer_frozen", "进化已被全局冻结")
+ raise activation_types.ActivationError("self_approval_forbidden", "生成者不能激活自己提出的候选")
+ if activation_policy.is_frozen(db):
+ raise activation_types.ActivationError("layer_frozen", "进化已被全局冻结")
payload: Dict[str, Any] = {}
for change in candidate.ir.get("changes") or []:
@@ -50,7 +53,7 @@ def materialise_profile_candidate(
payload = change["profile"]
break
if not payload:
- raise activation_types_module.ActivationError("no_profile_payload", "候选未携带 profile 定义")
+ raise activation_types.ActivationError("no_profile_payload", "候选未携带 profile 定义")
profile = AgentProfile.from_dict(payload)
profile.scope = {**(profile.scope or {}), "tenant_id": tenant_id or "default"}
@@ -65,7 +68,7 @@ def materialise_profile_candidate(
]
ok, problems = validate_profile(profile, base_tools=base_tools)
if not ok:
- raise activation_types_module.ActivationError("profile_invalid", f"编排定义未通过校验: {problems}")
+ raise activation_types.ActivationError("profile_invalid", f"编排定义未通过校验: {problems}")
previous = db.get(EvolutionAgentProfile, profile.profile_id)
previous_payload = dict(previous.payload) if previous is not None else None
@@ -168,24 +171,26 @@ def materialise_subagent_candidate(
does nothing, and one that is enabled before its route is reviewed is a
capability sitting in the product that no reviewer has seen used.
"""
+ from core.db.models.evolution import EvolutionCandidate, EvolutionRelease
+
from core.db.models import UserAgent
- from core.evolution.cycle.release import ACTIVE as _ACTIVE, SHADOW, new_release_id
+ from core.evolution.cycle.release import SHADOW, new_release_id
if not approver:
- raise activation_types_module.ActivationError("approver_required", "激活必须署名")
+ raise activation_types.ActivationError("approver_required", "激活必须署名")
with SessionLocal() as db:
candidate = db.get(EvolutionCandidate, candidate_id)
if candidate is None:
- raise activation_types_module.ActivationError("candidate_not_found", f"候选不存在: {candidate_id}")
+ raise activation_types.ActivationError("candidate_not_found", f"候选不存在: {candidate_id}")
if candidate.target_kind != "subagent":
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"unsupported_kind", f"该入口只处理子智能体候选,收到 {candidate.target_kind}"
)
if approver == (candidate.proposer or ""):
- raise activation_types_module.ActivationError("self_approval_forbidden", "生成者不能激活自己提出的候选")
- if activation_policy_module.is_frozen(db):
- raise activation_types_module.ActivationError("layer_frozen", "进化已被全局冻结")
+ raise activation_types.ActivationError("self_approval_forbidden", "生成者不能激活自己提出的候选")
+ if activation_policy.is_frozen(db):
+ raise activation_types.ActivationError("layer_frozen", "进化已被全局冻结")
spec: Dict[str, Any] = {}
for change in candidate.ir.get("changes") or []:
@@ -193,7 +198,7 @@ def materialise_subagent_candidate(
spec = change
break
if not spec:
- raise activation_types_module.ActivationError("no_subagent_payload", "候选未携带子智能体定义")
+ raise activation_types.ActivationError("no_subagent_payload", "候选未携带子智能体定义")
# The subset check runs again at activation against the current grant.
from core.capabilities.catalog.api import get_enabled_ids
@@ -204,16 +209,16 @@ def materialise_subagent_candidate(
requested = {str(t) for t in (spec.get("tools_allowlist") or [])}
widened = sorted(requested - base_tools)
if widened:
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"tool_escalation", f"子智能体工具集超出主体授权: {widened}"
)
if spec.get("may_delegate"):
- raise activation_types_module.ActivationError("subagent_nesting", "子智能体不得再派生子智能体")
+ raise activation_types.ActivationError("subagent_nesting", "子智能体不得再派生子智能体")
agent_id = str(spec.get("agent_id"))
existing = db.get(UserAgent, agent_id)
if existing is not None:
- raise activation_types_module.ActivationError("agent_exists", f"子智能体已存在: {agent_id}")
+ raise activation_types.ActivationError("agent_exists", f"子智能体已存在: {agent_id}")
agent = UserAgent(
agent_id=agent_id,
diff --git a/src/backend/core/evolution/cycle/promotion.py b/src/backend/core/evolution/cycle/promotion.py
index 61cd2263..2a57ef72 100644
--- a/src/backend/core/evolution/cycle/promotion.py
+++ b/src/backend/core/evolution/cycle/promotion.py
@@ -1,14 +1,15 @@
-"""promotion responsibilities."""
+"""Promote recurring sequences, procedures and skill combinations into proposals."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence, Tuple
import logging
+from core.evolution.cycle import patterns
+from core.evolution.cycle import promotion_constants
+from core.evolution.cycle import promotion_types
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import patterns as patterns_module
-from core.evolution.cycle import promotion_state as promotion_state_module
-from core.evolution.cycle import promotion_types as promotion_types_module
def dominant_shared_intent(
- pattern: patterns_module.Pattern,
+ pattern: patterns.Pattern,
episodes: Optional[Sequence[Dict[str, Any]]],
*,
support_floor: int,
@@ -37,7 +38,7 @@ def dominant_shared_intent(
top = clusters[0]
if len(top.episode_ids) < support_floor:
return None
- if len(top.episode_ids) / len(supporting) < promotion_state_module.INTENT_COHESION_RATIO:
+ if len(top.episode_ids) / len(supporting) < promotion_constants.INTENT_COHESION_RATIO:
return None
# 复现必须跨会话:occasions 按 chat_id 去重,同一会话里反复跑同一套
# 流程只算一次场合,不构成"这件事总在发生"。
@@ -47,14 +48,14 @@ def dominant_shared_intent(
def promote_tool_sequence_to_skill(
- pattern: patterns_module.Pattern,
+ pattern: patterns.Pattern,
*,
episodes: Optional[Sequence[Dict[str, Any]]] = None,
min_support: Optional[int] = None,
skill_credit: float = 0.0,
workflow_credit: float = 0.0,
existing_skill_signatures: Optional[Dict[str, str]] = None,
-) -> Optional[promotion_types_module.PromotionProposal]:
+) -> Optional[promotion_types.PromotionProposal]:
"""Compile a recurring successful tool sequence into a skill proposal.
A recurring sequence alone is *not* evidence of a reusable capability.
@@ -91,10 +92,10 @@ def promote_tool_sequence_to_skill(
skill here". Promoting in that case creates a skill with no independent
content, which then has to be maintained forever.
"""
- if pattern.kind != promotion_state_module.PATTERN_SUCCESS_SUBSEQUENCE or not pattern.tool_sequence:
+ if pattern.kind != promotion_constants.PATTERN_SUCCESS_SUBSEQUENCE or not pattern.tool_sequence:
return None
- support_floor = promotion_state_module.MIN_SUPPORT if min_support is None else int(min_support)
- if len(pattern.tool_sequence) < promotion_state_module.MIN_TOOL_SEQUENCE_LEN:
+ support_floor = promotion_constants.MIN_SUPPORT if min_support is None else int(min_support)
+ if len(pattern.tool_sequence) < promotion_constants.MIN_TOOL_SEQUENCE_LEN:
logger.info(
"[promotion] skipping %s: %d-step sequence is below the SOP floor",
pattern.signature,
@@ -103,7 +104,7 @@ def promote_tool_sequence_to_skill(
return None
if pattern.support < support_floor:
return None
- if pattern.success_rate < promotion_state_module.SKILL_MIN_SUCCESS_RATE:
+ if pattern.success_rate < promotion_constants.SKILL_MIN_SUCCESS_RATE:
return None
if workflow_credit > skill_credit:
logger.info(
@@ -123,7 +124,7 @@ def promote_tool_sequence_to_skill(
covered = [e for e in pattern.episode_ids if e in set(intent.episode_ids)]
existing = (existing_skill_signatures or {}).get(pattern.signature)
- return promotion_types_module.PromotionProposal(
+ return promotion_types.PromotionProposal(
from_layer="episode",
to_layer="skill",
signature=pattern.signature,
@@ -159,7 +160,7 @@ def promote_procedural_memory_to_skill(
success_rate: float,
plan_variety: int,
existing_skill_id: Optional[str] = None,
-) -> Optional[promotion_types_module.PromotionProposal]:
+) -> Optional[promotion_types.PromotionProposal]:
"""Compile *how work is done here* into a skill proposal.
This is the chain the design described and the code never had. Four
@@ -188,15 +189,15 @@ def promote_procedural_memory_to_skill(
]
if not procedural:
return None
- if occurrences < promotion_state_module.PROCEDURAL_MIN_OCCURRENCES:
+ if occurrences < promotion_constants.PROCEDURAL_MIN_OCCURRENCES:
return None
- if success_rate < promotion_state_module.PROCEDURAL_MIN_SUCCESS_RATE:
+ if success_rate < promotion_constants.PROCEDURAL_MIN_SUCCESS_RATE:
return None
if plan_variety < 2:
return None
representative = str(intent_cluster.get("representative") or "")
- return promotion_types_module.PromotionProposal(
+ return promotion_types.PromotionProposal(
from_layer="memory",
to_layer="skill",
signature="proc:" + "|".join(sorted(str(m.get("ref") or "") for m in procedural)),
@@ -225,13 +226,13 @@ def promote_procedural_memory_to_skill(
def promote_skills_to_orchestration(
- pattern: patterns_module.Pattern,
+ pattern: patterns.Pattern,
*,
combo_success_rate: float,
individual_success_rate: float,
task_types: Optional[Sequence[str]] = None,
skill_versions: Optional[Dict[str, str]] = None,
-) -> Optional[promotion_types_module.PromotionProposal]:
+) -> Optional[promotion_types.PromotionProposal]:
"""Promote a recurring skill combination into an **agent profile** proposal.
Promoting it into a *workflow* — a fixed DAG — was the wrong target: the
@@ -248,13 +249,13 @@ def promote_skills_to_orchestration(
"""
if not pattern.skill_sequence or len(pattern.skill_sequence) < 2:
return None
- if pattern.support < promotion_state_module.WORKFLOW_MIN_COMBO_FREQUENCY:
+ if pattern.support < promotion_constants.WORKFLOW_MIN_COMBO_FREQUENCY:
return None
- if combo_success_rate - individual_success_rate < promotion_state_module.WORKFLOW_MIN_UPLIFT:
+ if combo_success_rate - individual_success_rate < promotion_constants.WORKFLOW_MIN_UPLIFT:
return None
versions = skill_versions or {}
- return promotion_types_module.PromotionProposal(
+ return promotion_types.PromotionProposal(
from_layer="skill",
to_layer="agent_profile",
signature=pattern.signature,
@@ -277,7 +278,7 @@ def promote_skills_to_orchestration(
def dependencies_satisfied(
- proposal: promotion_types_module.PromotionProposal, live_skill_versions: Dict[str, str]
+ proposal: promotion_types.PromotionProposal, live_skill_versions: Dict[str, str]
) -> Tuple[bool, List[str]]:
"""Whether every declared dependency still exists at the declared version.
diff --git a/src/backend/core/evolution/cycle/promotion_state.py b/src/backend/core/evolution/cycle/promotion_constants.py
similarity index 94%
rename from src/backend/core/evolution/cycle/promotion_state.py
rename to src/backend/core/evolution/cycle/promotion_constants.py
index 60ead47a..992f0aa1 100644
--- a/src/backend/core/evolution/cycle/promotion_state.py
+++ b/src/backend/core/evolution/cycle/promotion_constants.py
@@ -1,4 +1,4 @@
-"""promotion state responsibilities."""
+"""Pattern kinds and support thresholds for promotion."""
from __future__ import annotations
diff --git a/src/backend/core/evolution/cycle/promotion_types.py b/src/backend/core/evolution/cycle/promotion_types.py
index e426f337..9bde9881 100644
--- a/src/backend/core/evolution/cycle/promotion_types.py
+++ b/src/backend/core/evolution/cycle/promotion_types.py
@@ -1,4 +1,4 @@
-"""promotion types responsibilities."""
+"""Promotion proposals, negative-transfer records and ordering constraints."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
diff --git a/src/backend/core/evolution/cycle/prompt_candidates.py b/src/backend/core/evolution/cycle/prompt_candidates.py
index 5e0f8b1b..629dc714 100644
--- a/src/backend/core/evolution/cycle/prompt_candidates.py
+++ b/src/backend/core/evolution/cycle/prompt_candidates.py
@@ -1,18 +1,19 @@
-"""prompt candidates responsibilities."""
+"""Prompt-fragment candidates for failures attributed to the prompt."""
from __future__ import annotations
import hashlib
from typing import Any, Dict, List
from core.evolution.evidence.credit import CreditFeatures, assign_credit
from core.evolution.ir import RISK_HIGH, EvolutionIR, InvariantViolation, Rollback, validate_ir
import logging
+from core.evolution.cycle import candidate_records
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import candidate_records as candidate_records_module
def prompt_candidates(views: List[Dict[str, Any]], *, dry_run: bool) -> List[Dict[str, Any]]:
"""Prompt fragments, for the failures attribution already blames on the prompt."""
from core.evolution.generators.prompt_gen import generate_prompt_candidate
- payload, evidence, draft = candidate_records_module.run_async(
+ payload, evidence, draft = candidate_records.run_async(
generate_prompt_candidate(
views,
cause="归因判定该失败源于提示词未声明相关约束",
@@ -58,7 +59,7 @@ def prompt_candidates(views: List[Dict[str, Any]], *, dry_run: bool) -> List[Dic
return [{"kind": "prompt", "written": False, "why_not": {"code": violation.code}}]
if dry_run:
return [{"dry_run": True, "kind": "prompt", "asset_id": asset_id}]
- candidate_id = candidate_records_module.persist_candidate(
+ candidate_id = candidate_records.persist_candidate(
ir,
credit=decision.to_dict(),
proposer="prompt_engine",
diff --git a/src/backend/core/evolution/cycle/readiness.py b/src/backend/core/evolution/cycle/readiness.py
index cae60ff7..23daf06f 100644
--- a/src/backend/core/evolution/cycle/readiness.py
+++ b/src/backend/core/evolution/cycle/readiness.py
@@ -164,7 +164,6 @@ def _check_generator() -> Requirement:
_CACHE_TTL_S = 300.0
-
def reset_cache() -> None:
"""Drop the cached probe, so a configuration fix is picked up immediately."""
process_state().cached = None
@@ -190,17 +189,13 @@ def check_readiness(*, use_cache: bool = True) -> Readiness:
return readiness
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class ReadinessState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
cached: Optional[Tuple[float, 'Readiness']] = None
-@state_cache(maxsize=1)
-def process_state() -> ReadinessState:
- return ReadinessState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = ReadinessState()
+
+
+def process_state() -> ReadinessState:
+ return _STATE
diff --git a/src/backend/core/evolution/cycle/retirement.py b/src/backend/core/evolution/cycle/retirement.py
index 21854835..724d042a 100644
--- a/src/backend/core/evolution/cycle/retirement.py
+++ b/src/backend/core/evolution/cycle/retirement.py
@@ -1,8 +1,8 @@
-"""retirement responsibilities."""
+"""Find assets to retire and activations that caused negative transfer."""
from __future__ import annotations
from typing import Any, Dict, List, Sequence
-from core.evolution.cycle import promotion_state as promotion_state_module
-from core.evolution.cycle import promotion_types as promotion_types_module
+from core.evolution.cycle import promotion_constants
+from core.evolution.cycle import promotion_types
def find_retirement_candidates(
usage: Dict[str, Dict[str, Any]],
@@ -44,8 +44,8 @@ def find_retirement_candidates(
out.append(
{
"asset_id": asset_id,
- "reason": promotion_state_module.REASON_HARMFUL,
- "action": promotion_state_module.ACTION_ROLLBACK,
+ "reason": promotion_constants.REASON_HARMFUL,
+ "action": promotion_constants.ACTION_ROLLBACK,
"explanation": (
f"成对比较显示有它反而更差({float(uplift):+.0%})——"
"这是唯一需要立即停止曝光的情况"
@@ -55,20 +55,20 @@ def find_retirement_candidates(
)
continue
- if exposure_days < promotion_state_module.MIN_EXPOSURE_DAYS:
+ if exposure_days < promotion_constants.MIN_EXPOSURE_DAYS:
# Too new to judge. Saying nothing is the honest output; the old
# rule's answer here was "retire it", which punished every skill for
# having just shipped.
continue
if opens == 0:
- if offers < promotion_state_module.MIN_OFFERS_BEFORE_JUDGING:
+ if offers < promotion_constants.MIN_OFFERS_BEFORE_JUDGING:
continue
out.append(
{
"asset_id": asset_id,
- "reason": promotion_state_module.REASON_NEVER_OPENED,
- "action": promotion_state_module.ACTION_LOWER_EXPOSURE,
+ "reason": promotion_constants.REASON_NEVER_OPENED,
+ "action": promotion_constants.ACTION_LOWER_EXPOSURE,
"explanation": (
f"已被挂载 {offers} 次、"
# A hand-authored skill has no release record and so no
@@ -87,16 +87,16 @@ def find_retirement_candidates(
)
continue
- if opens < promotion_state_module.MIN_OPENS_BEFORE_JUDGING:
+ if opens < promotion_constants.MIN_OPENS_BEFORE_JUDGING:
continue
follow_through = stats.get("follow_through")
- if follow_through is not None and float(follow_through) < promotion_state_module.FOLLOW_THROUGH_FLOOR:
+ if follow_through is not None and float(follow_through) < promotion_constants.FOLLOW_THROUGH_FLOOR:
out.append(
{
"asset_id": asset_id,
- "reason": promotion_state_module.REASON_IGNORED_WHEN_OPENED,
- "action": promotion_state_module.ACTION_REWRITE,
+ "reason": promotion_constants.REASON_IGNORED_WHEN_OPENED,
+ "action": promotion_constants.ACTION_REWRITE,
"explanation": (
f"被打开 {opens} 次,但只有 {float(follow_through):.0%} 的情况"
"后续动作真的照它说的做——文档本身需要重写,不是该退役"
@@ -107,12 +107,12 @@ def find_retirement_candidates(
continue
success_rate = float(stats.get("success_rate", 0.0) or 0.0)
- if success_rate < promotion_state_module.SUCCESS_FLOOR:
+ if success_rate < promotion_constants.SUCCESS_FLOOR:
out.append(
{
"asset_id": asset_id,
- "reason": promotion_state_module.REASON_LOW_SUCCESS,
- "action": promotion_state_module.ACTION_RETIRE,
+ "reason": promotion_constants.REASON_LOW_SUCCESS,
+ "action": promotion_constants.ACTION_RETIRE,
"explanation": (
f"被打开 {opens} 次、照做了,但成功率只有 {success_rate:.0%}"
),
@@ -129,7 +129,7 @@ def detect_negative_transfer(
before: Dict[str, float],
after: Dict[str, float],
threshold: float = 0.05,
-) -> List[promotion_types_module.NegativeTransfer]:
+) -> List[promotion_types.NegativeTransfer]:
"""Find task types that got worse after an activation *and are unrelated to it*.
Only unrelated tasks are examined. A dip on the task the change targeted is
@@ -139,7 +139,7 @@ def detect_negative_transfer(
changes.
"""
related = set(related_task_types)
- findings: List[promotion_types_module.NegativeTransfer] = []
+ findings: List[promotion_types.NegativeTransfer] = []
for task_type, after_value in (after or {}).items():
if task_type in related:
continue
@@ -148,7 +148,7 @@ def detect_negative_transfer(
continue
if before_value - after_value >= threshold:
findings.append(
- promotion_types_module.NegativeTransfer(
+ promotion_types.NegativeTransfer(
asset_id=asset_id,
task_type=task_type,
before=before_value,
diff --git a/src/backend/core/evolution/cycle/runtime_binding.py b/src/backend/core/evolution/cycle/runtime_binding.py
index c2680f1e..dcc87496 100644
--- a/src/backend/core/evolution/cycle/runtime_binding.py
+++ b/src/backend/core/evolution/cycle/runtime_binding.py
@@ -19,6 +19,7 @@
import logging
import threading
+from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
@@ -42,9 +43,6 @@
_MAX_CACHED_BUNDLES = 512
-
-
-
def _remember(bundle: AssetBundle, run_id: Optional[str], capability_scope: str = "") -> None:
with process_state().lock:
process_state().bundles[bundle.bundle_id] = bundle
@@ -403,19 +401,15 @@ def reset_for_tests() -> None:
process_state().run_to_bundle.clear()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class RuntimeBindingState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- bundles: Dict[str, AssetBundle] = state_field(default_factory=lambda: {})
- run_to_bundle: Dict[tuple[str, str], str] = state_field(default_factory=lambda: {})
- lock: object = state_field(default_factory=lambda: threading.Lock())
+ bundles: Dict[str, AssetBundle] = field(default_factory=lambda: {})
+ run_to_bundle: Dict[tuple[str, str], str] = field(default_factory=lambda: {})
+ lock: object = field(default_factory=lambda: threading.Lock())
-@state_cache(maxsize=1)
-def process_state() -> RuntimeBindingState:
- return RuntimeBindingState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = RuntimeBindingState()
+
+
+def process_state() -> RuntimeBindingState:
+ return _STATE
diff --git a/src/backend/core/evolution/cycle/skill_document.py b/src/backend/core/evolution/cycle/skill_document.py
index 7c6a27aa..c2ed5142 100644
--- a/src/backend/core/evolution/cycle/skill_document.py
+++ b/src/backend/core/evolution/cycle/skill_document.py
@@ -1,4 +1,4 @@
-"""skill document responsibilities."""
+"""Render distilled skills as SKILL.md documents."""
from __future__ import annotations
from typing import Any, Dict, List, Sequence
import logging
diff --git a/src/backend/core/evolution/cycle/skill_release.py b/src/backend/core/evolution/cycle/skill_release.py
index 0d0820c5..1eba4793 100644
--- a/src/backend/core/evolution/cycle/skill_release.py
+++ b/src/backend/core/evolution/cycle/skill_release.py
@@ -1,12 +1,13 @@
-"""skill release responsibilities."""
+"""Ramp, roll back and retire skill releases."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from core.db.engine import SessionLocal
import logging
+from core.evolution.cycle import activation_policy
+from core.evolution.cycle import activation_types
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import activation_policy as activation_policy_module
-from core.evolution.cycle import activation_types as activation_types_module
def advance_skill_release(
release_id: str,
@@ -29,14 +30,14 @@ def advance_skill_release(
from core.evolution.cycle.release import ACTIVE, CANARY, CANARY_STEPS, SHADOW, can_promote, evaluate_guardrails, next_traffic, should_auto_rollback
if not actor:
- raise activation_types_module.ActivationError("actor_required", "推进发布必须署名")
+ raise activation_types.ActivationError("actor_required", "推进发布必须署名")
with SessionLocal() as db:
release = db.get(EvolutionRelease, release_id)
if release is None:
- raise activation_types_module.ActivationError("release_not_found", f"发布记录不存在: {release_id}")
- if activation_policy_module.is_frozen(db):
- raise activation_types_module.ActivationError("globally_frozen", "全局冻结开关已打开,禁止推进发布")
+ raise activation_types.ActivationError("release_not_found", f"发布记录不存在: {release_id}")
+ if activation_policy.is_frozen(db):
+ raise activation_types.ActivationError("globally_frozen", "全局冻结开关已打开,禁止推进发布")
candidate = db.get(EvolutionCandidate, release.candidate_id)
risk = str(getattr(candidate, "risk_tier", "") or "low")
@@ -76,7 +77,7 @@ def advance_skill_release(
target_stage, target_traffic = ACTIVE, 100
completed = sorted(set(completed) | {"canary"})
else:
- raise activation_types_module.ActivationError(
+ raise activation_types.ActivationError(
"not_rampable", f"当前阶段 {stage} 无法继续放量"
)
@@ -96,17 +97,17 @@ def advance_skill_release(
# A same-stage traffic bump is not a transition; can_promote would reject
# canary→canary, which is correct for stages and wrong for ramp steps.
if not allowed and not (stage == target_stage == CANARY):
- raise activation_types_module.ActivationError("promotion_refused", reason)
+ raise activation_types.ActivationError("promotion_refused", reason)
if target_stage == ACTIVE:
# 冲突的 L3 证据在 shadow/canary 里观测没问题,但升到全量必须先由
# 人裁决冲突本身——适用条件不可信的技能不配自动放大到所有人。
- if activation_policy_module.has_graph_conflict(candidate):
- raise activation_types_module.ActivationError(
+ if activation_policy.has_graph_conflict(candidate):
+ raise activation_types.ActivationError(
"graph_conflict_requires_review",
"候选的 L3 图谱证据存在未裁决的冲突,禁止放量到 active",
)
- activation_policy_module.check_pace(db, tenant_id=str((release.scope_filter or {}).get("tenant_id") or "default"))
+ activation_policy.check_pace(db, tenant_id=str((release.scope_filter or {}).get("tenant_id") or "default"))
release.stage = target_stage
release.traffic_percent = target_traffic
@@ -161,12 +162,12 @@ def rollback_skill_activation(
from core.evolution.cycle.release import ROLLBACK_GUARDRAIL, ROLLBACK_MANUAL, ROLLED_BACK
if not actor or not reason:
- raise activation_types_module.ActivationError("audit_required", "回滚必须署名并填写理由")
+ raise activation_types.ActivationError("audit_required", "回滚必须署名并填写理由")
with SessionLocal() as db:
release = db.get(EvolutionRelease, release_id)
if release is None:
- raise activation_types_module.ActivationError("release_not_found", f"发布记录不存在: {release_id}")
+ raise activation_types.ActivationError("release_not_found", f"发布记录不存在: {release_id}")
previous = (release.guardrails or {}).get("previous")
skill_id = release.target_asset_id
@@ -270,26 +271,28 @@ def retire_skill_candidate(candidate_id: str, *, approver: str) -> Dict[str, Any
anything built on top of it — a profile naming it, a sub-agent requiring it
— can be found and invalidated rather than silently pointing at nothing.
"""
+ from core.db.models.evolution import EvolutionCandidate, EvolutionRelease
+
from core.db.models import AdminSkill
from core.db.models.evolution import EvolutionAgentProfile
from core.evolution.cycle.release import RETIRED, new_release_id
if not approver:
- raise activation_types_module.ActivationError("approver_required", "退役必须署名")
+ raise activation_types.ActivationError("approver_required", "退役必须署名")
with SessionLocal() as db:
candidate = db.get(EvolutionCandidate, candidate_id)
if candidate is None:
- raise activation_types_module.ActivationError("candidate_not_found", f"候选不存在: {candidate_id}")
+ raise activation_types.ActivationError("candidate_not_found", f"候选不存在: {candidate_id}")
if candidate.operation != "deprecate":
- raise activation_types_module.ActivationError("not_a_retirement", "该候选不是退役候选")
+ raise activation_types.ActivationError("not_a_retirement", "该候选不是退役候选")
if approver == (candidate.proposer or ""):
- raise activation_types_module.ActivationError("self_approval_forbidden", "生成者不能批准自己提出的退役")
+ raise activation_types.ActivationError("self_approval_forbidden", "生成者不能批准自己提出的退役")
skill_id = candidate.target_asset_id
row = db.query(AdminSkill).filter(AdminSkill.skill_id == skill_id).first()
if row is None:
- raise activation_types_module.ActivationError("skill_not_found", f"技能不存在: {skill_id}")
+ raise activation_types.ActivationError("skill_not_found", f"技能不存在: {skill_id}")
previous_enabled = bool(row.is_enabled)
row.is_enabled = False
@@ -335,7 +338,7 @@ def retire_skill_candidate(candidate_id: str, *, approver: str) -> Dict[str, Any
# its cheaper carrier, so it goes back to being paid for by retrieval.
from core.evolution.generators.memory_apply import revert_memory_ops
- source_candidate = activation_policy_module.source_candidate_for(db, skill_id)
+ source_candidate = activation_policy.source_candidate_for(db, skill_id)
restored = revert_memory_ops(candidate_id=source_candidate) if source_candidate else {}
logger.info(
diff --git a/src/backend/core/evolution/cycle/timescales.py b/src/backend/core/evolution/cycle/timescales.py
index bcd93632..94ef52d6 100644
--- a/src/backend/core/evolution/cycle/timescales.py
+++ b/src/backend/core/evolution/cycle/timescales.py
@@ -17,7 +17,7 @@
import logging
from dataclasses import dataclass, field
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Sequence, Tuple
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/cycle/trigger.py b/src/backend/core/evolution/cycle/trigger.py
index 30f97d45..1a5339e3 100644
--- a/src/backend/core/evolution/cycle/trigger.py
+++ b/src/backend/core/evolution/cycle/trigger.py
@@ -31,7 +31,8 @@
import logging
import os
import threading
-from datetime import datetime, timedelta, timezone
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
from core.db.engine import SessionLocal
@@ -67,10 +68,6 @@ def _env_bool(name: str, default: bool) -> bool:
# Per-user debounce, so a busy person cannot crowd out everyone else's cycle.
-
-
-
-
def _now() -> datetime:
return datetime.now(timezone.utc)
@@ -78,7 +75,7 @@ def _now() -> datetime:
def _episode_count() -> int:
"""Cheap indexed count of mineable episodes."""
from core.db.models.evolution import EvolutionEpisode
- from core.evolution.profiles.user_settings_state import PRIVACY_PRIVATE
+ from core.evolution.profiles.user_settings_constants import PRIVACY_PRIVATE
with SessionLocal() as db:
return (
@@ -267,21 +264,17 @@ def reset_for_tests() -> None:
process_state().personal_last_run.clear()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class TriggerState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- state_lock: object = state_field(default_factory=lambda: threading.Lock())
- personal_last_run: Dict[str, datetime] = state_field(default_factory=lambda: {})
+ state_lock: object = field(default_factory=lambda: threading.Lock())
+ personal_last_run: Dict[str, datetime] = field(default_factory=lambda: {})
last_cycle_at: Optional[datetime] = None
last_cycle_episode_count: int = 0
running: object = False
-@state_cache(maxsize=1)
-def process_state() -> TriggerState:
- return TriggerState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = TriggerState()
+
+
+def process_state() -> TriggerState:
+ return _STATE
diff --git a/src/backend/core/evolution/cycle/usage_evidence.py b/src/backend/core/evolution/cycle/usage_evidence.py
index b89ecc52..105693ea 100644
--- a/src/backend/core/evolution/cycle/usage_evidence.py
+++ b/src/backend/core/evolution/cycle/usage_evidence.py
@@ -1,9 +1,10 @@
-"""usage evidence responsibilities."""
+"""Skill usage, canary uplift and exposure measured from episode evidence."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence, Tuple
import logging
+from core.evolution.cycle import cycle_extras_constants
+
logger = logging.getLogger(__name__)
-from core.evolution.cycle import cycle_extras_state as cycle_extras_state_module
def is_after(created_at: Any, activated_at: Any) -> bool:
from datetime import timezone
@@ -234,7 +235,7 @@ def uplift_from_canary(
# Both arms need enough turns for a rate to mean anything. A one-sided
# or tiny comparison produces a number that looks like evidence and is
# not, and this number can trigger an immediate rollback.
- if len(exposed) < cycle_extras_state_module.MIN_UPLIFT_SAMPLES or len(withheld) < cycle_extras_state_module.MIN_UPLIFT_SAMPLES:
+ if len(exposed) < cycle_extras_constants.MIN_UPLIFT_SAMPLES or len(withheld) < cycle_extras_constants.MIN_UPLIFT_SAMPLES:
uplift[skill_id] = None
continue
uplift[skill_id] = (sum(exposed) / len(exposed)) - (
diff --git a/src/backend/core/evolution/evidence/__init__.py b/src/backend/core/evolution/evidence/__init__.py
index 7bcae0aa..09947758 100644
--- a/src/backend/core/evolution/evidence/__init__.py
+++ b/src/backend/core/evolution/evidence/__init__.py
@@ -1 +1 @@
-"""evidence domain package."""
+"""Evidence for evolution: traces, replay cassettes, credit assignment and lineage."""
diff --git a/src/backend/core/evolution/evidence/cassette.py b/src/backend/core/evolution/evidence/cassette.py
index e1f23d2c..0b606d75 100644
--- a/src/backend/core/evolution/evidence/cassette.py
+++ b/src/backend/core/evolution/evidence/cassette.py
@@ -25,7 +25,7 @@
import json
import logging
from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
+from typing import Any, Dict, List, Optional, Sequence, Tuple
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/evidence/counterfactual.py b/src/backend/core/evolution/evidence/counterfactual.py
index 95a2ecda..82c566eb 100644
--- a/src/backend/core/evolution/evidence/counterfactual.py
+++ b/src/backend/core/evolution/evidence/counterfactual.py
@@ -30,7 +30,7 @@
import logging
from dataclasses import dataclass, field
-from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
+from typing import Any, Callable, Dict, List, Optional, Sequence
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/evidence/credit.py b/src/backend/core/evolution/evidence/credit.py
index 2744e713..bdd5e752 100644
--- a/src/backend/core/evolution/evidence/credit.py
+++ b/src/backend/core/evolution/evidence/credit.py
@@ -26,7 +26,7 @@
import logging
from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Tuple
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/evidence/exposure.py b/src/backend/core/evolution/evidence/exposure.py
index 0fc9970e..66f73d52 100644
--- a/src/backend/core/evolution/evidence/exposure.py
+++ b/src/backend/core/evolution/evidence/exposure.py
@@ -30,9 +30,9 @@
from __future__ import annotations
import logging
-from typing import Dict, Iterable, List, Optional, Sequence, Set
+from typing import Dict, Iterable, List, Sequence, Set
-from core.evolution.cycle.activation_state import EVOLUTION_PREFIX
+from core.evolution.cycle.activation_constants import EVOLUTION_PREFIX
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/evidence/trace_assembler.py b/src/backend/core/evolution/evidence/trace_assembler.py
index 49ba08ca..d161ae69 100644
--- a/src/backend/core/evolution/evidence/trace_assembler.py
+++ b/src/backend/core/evolution/evidence/trace_assembler.py
@@ -24,7 +24,7 @@
import re
import uuid
from datetime import datetime, timezone
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Optional
from core.db.engine import SessionLocal
from core.db.models.evolution import EvolutionEpisode, EvolutionTraceEvent
diff --git a/src/backend/core/evolution/evidence/trace_store.py b/src/backend/core/evolution/evidence/trace_store.py
index 153ecc04..944bd1ba 100644
--- a/src/backend/core/evolution/evidence/trace_store.py
+++ b/src/backend/core/evolution/evidence/trace_store.py
@@ -12,7 +12,7 @@
from __future__ import annotations
import logging
-from typing import Iterable, List, Optional, Sequence
+from typing import List, Sequence
from core.db.engine import SessionLocal
from core.db.models.evolution import EvolutionTraceEvent
diff --git a/src/backend/core/evolution/generators/__init__.py b/src/backend/core/evolution/generators/__init__.py
index db8de9e4..6b0bafac 100644
--- a/src/backend/core/evolution/generators/__init__.py
+++ b/src/backend/core/evolution/generators/__init__.py
@@ -1 +1 @@
-"""generators domain package."""
+"""Generate assets from evolution candidates: skills and orchestration profiles."""
diff --git a/src/backend/core/evolution/generators/memory_ops.py b/src/backend/core/evolution/generators/memory_ops.py
index 7b749c30..ce55fd39 100644
--- a/src/backend/core/evolution/generators/memory_ops.py
+++ b/src/backend/core/evolution/generators/memory_ops.py
@@ -25,7 +25,7 @@
import logging
from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional, Sequence, Tuple
+from typing import Any, Dict, List, Sequence, Tuple
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/generators/skill_candidate_engine.py b/src/backend/core/evolution/generators/skill_candidate_engine.py
index 2f6350a8..3b234a90 100644
--- a/src/backend/core/evolution/generators/skill_candidate_engine.py
+++ b/src/backend/core/evolution/generators/skill_candidate_engine.py
@@ -18,7 +18,7 @@
"""
from __future__ import annotations
-from core.evolution.generators import skill_gen as skill_gen_module
+from core.evolution.generators import skill_gen
import logging
from dataclasses import dataclass, field
@@ -102,10 +102,9 @@ def create_skill_candidate(
if spec.write_document:
if run_async is None:
return {"status": STATUS_REJECTED, "code": "no_async_runner"}
- import core.evolution.generators.skill_gen as skill_gen
document, draft = run_async(
- skill_gen_module.generate_skill_document(
+ skill_gen.generate_skill_document(
skill_id=spec.asset_id,
episodes=spec.views,
ordering_constraints=spec.ordering_constraints,
diff --git a/src/backend/core/evolution/generators/skill_drafting.py b/src/backend/core/evolution/generators/skill_drafting.py
index ad5fc1ed..89902db3 100644
--- a/src/backend/core/evolution/generators/skill_drafting.py
+++ b/src/backend/core/evolution/generators/skill_drafting.py
@@ -1,9 +1,9 @@
-"""skill drafting responsibilities."""
+"""Prompt, parse and validate a model-drafted skill body."""
from __future__ import annotations
import re
from typing import Any, List, Sequence, Tuple
-from core.evolution.generators import skill_evidence as skill_evidence_module
-from core.evolution.generators import skill_gen_state as skill_gen_state_module
+from core.evolution.generators import skill_evidence
+from core.evolution.generators import skill_gen_constants
def format_list(items: Sequence[Any], empty: str = "(无)") -> str:
if not items:
@@ -19,7 +19,7 @@ def format_list(items: Sequence[Any], empty: str = "(无)") -> str:
return "\n".join(lines)
-def build_prompt(pack: skill_evidence_module.EvidencePack, violations: Sequence[str]) -> str:
+def build_prompt(pack: skill_evidence.EvidencePack, violations: Sequence[str]) -> str:
exclusion_hint = (
f"证据显示本技能在以下任务类型中被推翻,必须写进不适用范围:{'、'.join(pack.excluded_task_types)}。"
if pack.excluded_task_types
@@ -32,7 +32,7 @@ def build_prompt(pack: skill_evidence_module.EvidencePack, violations: Sequence[
+ "\n".join(f"- {v}" for v in violations)
+ "\n"
)
- return skill_gen_state_module.DRAFT_PROMPT.format(
+ return skill_gen_constants.DRAFT_PROMPT.format(
objective_count=len(pack.objectives),
objectives=format_list(pack.objectives[:10]),
tools=format_list([f"`{t}`" for t in pack.tools]),
@@ -57,7 +57,7 @@ def build_prompt(pack: skill_evidence_module.EvidencePack, violations: Sequence[
]
),
exclusion_hint=exclusion_hint,
- max_chars=skill_gen_state_module.MAX_BODY_CHARS,
+ max_chars=skill_gen_constants.MAX_BODY_CHARS,
retry_hint=retry_hint,
)
@@ -99,28 +99,28 @@ def validate_draft(
violations: List[str] = []
if not body.strip():
- return [skill_gen_state_module.REJECT_EMPTY]
+ return [skill_gen_constants.REJECT_EMPTY]
if not display_name.strip() or not description.strip():
- violations.append(skill_gen_state_module.REJECT_NO_FRONTMATTER)
- if not skill_gen_state_module.FRONTMATTER_NAME.match(skill_id or ""):
+ violations.append(skill_gen_constants.REJECT_NO_FRONTMATTER)
+ if not skill_gen_constants.FRONTMATTER_NAME.match(skill_id or ""):
# The loader treats ``name`` as the id and rejects anything outside
# ``[a-z0-9_-]``. A skill that fails this still appears in the capability
# list — that metadata comes from table columns — while loading returns
# nothing, so it is present and inert, which is the hardest failure of
# all to notice.
- violations.append(skill_gen_state_module.REJECT_BAD_NAME)
+ violations.append(skill_gen_constants.REJECT_BAD_NAME)
- if skill_gen_state_module.CODE_FENCE.search(body):
- violations.append(skill_gen_state_module.REJECT_CODE_BLOCK)
- if skill_gen_state_module.URL.search(body):
- violations.append(skill_gen_state_module.REJECT_EXTERNAL_URL)
- if len(body) > skill_gen_state_module.MAX_BODY_CHARS:
- violations.append(skill_gen_state_module.REJECT_TOO_LONG)
- if len(body) < skill_gen_state_module.MIN_BODY_CHARS:
- violations.append(skill_gen_state_module.REJECT_TOO_SHORT)
+ if skill_gen_constants.CODE_FENCE.search(body):
+ violations.append(skill_gen_constants.REJECT_CODE_BLOCK)
+ if skill_gen_constants.URL.search(body):
+ violations.append(skill_gen_constants.REJECT_EXTERNAL_URL)
+ if len(body) > skill_gen_constants.MAX_BODY_CHARS:
+ violations.append(skill_gen_constants.REJECT_TOO_LONG)
+ if len(body) < skill_gen_constants.MIN_BODY_CHARS:
+ violations.append(skill_gen_constants.REJECT_TOO_SHORT)
if "## 不适用范围" not in body:
- violations.append(skill_gen_state_module.REJECT_NO_SCOPE)
+ violations.append(skill_gen_constants.REJECT_NO_SCOPE)
else:
# The exclusion has to precede anything the model could copy: measured,
# withholding a contradicted rule was not enough, because the step list
@@ -128,12 +128,12 @@ def validate_draft(
scope_at = body.index("## 不适用范围")
earlier_section = re.search(r"^## ", body[:scope_at], re.MULTILINE)
if earlier_section:
- violations.append(skill_gen_state_module.REJECT_NO_SCOPE)
+ violations.append(skill_gen_constants.REJECT_NO_SCOPE)
if "## 常见失败与处理" not in body and "## 步骤" not in body:
- violations.append(skill_gen_state_module.REJECT_NO_CHECKPOINTS)
+ violations.append(skill_gen_constants.REJECT_NO_CHECKPOINTS)
allowed = {str(t) for t in allowed_tools}
- mentioned = {m for m in skill_gen_state_module.TOOL_MENTION.findall(body)}
+ mentioned = {m for m in skill_gen_constants.TOOL_MENTION.findall(body)}
# Only identifiers that look like tool names are checked: prose in backticks
# (a field name, a value) must not be mistaken for a tool grant, and a real
# tool name always appears verbatim in the evidence.
@@ -143,7 +143,7 @@ def validate_draft(
if name not in allowed and "_" in name and name.islower()
)
if unknown:
- violations.append(f"{skill_gen_state_module.REJECT_UNKNOWN_TOOL}: {unknown}")
+ violations.append(f"{skill_gen_constants.REJECT_UNKNOWN_TOOL}: {unknown}")
return violations
diff --git a/src/backend/core/evolution/generators/skill_evidence.py b/src/backend/core/evolution/generators/skill_evidence.py
index c7c7841c..74a2c836 100644
--- a/src/backend/core/evolution/generators/skill_evidence.py
+++ b/src/backend/core/evolution/generators/skill_evidence.py
@@ -1,4 +1,4 @@
-"""skill evidence responsibilities."""
+"""The evidence pack a generated skill may be written from."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Sequence
diff --git a/src/backend/core/evolution/generators/skill_gen.py b/src/backend/core/evolution/generators/skill_gen.py
index a2e4a75b..0da410d6 100644
--- a/src/backend/core/evolution/generators/skill_gen.py
+++ b/src/backend/core/evolution/generators/skill_gen.py
@@ -1,13 +1,14 @@
-"""skill gen responsibilities."""
+"""Generate a skill document from evidence, retrying on validation failures."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence, Tuple
import logging
+from core.evolution.generators import skill_drafting
+from core.evolution.generators import skill_evidence
+from core.evolution.generators import skill_gen_constants
+
logger = logging.getLogger(__name__)
-from core.evolution.generators import skill_drafting as skill_drafting_module
-from core.evolution.generators import skill_evidence as skill_evidence_module
-from core.evolution.generators import skill_gen_state as skill_gen_state_module
-async def draft_skill(pack: skill_evidence_module.EvidencePack) -> skill_evidence_module.DraftResult:
+async def draft_skill(pack: skill_evidence.EvidencePack) -> skill_evidence.DraftResult:
"""Draft and validate, retrying with the specific violations.
Returns ``ok=False`` rather than raising. A skill that cannot be written to
@@ -18,19 +19,19 @@ async def draft_skill(pack: skill_evidence_module.EvidencePack) -> skill_evidenc
from core.memory.extractors._base import run_llm_with_prompt
violations: List[str] = []
- for attempt in range(1, skill_gen_state_module.MAX_ATTEMPTS + 1):
- prompt = skill_drafting_module.build_prompt(pack, violations)
- raw = await run_llm_with_prompt(prompt, timeout_s=skill_gen_state_module.DRAFT_TIMEOUT_S, max_tokens=2400)
+ for attempt in range(1, skill_gen_constants.MAX_ATTEMPTS + 1):
+ prompt = skill_drafting.build_prompt(pack, violations)
+ raw = await run_llm_with_prompt(prompt, timeout_s=skill_gen_constants.DRAFT_TIMEOUT_S, max_tokens=2400)
if raw is None:
- return skill_evidence_module.DraftResult(
+ return skill_evidence.DraftResult(
ok=False,
violations=["generator_unavailable"],
attempts=attempt,
generator_unavailable=True,
)
- display_name, description, body = skill_drafting_module.parse_draft(raw)
- violations = skill_drafting_module.validate_draft(
+ display_name, description, body = skill_drafting.parse_draft(raw)
+ violations = skill_drafting.validate_draft(
skill_id=pack.skill_id,
display_name=display_name,
description=description,
@@ -38,7 +39,7 @@ async def draft_skill(pack: skill_evidence_module.EvidencePack) -> skill_evidenc
allowed_tools=pack.tools,
)
if not violations:
- return skill_evidence_module.DraftResult(
+ return skill_evidence.DraftResult(
ok=True,
body=body,
display_name=display_name[:60],
@@ -50,7 +51,7 @@ async def draft_skill(pack: skill_evidence_module.EvidencePack) -> skill_evidenc
attempt, pack.skill_id, violations,
)
- return skill_evidence_module.DraftResult(ok=False, violations=violations, attempts=skill_gen_state_module.MAX_ATTEMPTS)
+ return skill_evidence.DraftResult(ok=False, violations=violations, attempts=skill_gen_constants.MAX_ATTEMPTS)
async def generate_skill_document(
@@ -60,16 +61,16 @@ async def generate_skill_document(
ordering_constraints: Sequence[Dict[str, Any]] = (),
procedures: Sequence[Dict[str, str]] = (),
rationale: str = "",
-) -> Tuple[Optional[Dict[str, str]], skill_evidence_module.DraftResult]:
+) -> Tuple[Optional[Dict[str, str]], skill_evidence.DraftResult]:
"""All three stages. Returns ``(document, result)``; document is ``None`` on failure."""
- pack = skill_evidence_module.build_evidence_pack(
+ pack = skill_evidence.build_evidence_pack(
skill_id=skill_id,
episodes=episodes,
ordering_constraints=ordering_constraints,
procedures=procedures,
)
if not pack.tools and not pack.procedures:
- return None, skill_evidence_module.DraftResult(ok=False, violations=["no_evidence_to_write_from"])
+ return None, skill_evidence.DraftResult(ok=False, violations=["no_evidence_to_write_from"])
result = await draft_skill(pack)
if not result.ok:
@@ -80,7 +81,7 @@ async def generate_skill_document(
"skill_id": skill_id,
"display_name": result.display_name,
"description": result.description,
- "content": skill_drafting_module.render_skill_markdown(
+ "content": skill_drafting.render_skill_markdown(
skill_id=skill_id,
display_name=result.display_name,
description=result.description,
diff --git a/src/backend/core/evolution/generators/skill_gen_state.py b/src/backend/core/evolution/generators/skill_gen_constants.py
similarity index 97%
rename from src/backend/core/evolution/generators/skill_gen_state.py
rename to src/backend/core/evolution/generators/skill_gen_constants.py
index a7137e51..c8bc9b4d 100644
--- a/src/backend/core/evolution/generators/skill_gen_state.py
+++ b/src/backend/core/evolution/generators/skill_gen_constants.py
@@ -1,4 +1,4 @@
-"""skill gen state responsibilities."""
+"""Limits and patterns used to validate generated skills."""
from __future__ import annotations
import re
diff --git a/src/backend/core/evolution/profiles/__init__.py b/src/backend/core/evolution/profiles/__init__.py
index 577c0b5e..b8789016 100644
--- a/src/backend/core/evolution/profiles/__init__.py
+++ b/src/backend/core/evolution/profiles/__init__.py
@@ -1 +1 @@
-"""profiles domain package."""
+"""Agent profiles and per-user evolution settings and reviews."""
diff --git a/src/backend/core/evolution/profiles/agent_profile.py b/src/backend/core/evolution/profiles/agent_profile.py
index 2e0bfdb1..80589412 100644
--- a/src/backend/core/evolution/profiles/agent_profile.py
+++ b/src/backend/core/evolution/profiles/agent_profile.py
@@ -1,8 +1,8 @@
-"""agent profile responsibilities."""
+"""Agent profiles: one ReAct configuration expressed as data, with validation."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
-from core.evolution.profiles import agent_profile_state as agent_profile_state_module
+from core.evolution.profiles import agent_profile_constants
@dataclass
class InterventionRule:
@@ -85,7 +85,7 @@ class AgentProfile:
# 0 = 不限(默认)。See UNBOUNDED_REACT_TURNS: the main agent's round ceiling
# was removed, so a turn budget here is an opt-in narrowing a published
# profile performs deliberately, not a floor every profile inherits.
- max_react_turns: int = agent_profile_state_module.UNBOUNDED_REACT_TURNS
+ max_react_turns: int = agent_profile_constants.UNBOUNDED_REACT_TURNS
budget_multiplier: float = 1.0
reviewer_checkpoints: List[str] = field(default_factory=list)
intervention_rules: List[InterventionRule] = field(default_factory=list)
@@ -135,7 +135,7 @@ def from_dict(cls, raw: Dict[str, Any]) -> "AgentProfile":
# Missing key → unbounded, not an invented ceiling: a profile
# published for unrelated reasons (tool allowlist, memory budget)
# must not silently re-fence the main loop.
- max_react_turns=int(raw.get("max_react_turns") or agent_profile_state_module.UNBOUNDED_REACT_TURNS),
+ max_react_turns=int(raw.get("max_react_turns") or agent_profile_constants.UNBOUNDED_REACT_TURNS),
budget_multiplier=float(raw.get("budget_multiplier") or 1.0),
reviewer_checkpoints=[str(c) for c in (raw.get("reviewer_checkpoints") or [])],
intervention_rules=[
@@ -166,15 +166,15 @@ def builtin_profile() -> AgentProfile:
# The main agent's runtime default, reproduced exactly — which is now
# "no cap". This field only bounds the loop when a published profile
# sets it on purpose.
- max_react_turns=agent_profile_state_module.UNBOUNDED_REACT_TURNS,
+ max_react_turns=agent_profile_constants.UNBOUNDED_REACT_TURNS,
budget_multiplier=1.0,
loop_max_attempts_per_requirement=6,
loop_strategy_change_after=2,
intervention_rules=[
- InterventionRule(agent_profile_state_module.SIGNAL_NO_PROGRESS, 2, agent_profile_state_module.ACTION_CHANGE_STRATEGY),
- InterventionRule(agent_profile_state_module.SIGNAL_REPEATED_ACTIONS, 3, agent_profile_state_module.ACTION_NARROW_SCOPE),
- InterventionRule(agent_profile_state_module.SIGNAL_TOOL_ERROR_STREAK, 3, agent_profile_state_module.ACTION_CHANGE_STRATEGY),
- InterventionRule(agent_profile_state_module.SIGNAL_BUDGET_LOW, 1, agent_profile_state_module.ACTION_ESCALATE),
+ InterventionRule(agent_profile_constants.SIGNAL_NO_PROGRESS, 2, agent_profile_constants.ACTION_CHANGE_STRATEGY),
+ InterventionRule(agent_profile_constants.SIGNAL_REPEATED_ACTIONS, 3, agent_profile_constants.ACTION_NARROW_SCOPE),
+ InterventionRule(agent_profile_constants.SIGNAL_TOOL_ERROR_STREAK, 3, agent_profile_constants.ACTION_CHANGE_STRATEGY),
+ InterventionRule(agent_profile_constants.SIGNAL_BUDGET_LOW, 1, agent_profile_constants.ACTION_ESCALATE),
],
)
@@ -220,38 +220,38 @@ def validate_profile(
problems.append(f"路由指向不存在的子智能体: {route.agent_id}")
for rule in profile.intervention_rules:
- if rule.signal not in agent_profile_state_module.ALLOWED_SIGNALS:
+ if rule.signal not in agent_profile_constants.ALLOWED_SIGNALS:
problems.append(f"未知信号 {rule.signal}")
- if rule.action not in agent_profile_state_module.ALLOWED_ACTIONS:
+ if rule.action not in agent_profile_constants.ALLOWED_ACTIONS:
problems.append(f"未知动作 {rule.action}")
if rule.threshold < 1:
problems.append(f"{rule.signal} 阈值必须 ≥1(0 会让干预立刻触发)")
- if not (agent_profile_state_module.MIN_BUDGET_MULTIPLIER <= profile.budget_multiplier <= agent_profile_state_module.MAX_BUDGET_MULTIPLIER):
+ if not (agent_profile_constants.MIN_BUDGET_MULTIPLIER <= profile.budget_multiplier <= agent_profile_constants.MAX_BUDGET_MULTIPLIER):
problems.append(
f"预算倍数 {profile.budget_multiplier} 超出 "
- f"[{agent_profile_state_module.MIN_BUDGET_MULTIPLIER}, {agent_profile_state_module.MAX_BUDGET_MULTIPLIER}]"
+ f"[{agent_profile_constants.MIN_BUDGET_MULTIPLIER}, {agent_profile_constants.MAX_BUDGET_MULTIPLIER}]"
)
- if profile.max_react_turns != agent_profile_state_module.UNBOUNDED_REACT_TURNS and not (
- agent_profile_state_module.MIN_REACT_TURNS <= profile.max_react_turns <= agent_profile_state_module.MAX_REACT_TURNS
+ if profile.max_react_turns != agent_profile_constants.UNBOUNDED_REACT_TURNS and not (
+ agent_profile_constants.MIN_REACT_TURNS <= profile.max_react_turns <= agent_profile_constants.MAX_REACT_TURNS
):
problems.append(
f"最大轮数 {profile.max_react_turns} 超出 "
- f"[{agent_profile_state_module.MIN_REACT_TURNS}, {agent_profile_state_module.MAX_REACT_TURNS}]({agent_profile_state_module.UNBOUNDED_REACT_TURNS} 表示不限)"
+ f"[{agent_profile_constants.MIN_REACT_TURNS}, {agent_profile_constants.MAX_REACT_TURNS}]({agent_profile_constants.UNBOUNDED_REACT_TURNS} 表示不限)"
)
- if not (agent_profile_state_module.MIN_MEMORY_BUDGET_MS <= profile.memory_budget_ms <= agent_profile_state_module.MAX_MEMORY_BUDGET_MS):
+ if not (agent_profile_constants.MIN_MEMORY_BUDGET_MS <= profile.memory_budget_ms <= agent_profile_constants.MAX_MEMORY_BUDGET_MS):
problems.append(
f"记忆检索预算 {profile.memory_budget_ms}ms 超出 "
- f"[{agent_profile_state_module.MIN_MEMORY_BUDGET_MS}, {agent_profile_state_module.MAX_MEMORY_BUDGET_MS}]"
+ f"[{agent_profile_constants.MIN_MEMORY_BUDGET_MS}, {agent_profile_constants.MAX_MEMORY_BUDGET_MS}]"
)
if not (
- agent_profile_state_module.MIN_LOOP_ATTEMPTS
+ agent_profile_constants.MIN_LOOP_ATTEMPTS
<= profile.loop_max_attempts_per_requirement
- <= agent_profile_state_module.MAX_LOOP_ATTEMPTS
+ <= agent_profile_constants.MAX_LOOP_ATTEMPTS
):
problems.append(
f"单需求最大尝试次数 {profile.loop_max_attempts_per_requirement} 超出 "
- f"[{agent_profile_state_module.MIN_LOOP_ATTEMPTS}, {agent_profile_state_module.MAX_LOOP_ATTEMPTS}]"
+ f"[{agent_profile_constants.MIN_LOOP_ATTEMPTS}, {agent_profile_constants.MAX_LOOP_ATTEMPTS}]"
)
if profile.loop_strategy_change_after < 1:
problems.append("换策略阈值必须 ≥1(0 会让每一轮都换策略)")
diff --git a/src/backend/core/evolution/profiles/agent_profile_state.py b/src/backend/core/evolution/profiles/agent_profile_constants.py
similarity index 95%
rename from src/backend/core/evolution/profiles/agent_profile_state.py
rename to src/backend/core/evolution/profiles/agent_profile_constants.py
index 1242c63c..8f937ff1 100644
--- a/src/backend/core/evolution/profiles/agent_profile_state.py
+++ b/src/backend/core/evolution/profiles/agent_profile_constants.py
@@ -1,4 +1,4 @@
-"""agent profile state responsibilities."""
+"""Signals, actions and limits allowed in agent profiles."""
from __future__ import annotations
diff --git a/src/backend/core/evolution/profiles/change_preview.py b/src/backend/core/evolution/profiles/change_preview.py
index 7c7128f0..b5d1ae64 100644
--- a/src/backend/core/evolution/profiles/change_preview.py
+++ b/src/backend/core/evolution/profiles/change_preview.py
@@ -1,4 +1,4 @@
-"""change preview responsibilities."""
+"""Show the concrete content of a proposed evolution change."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
diff --git a/src/backend/core/evolution/profiles/federation.py b/src/backend/core/evolution/profiles/federation.py
index f42b10f7..e42a1528 100644
--- a/src/backend/core/evolution/profiles/federation.py
+++ b/src/backend/core/evolution/profiles/federation.py
@@ -26,7 +26,7 @@
import logging
import re
from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional, Sequence, Tuple
+from typing import Any, Dict, List, Sequence
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/evolution/profiles/personal.py b/src/backend/core/evolution/profiles/personal.py
index 5b6eb0f8..039f8240 100644
--- a/src/backend/core/evolution/profiles/personal.py
+++ b/src/backend/core/evolution/profiles/personal.py
@@ -22,21 +22,19 @@
"""
from __future__ import annotations
-from core.evolution.cycle import ordering as ordering_module
-from core.evolution.cycle import patterns as patterns_module
-from core.evolution.cycle import promotion as promotion_module
-from core.evolution.cycle import promotion_state as promotion_state_module
+from core.evolution.cycle import ordering
+from core.evolution.cycle import patterns
+from core.evolution.cycle import promotion
+from core.evolution.cycle import promotion_constants
import hashlib
import logging
from typing import Any, Dict, List, Optional
from core.db.engine import SessionLocal
-from core.evolution.cycle import promotion as P
from core.evolution.evidence.credit import T_SKILL, aggregate_finding, confidence_from_evidence
from core.evolution.ir import (
RISK_LOW,
- RISK_MEDIUM,
EvolutionIR,
InvariantViolation,
Rollback,
@@ -100,14 +98,14 @@ def load_personal_episodes(user_id: str, *, limit: int = 300) -> List[Dict[str,
return views
-def _discover(views: List[Dict[str, Any]]) -> List[patterns_module.Pattern]:
+def _discover(views: List[Dict[str, Any]]) -> List[patterns.Pattern]:
"""Pattern discovery at the personal support threshold."""
- original = promotion_state_module.MIN_SUPPORT
+ original = promotion_constants.MIN_SUPPORT
try:
- promotion_state_module.MIN_SUPPORT = PERSONAL_MIN_SUPPORT
- return patterns_module.discover_patterns(views)
+ promotion_constants.MIN_SUPPORT = PERSONAL_MIN_SUPPORT
+ return patterns.discover_patterns(views)
finally:
- promotion_state_module.MIN_SUPPORT = original
+ promotion_constants.MIN_SUPPORT = original
def run_personal_cycle(
@@ -155,17 +153,17 @@ def run_personal_cycle(
if not views:
return report
- patterns = _discover(views)
- report["patterns"] = len(patterns)
- constraints = ordering_module.extract_ordering_constraints(
+ discovered = _discover(views)
+ report["patterns"] = len(discovered)
+ constraints = ordering.extract_ordering_constraints(
views, min_support=PERSONAL_MIN_SUPPORT, min_contexts=1
)
report["ordering_constraints"] = [c.to_dict() for c in constraints]
known_concepts = _known_concepts(prefs)
- for pattern in patterns:
- if pattern.kind != promotion_state_module.PATTERN_SUCCESS_SUBSEQUENCE or not pattern.tool_sequence:
+ for pattern in discovered:
+ if pattern.kind != promotion_constants.PATTERN_SUCCESS_SUBSEQUENCE or not pattern.tool_sequence:
continue
# 这是"多次成功里观察到的共性",不是某次失败的归因,所以走聚合决策。
# 旧实现在这里伪造 outcome_verdict="failed" 逼归因器选中技能引擎——
@@ -187,7 +185,7 @@ def run_personal_cycle(
# min_support 必须显式传入:发现阶段用的个人门槛(3)在 _discover 返回
# 后已恢复为全局值(5),不传的话支持度 3–4 的个人模式会在这里被静默拒绝。
- proposal = promotion_module.promote_tool_sequence_to_skill(
+ proposal = promotion.promote_tool_sequence_to_skill(
pattern,
episodes=views,
min_support=PERSONAL_MIN_SUPPORT,
diff --git a/src/backend/core/evolution/profiles/personal_review.py b/src/backend/core/evolution/profiles/personal_review.py
index d6e1e36f..2727e425 100644
--- a/src/backend/core/evolution/profiles/personal_review.py
+++ b/src/backend/core/evolution/profiles/personal_review.py
@@ -1,11 +1,12 @@
-"""personal review responsibilities."""
+"""Let users review, approve and activate candidates for themselves."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from core.db.engine import SessionLocal
import logging
+from core.evolution.profiles import change_preview
+from core.evolution.profiles import user_settings_constants
+
logger = logging.getLogger(__name__)
-from core.evolution.profiles import change_preview as change_preview_module
-from core.evolution.profiles import user_settings_state as user_settings_state_module
def personal_action(candidate) -> Optional[Dict[str, str]]:
"""What this user can actually do with a candidate, or ``None``.
@@ -156,12 +157,12 @@ def pending_for_user(user_id: str, *, limit: int = 20) -> List[Dict[str, Any]]:
continue
mine = refs & own_ids
share = len(mine) / len(refs)
- if share < user_settings_state_module.OWN_EVIDENCE_THRESHOLD:
+ if share < user_settings_constants.OWN_EVIDENCE_THRESHOLD:
continue
action = personal_action(candidate)
if action is None:
continue
- change_preview = change_preview_module.change_preview(candidate)
+ preview = change_preview.change_preview(candidate)
if str(candidate.target_kind or "") == "skill":
# Legacy personal cycles could promote a single repeated
# tool call as a "sequence". Such a candidate has neither
@@ -169,12 +170,12 @@ def pending_for_user(user_id: str, *, limit: int = 20) -> List[Dict[str, Any]]:
# approving it would install a tautological skill. Keep
# the evidence intact for audit, but do not present noise as
# a decision the user ought to make.
- if change_preview.get("type") == "skill_sequence":
- steps = change_preview.get("steps") or []
- rules = change_preview.get("ordering_constraints") or []
+ if preview.get("type") == "skill_sequence":
+ steps = preview.get("steps") or []
+ rules = preview.get("ordering_constraints") or []
if len(steps) < 2 and not rules:
continue
- elif change_preview.get("type") != "skill_document":
+ elif preview.get("type") != "skill_document":
# A skill with no inspectable content is not informed
# consent and would also fail materialisation later.
continue
@@ -189,14 +190,14 @@ def pending_for_user(user_id: str, *, limit: int = 20) -> List[Dict[str, Any]]:
"your_episodes": len(mine),
"total_evidence": len(refs),
"own_share": round(share, 3),
- "tool_sequence": change_preview_module.tool_sequence_of_ir(candidate.ir),
+ "tool_sequence": change_preview.tool_sequence_of_ir(candidate.ir),
# What the button does, and what it will change. Without
# these the row states a diagnosis and offers a verb that
# does not follow from it.
"action": action["action"],
"action_label": action["label"],
"action_effect": action["effect"],
- "change": change_preview,
+ "change": preview,
"created_at": candidate.created_at.isoformat()
if candidate.created_at
else None,
@@ -288,7 +289,7 @@ def summarise_contributions(user_id: str) -> Dict[str, Any]:
return empty
episode_ids = {e.episode_id for e in episodes}
- contributed = [e for e in episodes if e.privacy_class != user_settings_state_module.PRIVACY_PRIVATE]
+ contributed = [e for e in episodes if e.privacy_class != user_settings_constants.PRIVACY_PRIVATE]
memory_written = 0
for event in (
diff --git a/src/backend/core/evolution/profiles/policies.py b/src/backend/core/evolution/profiles/policies.py
index 1a4da55e..abfe1e10 100644
--- a/src/backend/core/evolution/profiles/policies.py
+++ b/src/backend/core/evolution/profiles/policies.py
@@ -29,7 +29,7 @@
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
-from core.evolution.profiles.agent_profile_state import ACTION_CHANGE_STRATEGY, ACTION_ESCALATE, ACTION_NARROW_SCOPE, ACTION_RETRY, ACTION_ROLLBACK_AND_FORK, ACTION_STOP, ALLOWED_ACTIONS, SIGNAL_BUDGET_LOW, SIGNAL_NO_DIFF, SIGNAL_NO_PROGRESS, SIGNAL_REPEATED_ACTIONS, SIGNAL_REVIEWER_FLAT
+from core.evolution.profiles.agent_profile_constants import ACTION_CHANGE_STRATEGY, ACTION_ESCALATE, ACTION_NARROW_SCOPE, ACTION_RETRY, ACTION_ROLLBACK_AND_FORK, ACTION_STOP, ALLOWED_ACTIONS, SIGNAL_BUDGET_LOW, SIGNAL_NO_DIFF, SIGNAL_NO_PROGRESS, SIGNAL_REPEATED_ACTIONS, SIGNAL_REVIEWER_FLAT
from core.evolution.profiles.agent_profile import AgentProfile, InterventionRule
from core.evolution.profiles.profile_selection import load_active_profile
diff --git a/src/backend/core/evolution/profiles/profile_selection.py b/src/backend/core/evolution/profiles/profile_selection.py
index 97d8ec9e..6d0fef54 100644
--- a/src/backend/core/evolution/profiles/profile_selection.py
+++ b/src/backend/core/evolution/profiles/profile_selection.py
@@ -1,10 +1,9 @@
-"""profile selection responsibilities."""
+"""Pick the agent profile that governs a run."""
from __future__ import annotations
-from core.evolution.profiles import agent_profile as agent_profile_module_
+from core.evolution.profiles import agent_profile
from typing import Any, Dict, List, Optional, Sequence, Tuple
import logging
logger = logging.getLogger(__name__)
-from core.evolution.profiles import agent_profile as agent_profile_module
def scope_rank(scope: Dict[str, str], *, user_id: str, tenant_id: str) -> Optional[int]:
"""How specific this profile is to the subject, or ``None`` if it is not.
@@ -22,18 +21,18 @@ def scope_rank(scope: Dict[str, str], *, user_id: str, tenant_id: str) -> Option
def pick_profile(
- profiles: Sequence[agent_profile_module_.AgentProfile],
+ profiles: Sequence[agent_profile.AgentProfile],
*,
task_type: str,
user_id: str = "",
tenant_id: str = "default",
-) -> Optional[agent_profile_module_.AgentProfile]:
+) -> Optional[agent_profile.AgentProfile]:
"""The one profile that governs this run, or ``None``.
A task-type-specific profile beats a general one at the same scope level:
the narrower statement is the more deliberate one.
"""
- ranked: List[Tuple[int, int, agent_profile_module_.AgentProfile]] = []
+ ranked: List[Tuple[int, int, agent_profile.AgentProfile]] = []
for profile in profiles:
if not profile.applies_to(task_type):
continue
@@ -47,7 +46,7 @@ def pick_profile(
return ranked[0][2]
-def profile_from_row(row: Any) -> agent_profile_module_.AgentProfile:
+def profile_from_row(row: Any) -> agent_profile.AgentProfile:
"""The profile a stored row describes, with the columns believed over payload.
``profile_id`` / ``version`` / ``task_types`` / ``scope`` exist as real
@@ -96,7 +95,7 @@ def profile_from_row(row: Any) -> agent_profile_module_.AgentProfile:
)
raw["scope"] = col_scope or payload_scope
- return agent_profile_module_.AgentProfile.from_dict(raw)
+ return agent_profile.AgentProfile.from_dict(raw)
def load_active_profile(
@@ -104,7 +103,7 @@ def load_active_profile(
task_type: str = "chat",
user_id: str = "",
tenant_id: str = "default",
-) -> agent_profile_module_.AgentProfile:
+) -> agent_profile.AgentProfile:
"""The profile in force for this run.
Read fresh on every call. Resolving once at import — which is what the old
@@ -127,14 +126,14 @@ def load_active_profile(
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 agent_profile_module_.builtin_profile()
+ return agent_profile.builtin_profile()
chosen = pick_profile(
profiles, task_type=task_type, user_id=user_id, tenant_id=tenant_id
)
if chosen is None:
- return agent_profile_module_.builtin_profile()
- ok, problems = agent_profile_module_.validate_profile(chosen)
+ return agent_profile.builtin_profile()
+ ok, problems = agent_profile.validate_profile(chosen)
if not ok:
# A stored profile that no longer validates is not applied. The
# alternative — applying it partially — produces a configuration nobody
@@ -144,5 +143,5 @@ def load_active_profile(
chosen.profile_id,
problems,
)
- return agent_profile_module_.builtin_profile()
+ return agent_profile.builtin_profile()
return chosen
diff --git a/src/backend/core/evolution/profiles/stall_hook.py b/src/backend/core/evolution/profiles/stall_hook.py
index bb871b0b..97b22316 100644
--- a/src/backend/core/evolution/profiles/stall_hook.py
+++ b/src/backend/core/evolution/profiles/stall_hook.py
@@ -31,7 +31,7 @@ def __call__(self, invocation):
return self.intervene(invocation)
def intervene(self, invocation):
- from core.evolution.profiles.agent_profile_state import ACTION_CHANGE_STRATEGY, ACTION_DELEGATE, ACTION_ESCALATE, ACTION_NARROW_SCOPE, ACTION_RETRY, ACTION_ROLLBACK_AND_FORK, ACTION_STOP
+ from core.evolution.profiles.agent_profile_constants import ACTION_CHANGE_STRATEGY, ACTION_DELEGATE, ACTION_ESCALATE, ACTION_NARROW_SCOPE, ACTION_RETRY, ACTION_ROLLBACK_AND_FORK, ACTION_STOP
applicable = [
rule
diff --git a/src/backend/core/evolution/profiles/user_settings.py b/src/backend/core/evolution/profiles/user_settings.py
index 5776e278..bb62a77e 100644
--- a/src/backend/core/evolution/profiles/user_settings.py
+++ b/src/backend/core/evolution/profiles/user_settings.py
@@ -1,10 +1,11 @@
-"""user settings responsibilities."""
+"""Read and update whether a user's episodes feed the evolution loop."""
from __future__ import annotations
from typing import Any, Dict, Optional
from core.db.engine import SessionLocal
import logging
+from core.evolution.profiles import user_settings_constants
+
logger = logging.getLogger(__name__)
-from core.evolution.profiles import user_settings_state as user_settings_state_module
def is_contribution_enabled(metadata: Optional[Dict[str, Any]]) -> bool:
"""Whether this user's episodes may feed the evolution loop.
@@ -25,13 +26,13 @@ def is_contribution_enabled(metadata: Optional[Dict[str, Any]]) -> bool:
def privacy_class_for(metadata: Optional[Dict[str, Any]]) -> str:
"""The Episode privacy class implied by the user's setting."""
- return user_settings_state_module.PRIVACY_TENANT if is_contribution_enabled(metadata) else user_settings_state_module.PRIVACY_PRIVATE
+ return user_settings_constants.PRIVACY_TENANT if is_contribution_enabled(metadata) else user_settings_constants.PRIVACY_PRIVATE
def resolve_for_user(user_id: str) -> Dict[str, Any]:
"""Read the setting for one user. Degrades to the default, never raises."""
if not user_id:
- return {user_settings_state_module.SETTING_KEY: False, "privacy_class": user_settings_state_module.PRIVACY_PRIVATE}
+ return {user_settings_constants.SETTING_KEY: False, "privacy_class": user_settings_constants.PRIVACY_PRIVATE}
try:
from core.db.models import UserShadow
@@ -43,7 +44,7 @@ def resolve_for_user(user_id: str) -> Dict[str, Any]:
metadata = {}
enabled = is_contribution_enabled(metadata)
- return {user_settings_state_module.SETTING_KEY: enabled, "privacy_class": privacy_class_for(metadata)}
+ return {user_settings_constants.SETTING_KEY: enabled, "privacy_class": privacy_class_for(metadata)}
def update_for_user(user_id: str, enabled: bool) -> Dict[str, Any]:
@@ -63,7 +64,7 @@ def update_for_user(user_id: str, enabled: bool) -> Dict[str, Any]:
merged = update_prefs(user_id, {"enabled": bool(enabled)})
on = bool(merged.get("enabled", False))
return {
- user_settings_state_module.SETTING_KEY: on,
- "privacy_class": user_settings_state_module.PRIVACY_TENANT if on else user_settings_state_module.PRIVACY_PRIVATE,
+ user_settings_constants.SETTING_KEY: on,
+ "privacy_class": user_settings_constants.PRIVACY_TENANT if on else user_settings_constants.PRIVACY_PRIVATE,
"note": "仅影响此后新产生的证据;已贡献的证据需单独撤回",
}
diff --git a/src/backend/core/evolution/profiles/user_settings_state.py b/src/backend/core/evolution/profiles/user_settings_constants.py
similarity index 72%
rename from src/backend/core/evolution/profiles/user_settings_state.py
rename to src/backend/core/evolution/profiles/user_settings_constants.py
index cccefbe8..7cceead8 100644
--- a/src/backend/core/evolution/profiles/user_settings_state.py
+++ b/src/backend/core/evolution/profiles/user_settings_constants.py
@@ -1,4 +1,4 @@
-"""user settings state responsibilities."""
+"""Setting key and privacy classes for evolution contributions."""
from __future__ import annotations
diff --git a/src/backend/core/evolution/settlement/__init__.py b/src/backend/core/evolution/settlement/__init__.py
index 73ba75b2..fc4d4e7c 100644
--- a/src/backend/core/evolution/settlement/__init__.py
+++ b/src/backend/core/evolution/settlement/__init__.py
@@ -1 +1 @@
-"""settlement domain package."""
+"""Settle evolution outcomes back into memory."""
diff --git a/src/backend/core/evolution/settlement/settlement_runner.py b/src/backend/core/evolution/settlement/settlement_runner.py
index 9cb0168b..fb990a70 100644
--- a/src/backend/core/evolution/settlement/settlement_runner.py
+++ b/src/backend/core/evolution/settlement/settlement_runner.py
@@ -37,8 +37,6 @@
MEMORY_WAIT_SECONDS = 45.0
-
-
@dataclass
class _PendingTurn:
message_id: str
@@ -53,8 +51,6 @@ class _PendingTurn:
timer: Optional[threading.Timer] = None
-
-
# Turns already settled. A late duplicate — the watchdog fired and the pipeline
# reported afterwards — must not settle a second time: the second pass would
# start from an empty entry and overwrite a good summary with an empty one.
@@ -206,18 +202,14 @@ def reset_for_tests() -> None:
_settled.clear()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class SettlementRunnerState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- lock: object = state_field(default_factory=lambda: threading.Lock())
- pending: Dict[str, _PendingTurn] = state_field(default_factory=lambda: {})
+ lock: object = field(default_factory=lambda: threading.Lock())
+ pending: Dict[str, _PendingTurn] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> SettlementRunnerState:
- return SettlementRunnerState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = SettlementRunnerState()
+
+
+def process_state() -> SettlementRunnerState:
+ return _STATE
diff --git a/src/backend/core/files/artifacts/local_reference.py b/src/backend/core/files/artifacts/local_reference.py
index 079c3359..15dc2ae6 100644
--- a/src/backend/core/files/artifacts/local_reference.py
+++ b/src/backend/core/files/artifacts/local_reference.py
@@ -48,7 +48,7 @@ def _allows_live_access(path: str, user_id: str, action: str = "read") -> bool:
platform="windows" if os.name == "nt" else "posix",
)
ticket = CURRENT_PERMISSION_TICKET.get()
- return verdict.decision == "allow" or bool(ticket and ticket.authorizes_path(path, action))
+ return verdict.decision == "allow" or bool(ticket and ticket.active and ticket.authorizes_path(path, action))
def reference_project_file(path: str, *, scope, user_id: str, name: str = "") -> dict:
diff --git a/src/backend/core/files/artifacts/service.py b/src/backend/core/files/artifacts/service.py
index c163fcde..42732f6c 100644
--- a/src/backend/core/files/artifacts/service.py
+++ b/src/backend/core/files/artifacts/service.py
@@ -143,8 +143,7 @@ def get_artifact(self, artifact_id: str, user_id: str) -> Optional[Dict[str, Any
# ── AI-generated artifact persistence (used by chat route + routing) ──────────
-# Relocated from ``api/routes/v1/chats/__init__.py`` so lower layers (``core.llm.tool``,
-# ``routing.*``) can persist artifacts without importing an API route module.
+# Lives in core so tools and routing can persist artifacts without importing an API route.
def persist_artifacts(
diff --git a/src/backend/core/files/artifacts/store.py b/src/backend/core/files/artifacts/store.py
index 6a1dacbf..3e245fb2 100644
--- a/src/backend/core/files/artifacts/store.py
+++ b/src/backend/core/files/artifacts/store.py
@@ -20,6 +20,7 @@
import logging
import os
import shutil
+from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
@@ -63,7 +64,6 @@
# Bucket directories already created in this process (see _ensure_bucket).
-
# ── Helper: get the current STORAGE_TYPE ──────────────────────────────────
def _storage_type() -> str:
return os.getenv("STORAGE_TYPE", "local").lower()
@@ -445,17 +445,13 @@ def prepare_records() -> int:
return restore_records_from_oss()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class StoreState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- known_buckets: set = state_field(default_factory=lambda: set())
+ known_buckets: set = field(default_factory=lambda: set())
-@state_cache(maxsize=1)
-def process_state() -> StoreState:
- return StoreState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = StoreState()
+
+
+def process_state() -> StoreState:
+ return _STATE
diff --git a/src/backend/core/files/artifacts/summary.py b/src/backend/core/files/artifacts/summary.py
index 8422c3fa..8849a393 100644
--- a/src/backend/core/files/artifacts/summary.py
+++ b/src/backend/core/files/artifacts/summary.py
@@ -15,7 +15,6 @@
import io
import logging
import os
-from typing import Optional
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/files/content/branding.py b/src/backend/core/files/content/branding.py
index 4d4a90f4..d4e6e33f 100644
--- a/src/backend/core/files/content/branding.py
+++ b/src/backend/core/files/content/branding.py
@@ -1,4 +1,4 @@
-"""branding responsibilities."""
+"""Product branding and page configuration stored as content."""
from __future__ import annotations
import re
from datetime import datetime, timezone
@@ -6,7 +6,7 @@
from core.config.settings import settings
from core.db.models import ContentBlock
from sqlalchemy.orm import Session
-from core.files.content import content_blocks_state
+from core.files.content import content_blocks_constants
def normalize_app_config(payload: Any) -> dict[str, Any]:
"""Normalize app_config payload to the canonical `{"apps": [...]}` shape.
@@ -15,7 +15,7 @@ def normalize_app_config(payload: Any) -> dict[str, Any]:
falls back to any default entries.
"""
if not isinstance(payload, dict):
- return {"apps": list(content_blocks_state.DEFAULT_APP_CONFIG["apps"])}
+ return {"apps": list(content_blocks_constants.DEFAULT_APP_CONFIG["apps"])}
apps_raw = payload.get("apps")
if isinstance(apps_raw, list):
@@ -38,17 +38,17 @@ def normalize_app_config(payload: Any) -> dict[str, Any]:
)
return {"apps": apps}
- return {"apps": list(content_blocks_state.DEFAULT_APP_CONFIG["apps"])}
+ return {"apps": list(content_blocks_constants.DEFAULT_APP_CONFIG["apps"])}
def default_payload(alias: str) -> Any:
if alias == "page_config":
- return content_blocks_state.DEFAULT_PAGE_CONFIG
+ return content_blocks_constants.DEFAULT_PAGE_CONFIG
if alias == "app_config":
- return content_blocks_state.DEFAULT_APP_CONFIG
+ return content_blocks_constants.DEFAULT_APP_CONFIG
if alias == "prompt_versions":
- return content_blocks_state.DEFAULT_PROMPT_VERSIONS
- return {} if alias in content_blocks_state.DICT_PAYLOAD_BLOCKS else []
+ return content_blocks_constants.DEFAULT_PROMPT_VERSIONS
+ return {} if alias in content_blocks_constants.DICT_PAYLOAD_BLOCKS else []
def seed_page_config_if_missing(db: Session) -> bool:
@@ -59,7 +59,7 @@ def seed_page_config_if_missing(db: Session) -> bool:
db.add(
ContentBlock(
id="page_config",
- payload=content_blocks_state.DEFAULT_PAGE_CONFIG,
+ payload=content_blocks_constants.DEFAULT_PAGE_CONFIG,
updated_at=datetime.now(timezone.utc),
updated_by="system_seed",
)
@@ -141,10 +141,10 @@ def backfill_navigation_entries(db: Session) -> int:
if not isinstance(nav, dict):
return 0 # malformed payload — leave it alone, admin UI will surface
- default_nav = content_blocks_state.DEFAULT_PAGE_CONFIG["navigation"]
+ default_nav = content_blocks_constants.DEFAULT_PAGE_CONFIG["navigation"]
changed = 0
- for entry in content_blocks_state.NAV_BACKFILL_ENTRIES:
+ for entry in content_blocks_constants.NAV_BACKFILL_ENTRIES:
key = entry["key"]
bucket_field = "menu_items" if entry["bucket"] == "menu" else "sidebar_items"
items = nav.get(bucket_field)
@@ -174,7 +174,7 @@ def backfill_navigation_entries(db: Session) -> int:
changed += 1
# 旧出厂文案改名(能力中心统一表述),与上面的补齐共用一次提交
- for field, renames in content_blocks_state.LEGACY_PANEL_TEXT_RENAMES.items():
+ for field, renames in content_blocks_constants.LEGACY_PANEL_TEXT_RENAMES.items():
target = nav.get(field)
if not isinstance(target, dict):
continue
@@ -204,8 +204,8 @@ def get_branding_info() -> dict[str, str]:
own DB session internally.
"""
defaults = {
- "product_name": content_blocks_state.DEFAULT_PAGE_CONFIG["branding"]["product_name"],
- "logo_url": content_blocks_state.DEFAULT_PAGE_CONFIG["branding"]["logo_url"],
+ "product_name": content_blocks_constants.DEFAULT_PAGE_CONFIG["branding"]["product_name"],
+ "logo_url": content_blocks_constants.DEFAULT_PAGE_CONFIG["branding"]["logo_url"],
}
try:
from core.db.engine import SessionLocal
@@ -261,7 +261,7 @@ def get_admin_platform_info() -> dict[str, str]:
mergeAdminPlatform normalization. Reused by server-side rendering paths
(Swagger / ReDoc doc page titles etc.); manages its own DB session internally.
"""
- ap_def = content_blocks_state.DEFAULT_PAGE_CONFIG["navigation"]["admin_platform"]
+ ap_def = content_blocks_constants.DEFAULT_PAGE_CONFIG["navigation"]["admin_platform"]
defaults = {
"product_name": ap_def["product_name"],
"content_label": ap_def["content_label"],
diff --git a/src/backend/core/files/content/content_blocks.py b/src/backend/core/files/content/content_blocks.py
index e5126b51..d7d2e5d5 100644
--- a/src/backend/core/files/content/content_blocks.py
+++ b/src/backend/core/files/content/content_blocks.py
@@ -1,11 +1,11 @@
-"""content blocks responsibilities."""
+"""Export and import content blocks and the prompt pool as snapshots."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Mapping
from core.db.models import ContentBlock
from sqlalchemy.orm import Session
from core.files.content import branding
-from core.files.content import content_blocks_state
+from core.files.content import content_blocks_constants
class ContentSnapshotError(ValueError):
"""Raised when a docs content snapshot is invalid."""
@@ -21,7 +21,7 @@ def serialize_block(row: ContentBlock | None, alias: str | None = None) -> dict[
def build_docs_snapshot(
db: Session,
- block_map: Mapping[str, str] = content_blocks_state.DOCS_BLOCK_MAP,
+ block_map: Mapping[str, str] = content_blocks_constants.DOCS_BLOCK_MAP,
) -> dict[str, Any]:
"""Build a portable JSON snapshot for content blocks.
@@ -32,7 +32,7 @@ def build_docs_snapshot(
row_map = {row.id: row for row in rows}
return {
- "schema_version": content_blocks_state.SNAPSHOT_SCHEMA_VERSION,
+ "schema_version": content_blocks_constants.SNAPSHOT_SCHEMA_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"blocks": {
alias: serialize_block(row_map.get(db_id), alias) for alias, db_id in block_map.items()
@@ -53,23 +53,23 @@ def parse_snapshot_datetime(value: Any) -> datetime | None:
def normalize_docs_snapshot(
snapshot: Mapping[str, Any],
- block_map: Mapping[str, str] = content_blocks_state.DOCS_BLOCK_MAP,
+ block_map: Mapping[str, str] = content_blocks_constants.DOCS_BLOCK_MAP,
) -> dict[str, Any]:
"""Validate and normalize an incoming content snapshot."""
if not isinstance(snapshot, Mapping):
raise ContentSnapshotError("Snapshot body must be a JSON object")
- schema_version = snapshot.get("schema_version", content_blocks_state.SNAPSHOT_SCHEMA_VERSION)
- if schema_version != content_blocks_state.SNAPSHOT_SCHEMA_VERSION:
+ schema_version = snapshot.get("schema_version", content_blocks_constants.SNAPSHOT_SCHEMA_VERSION)
+ if schema_version != content_blocks_constants.SNAPSHOT_SCHEMA_VERSION:
raise ContentSnapshotError(
- f"Unsupported schema_version: {schema_version}. Expected {content_blocks_state.SNAPSHOT_SCHEMA_VERSION}"
+ f"Unsupported schema_version: {schema_version}. Expected {content_blocks_constants.SNAPSHOT_SCHEMA_VERSION}"
)
raw_blocks = snapshot.get("blocks")
if not isinstance(raw_blocks, Mapping):
raise ContentSnapshotError("Snapshot.blocks must be an object")
- unknown_blocks = set(raw_blocks.keys()) - set(block_map.keys()) - content_blocks_state.RETIRED_BLOCK_ALIASES
+ unknown_blocks = set(raw_blocks.keys()) - set(block_map.keys()) - content_blocks_constants.RETIRED_BLOCK_ALIASES
if unknown_blocks:
raise ContentSnapshotError(
f"Unknown blocks in snapshot: {', '.join(sorted(unknown_blocks))}"
@@ -84,7 +84,7 @@ def normalize_docs_snapshot(
raise ContentSnapshotError(f"Snapshot block '{alias}' must be an object")
payload = raw_block.get("payload", branding.default_payload(alias))
- expected_type = dict if alias in content_blocks_state.DICT_PAYLOAD_BLOCKS else list
+ expected_type = dict if alias in content_blocks_constants.DICT_PAYLOAD_BLOCKS else list
if not isinstance(payload, expected_type):
kind = "object" if expected_type is dict else "list"
raise ContentSnapshotError(f"Snapshot block '{alias}'.payload must be a {kind}")
@@ -103,7 +103,7 @@ def normalize_docs_snapshot(
raise ContentSnapshotError("Snapshot.exported_at must be a string")
return {
- "schema_version": content_blocks_state.SNAPSHOT_SCHEMA_VERSION,
+ "schema_version": content_blocks_constants.SNAPSHOT_SCHEMA_VERSION,
"exported_at": exported_at,
"blocks": normalized_blocks,
}
@@ -115,7 +115,7 @@ def import_docs_snapshot(
*,
overwrite: bool = True,
default_updated_by: str | None = None,
- block_map: Mapping[str, str] = content_blocks_state.DOCS_BLOCK_MAP,
+ block_map: Mapping[str, str] = content_blocks_constants.DOCS_BLOCK_MAP,
) -> dict[str, Any]:
"""Import a content snapshot into the current database."""
normalized = normalize_docs_snapshot(snapshot, block_map=block_map)
@@ -163,7 +163,7 @@ def build_prompt_snapshot(db: Session) -> dict[str, Any]:
Same format as the docs snapshot, but scoped to PROMPT_BLOCK_MAP so it can
be migrated across environments independently of docs/page config.
"""
- return build_docs_snapshot(db, block_map=content_blocks_state.PROMPT_BLOCK_MAP)
+ return build_docs_snapshot(db, block_map=content_blocks_constants.PROMPT_BLOCK_MAP)
def import_prompt_snapshot(
@@ -179,5 +179,5 @@ def import_prompt_snapshot(
snapshot,
overwrite=overwrite,
default_updated_by=default_updated_by,
- block_map=content_blocks_state.PROMPT_BLOCK_MAP,
+ block_map=content_blocks_constants.PROMPT_BLOCK_MAP,
)
diff --git a/src/backend/core/files/content/content_blocks_state.py b/src/backend/core/files/content/content_blocks_constants.py
similarity index 99%
rename from src/backend/core/files/content/content_blocks_state.py
rename to src/backend/core/files/content/content_blocks_constants.py
index 9c894700..c46a3a41 100644
--- a/src/backend/core/files/content/content_blocks_state.py
+++ b/src/backend/core/files/content/content_blocks_constants.py
@@ -1,4 +1,4 @@
-"""content blocks state responsibilities."""
+"""Block maps and schema version for content snapshots."""
from __future__ import annotations
from typing import Any
from core.config.settings import settings
diff --git a/src/backend/core/files/content/file_formats.py b/src/backend/core/files/content/file_formats.py
index b5ed63c9..b5d6748d 100644
--- a/src/backend/core/files/content/file_formats.py
+++ b/src/backend/core/files/content/file_formats.py
@@ -1,4 +1,4 @@
-"""file formats responsibilities."""
+"""File extensions the parsers accept."""
from __future__ import annotations
diff --git a/src/backend/core/files/content/file_parser.py b/src/backend/core/files/content/file_parser.py
index 49984ee6..333fbc57 100644
--- a/src/backend/core/files/content/file_parser.py
+++ b/src/backend/core/files/content/file_parser.py
@@ -1,4 +1,4 @@
-"""file parser responsibilities."""
+"""Dispatch an uploaded file to the parser for its extension."""
from __future__ import annotations
import os
from typing import Optional
diff --git a/src/backend/core/files/content/parser_config.py b/src/backend/core/files/content/parser_config.py
index 0a340215..2d5f4434 100644
--- a/src/backend/core/files/content/parser_config.py
+++ b/src/backend/core/files/content/parser_config.py
@@ -1,4 +1,4 @@
-"""parser config responsibilities."""
+"""Settings for the external file-parsing service."""
from __future__ import annotations
import os
diff --git a/src/backend/core/files/content/pdf_parser.py b/src/backend/core/files/content/pdf_parser.py
index 570c14e7..073eabd0 100644
--- a/src/backend/core/files/content/pdf_parser.py
+++ b/src/backend/core/files/content/pdf_parser.py
@@ -1,4 +1,4 @@
-"""pdf parser responsibilities."""
+"""Parse PDFs through the external layout service."""
from __future__ import annotations
import base64
import binascii
diff --git a/src/backend/core/files/content/presentation_parser.py b/src/backend/core/files/content/presentation_parser.py
index d80ac365..76a56824 100644
--- a/src/backend/core/files/content/presentation_parser.py
+++ b/src/backend/core/files/content/presentation_parser.py
@@ -1,4 +1,4 @@
-"""presentation parser responsibilities."""
+"""Parse PPTX files into markdown."""
from __future__ import annotations
import io
diff --git a/src/backend/core/files/content/spreadsheet_parser.py b/src/backend/core/files/content/spreadsheet_parser.py
index 800f8edc..f263805f 100644
--- a/src/backend/core/files/content/spreadsheet_parser.py
+++ b/src/backend/core/files/content/spreadsheet_parser.py
@@ -1,4 +1,4 @@
-"""spreadsheet parser responsibilities."""
+"""Parse XLSX files into markdown tables and bounded previews."""
from __future__ import annotations
import csv
import io
diff --git a/src/backend/core/files/content/text_parser.py b/src/backend/core/files/content/text_parser.py
index 0f68cf9c..a98766ef 100644
--- a/src/backend/core/files/content/text_parser.py
+++ b/src/backend/core/files/content/text_parser.py
@@ -1,4 +1,4 @@
-"""text parser responsibilities."""
+"""Decode plain-text files."""
from __future__ import annotations
diff --git a/src/backend/core/files/content/word_parser.py b/src/backend/core/files/content/word_parser.py
index 2041cba7..6b0e3917 100644
--- a/src/backend/core/files/content/word_parser.py
+++ b/src/backend/core/files/content/word_parser.py
@@ -1,4 +1,4 @@
-"""word parser responsibilities."""
+"""Parse DOCX, DOC and WPS files into markdown."""
from __future__ import annotations
import os
import subprocess
diff --git a/src/backend/core/files/spaces/leases.py b/src/backend/core/files/spaces/leases.py
index 81d6e0dd..a4d98850 100644
--- a/src/backend/core/files/spaces/leases.py
+++ b/src/backend/core/files/spaces/leases.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import contextmanager, asynccontextmanager
import asyncio
+from dataclasses import dataclass, field
from datetime import timedelta
from functools import wraps
import hashlib
@@ -11,9 +12,6 @@
from sqlalchemy import text
-
-
-
@contextmanager
def space_lock(identity, *, engine=None):
if engine is None:
@@ -103,18 +101,14 @@ async def async_space_lock(identity):
await asyncio.to_thread(guard.__exit__, None, None, None)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class LeasesState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- local_guard: object = state_field(default_factory=lambda: threading.Lock())
- local_locks: object = state_field(default_factory=lambda: {})
+ local_guard: object = field(default_factory=lambda: threading.Lock())
+ local_locks: object = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> LeasesState:
- return LeasesState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = LeasesState()
+
+
+def process_state() -> LeasesState:
+ return _STATE
diff --git a/src/backend/core/files/spaces/mounts.py b/src/backend/core/files/spaces/mounts.py
index 7ccc0a59..dc7c0c5d 100644
--- a/src/backend/core/files/spaces/mounts.py
+++ b/src/backend/core/files/spaces/mounts.py
@@ -31,14 +31,12 @@ def mount_revision(grants):
def provider_volume(grant):
from opensandbox.models.sandboxes import Host, OSSFS, Volume
from core.config.settings import settings
- from core.files.storage import get_storage
backend = os.getenv("STORAGE_TYPE", "local").lower()
name = "space-" + fingerprint((grant,))[:24]
if backend == "local":
root = settings.storage.root.resolve()
path = root / grant.prefix
- get_storage().make_directory(grant.prefix)
if path.is_symlink() or not path.resolve().is_relative_to(root):
raise HTTPException(503, "Invalid local space root")
host_root = settings.sandbox.opensandbox_host_storage_path
@@ -58,8 +56,6 @@ def provider_volume(grant):
key_prefix = os.getenv("OSS_KEY_PREFIX", "").strip("/") if backend == "oss" else ""
path = "/".join(part for part in (key_prefix, grant.prefix.rstrip("/")) if part)
validate_key(path)
- if grant.writable:
- get_storage().make_directory(grant.prefix)
options = ["hugagent_driver=s3fs", "allow_other", "uid=1000", "gid=1000",
"umask=0022", "stat_cache_expire=0", "attr_timeout=0", "entry_timeout=0",
"negative_timeout=0"]
@@ -79,9 +75,21 @@ def prepare_volumes(actor):
from core.db.engine import SessionLocal
with SessionLocal() as db:
grants = resolve_grants(db, actor)
- revision = mount_revision(grants)
- volumes = [provider_volume(grant) for grant in grants]
- db.expire_all()
- if mount_revision(resolve_grants(db, actor)) != revision:
- raise HTTPException(409, "Space permissions changed while preparing volumes")
- return volumes, {MOUNT_REVISION_TAG: revision}
+ return [provider_volume(grant) for grant in grants], {MOUNT_REVISION_TAG: mount_revision(grants)}
+
+
+def provision_volumes(actor):
+ """Initialize directory roots once, at sandbox creation."""
+ from core.files.storage import get_storage
+ volumes, metadata = prepare_volumes(actor)
+ # Grants are represented by the authorized volume specifications. Resolve
+ # writable prefixes once for initialization, then verify the same revision.
+ from core.db.engine import SessionLocal
+ with SessionLocal() as db:
+ grants = resolve_grants(db, actor)
+ if mount_revision(grants) != metadata[MOUNT_REVISION_TAG]:
+ raise HTTPException(409, "Space permissions changed before provisioning")
+ for grant in grants:
+ if grant.writable or os.getenv("STORAGE_TYPE", "local").lower() == "local":
+ get_storage().make_directory(grant.prefix)
+ return volumes, metadata
diff --git a/src/backend/core/files/spaces/sandbox.py b/src/backend/core/files/spaces/sandbox.py
index c1b5c4be..7940d6c4 100644
--- a/src/backend/core/files/spaces/sandbox.py
+++ b/src/backend/core/files/spaces/sandbox.py
@@ -55,18 +55,10 @@ async def _resolve_bound_session(self, session_id, user_id=None):
async def ensure_user_workspace(self, session_id, user_id):
await self._get_or_create_session(session_id, user_id)
- @_on_service_loop
- async def write_stdin(
- self, session_id, *, sandbox_session_id, user_id=None, chars="", yield_time_ms=60000
- ):
- await self._get_or_create_session(sandbox_session_id, user_id)
- return await super().write_stdin(
- session_id, sandbox_session_id=sandbox_session_id, user_id=user_id,
- chars=chars, yield_time_ms=yield_time_ms,
- )
-
async def _renew_in_background(self, session_id, session):
- if session.user_id and not await self._grants_match(session):
+ if session.user_id and (await asyncio.to_thread(current_revision, session.user_id)) != (
+ (await session.sandbox.get_info()).metadata or {}
+ ).get(MOUNT_REVISION_TAG):
await self._destroy_session(session_id, session, reuse_sandbox=False)
return
await super()._renew_in_background(session_id, session)
diff --git a/src/backend/core/files/storage/factory.py b/src/backend/core/files/storage/factory.py
index 487c4f0c..ccd914cb 100644
--- a/src/backend/core/files/storage/factory.py
+++ b/src/backend/core/files/storage/factory.py
@@ -4,6 +4,7 @@
import logging
import os
+from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@@ -108,7 +109,6 @@ def get_storage_category_for_resource(resource_type: str) -> str:
# Module-level storage instance (lazy-loaded)
-
def get_storage() -> StorageBackend:
"""
Get the global storage backend instance (lazy-loaded singleton).
@@ -118,17 +118,13 @@ def get_storage() -> StorageBackend:
return process_state().storage_instance
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class FactoryState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
storage_instance: Optional[StorageBackend] = None
-@state_cache(maxsize=1)
-def process_state() -> FactoryState:
- return FactoryState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = FactoryState()
+
+
+def process_state() -> FactoryState:
+ return _STATE
diff --git a/src/backend/core/harness/events.py b/src/backend/core/harness/events.py
index 9a9a117c..f54932fb 100644
--- a/src/backend/core/harness/events.py
+++ b/src/backend/core/harness/events.py
@@ -5,12 +5,15 @@
import asyncio
import copy
import inspect
-from collections.abc import Awaitable, Callable, Mapping
+from collections import deque
+from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from types import MappingProxyType
from typing import Any
+from core.harness.observation_writer import observation_writer
+
def freeze_value(value: Any) -> Any:
"""Recursively detach and freeze a value before exposing it to consumers."""
@@ -67,6 +70,28 @@ def create(
EventStore = Callable[[Event], Event]
+def _detach(event: Event) -> Event:
+ return Event(
+ run_id=event.run_id,
+ event_type=event.event_type,
+ phase=event.phase,
+ payload=freeze_value(event.payload),
+ created_at=event.created_at,
+ event_seq=event.event_seq,
+ )
+
+
+def store_events(store: EventStore, events: Sequence[Event]) -> tuple[Event, ...]:
+ """Write a batch through ``store.store_many`` when the store supports it."""
+ store_many = getattr(store, "store_many", None)
+ if store_many is not None:
+ return tuple(store_many(events))
+ return tuple(store(event) for event in events)
+
+
+MEMORY_EVENT_LIMIT = 256
+
+
class EventSink:
"""Append first, then notify observers with immutable detached events.
@@ -85,20 +110,15 @@ def __init__(
self._store_timeout_ms = max(1, int(store_timeout_ms))
self._subscriber_timeout_ms = max(1, int(subscriber_timeout_ms))
self._handlers: list[EventHandler] = []
- self._memory: list[Event] = []
+ # Recent events for in-process inspection; durable history lives in the store.
+ self._memory: deque[Event] = deque(maxlen=MEMORY_EVENT_LIMIT)
def subscribe(self, handler: EventHandler) -> None:
self._handlers.append(handler)
async def append(self, event: Event) -> Event:
- detached = Event(
- run_id=event.run_id,
- event_type=event.event_type,
- phase=event.phase,
- payload=freeze_value(event.payload),
- created_at=event.created_at,
- event_seq=event.event_seq,
- )
+ """Store, then notify: observers see the durable sequence number."""
+ detached = _detach(event)
recorded = detached
if self._store is not None:
try:
@@ -111,15 +131,25 @@ async def append(self, event: Event) -> Event:
# channel. Durable stores log their own failures.
recorded = detached
self._memory.append(recorded)
+ await self._notify(recorded)
+ return recorded
+
+ async def publish(self, event: Event) -> Event:
+ """Record without waiting for the store.
+
+ The durable write is queued for the background observation writer, so
+ the returned and observed event carries no ``event_seq``.
+ """
+ detached = _detach(event)
+ if self._store is not None:
+ observation_writer().submit(self._store, store_events, detached.run_id, (detached,))
+ self._memory.append(detached)
+ await self._notify(detached)
+ return detached
+
+ async def _notify(self, recorded: Event) -> None:
for handler in tuple(self._handlers):
- consumer_copy = Event(
- run_id=recorded.run_id,
- event_type=recorded.event_type,
- phase=recorded.phase,
- payload=freeze_value(recorded.payload),
- created_at=recorded.created_at,
- event_seq=recorded.event_seq,
- )
+ consumer_copy = _detach(recorded)
try:
async def notify(
@@ -144,7 +174,6 @@ def call_handler() -> Any:
)
except Exception: # noqa: BLE001, S112
continue
- return recorded
def events(self) -> tuple[Event, ...]:
return tuple(self._memory)
diff --git a/src/backend/core/harness/hooks.py b/src/backend/core/harness/hooks.py
index 6feae4c6..94ab6256 100644
--- a/src/backend/core/harness/hooks.py
+++ b/src/backend/core/harness/hooks.py
@@ -4,7 +4,6 @@
import asyncio
import inspect
-import logging
import time
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field, replace
@@ -13,9 +12,8 @@
from typing import Any
from core.harness.events import Event, EventSink, freeze_value, thaw_value
-from core.harness.usage import AttemptUsage, UsageAttempt, UsageRecorder
-
-logger = logging.getLogger(__name__)
+from core.harness.observation_writer import observation_writer
+from core.harness.usage import AttemptUsage, UsageAttempt, UsageRecorder, record_attempt_batch
class HookStage(str, Enum):
@@ -179,11 +177,9 @@ def __init__(
*,
event_sink: EventSink | None = None,
usage_recorder: UsageRecorder | None = None,
- usage_timeout_ms: int = 1_000,
) -> None:
self.event_sink = event_sink or EventSink()
self.usage_recorder = usage_recorder
- self.usage_timeout_ms = max(1, int(usage_timeout_ms))
self._hooks: dict[HookStage, list[tuple[int, int, HookSpec]]] = {}
self._registration_seq = 0
@@ -206,43 +202,36 @@ def registered_specs(self) -> tuple[HookSpec, ...]:
for _, _, spec in self._hooks.get(stage, ()))
async def _call(self, spec: HookSpec, invocation: Invocation) -> Decision:
- async def invoke() -> Decision:
- if inspect.iscoroutinefunction(spec.handler):
- result = await spec.handler(invocation)
+ # Synchronous handlers are in-memory policies and run inline: a thread
+ # hop plus timeout would let executor saturation reject or skip them.
+ # Policies that block on I/O must be async and own their offloading.
+ result = spec.handler(invocation)
+ if inspect.isawaitable(result):
+ # Interactive policies own their timeout and must retain caller cancellation.
+ if spec.timeout_ms is None:
+ result = await result
else:
- result = await asyncio.to_thread(spec.handler, invocation)
- if inspect.isawaitable(result):
- result = await result
- if result is None:
- return Decision()
- if not isinstance(result, Decision):
- raise HookContractError(
- f"hook {spec.name} returned {type(result).__name__}"
- )
- return result
-
- # Interactive policies own their timeout and must retain caller cancellation.
- if spec.timeout_ms is None:
- return await invoke()
- return await asyncio.wait_for(invoke(), timeout=max(1, spec.timeout_ms) / 1_000)
+ result = await asyncio.wait_for(result, timeout=max(1, spec.timeout_ms) / 1_000)
+ if result is None:
+ return Decision()
+ if not isinstance(result, Decision):
+ raise HookContractError(f"hook {spec.name} returned {type(result).__name__}")
+ return result
async def run(self, invocation: Invocation) -> HookOutcome:
current = invocation
executed: list[str] = []
terminal = Decision()
- await self.event_sink.append(
- Event.create(
- run_id=current.run_id,
- event_type="hook_stage_started",
- phase=current.stage.value,
- payload={"operation_name": current.operation_name},
- )
- )
- for _, _, spec in tuple(self._hooks.get(current.stage, ())):
+ hooks = tuple(self._hooks.get(current.stage, ()))
+ if not hooks:
+ return HookOutcome(current, terminal, ())
+ attempts: list[dict[str, Any]] = []
+ usage: list[UsageAttempt] = []
+ cancellation: asyncio.CancelledError | None = None
+ for _, _, spec in hooks:
started = time.monotonic()
status = "success"
decision = Decision()
- cancellation: asyncio.CancelledError | None = None
try:
decision = await self._call(spec, current)
if decision.patch:
@@ -270,57 +259,56 @@ async def run(self, invocation: Invocation) -> HookOutcome:
terminal = Decision.reject(f"hook {spec.name} failed: {exc}")
latency_ms = int((time.monotonic() - started) * 1_000)
executed.append(spec.name)
- if self.usage_recorder is not None:
- try:
- await asyncio.wait_for(
- asyncio.to_thread(
- self.usage_recorder.record_attempt,
- UsageAttempt(
- run_id=current.run_id,
- kind="hook",
- operation_name=spec.name,
- status=status,
- latency_ms=latency_ms,
- usage=decision.usage,
- metadata={"stage": current.stage.value},
- ),
- ),
- timeout=self.usage_timeout_ms / 1_000,
- )
- except Exception: # noqa: BLE001
- logger.warning("Hook usage recording failed for %s", spec.name, exc_info=True)
- await self.event_sink.append(
- Event.create(
+ usage.append(
+ UsageAttempt(
run_id=current.run_id,
- event_type="hook_attempt_finished",
- phase=current.stage.value,
- payload={
- "hook": spec.name,
- "status": status,
- "decision": terminal.kind.value
- if terminal.kind != DecisionKind.CONTINUE
- else decision.kind.value,
- "latency_ms": latency_ms,
- },
+ kind="hook",
+ operation_name=spec.name,
+ status=status,
+ latency_ms=latency_ms,
+ usage=decision.usage,
+ metadata={"stage": current.stage.value},
)
)
- if terminal.kind in {DecisionKind.REJECT, DecisionKind.PAUSE}:
+ attempts.append(
+ {
+ "hook": spec.name,
+ "status": status,
+ "decision": terminal.kind.value
+ if terminal.kind != DecisionKind.CONTINUE
+ else decision.kind.value,
+ "latency_ms": latency_ms,
+ }
+ )
+ if terminal.kind in {DecisionKind.REJECT, DecisionKind.PAUSE} or cancellation is not None:
break
- if cancellation is not None:
- raise cancellation
- await self.event_sink.append(
+ await self._record_stage(current, terminal, executed, attempts, usage)
+ if cancellation is not None:
+ raise cancellation
+ return HookOutcome(current, terminal, tuple(executed))
+
+ async def _record_stage(self, current, terminal, executed, attempts, usage) -> None:
+ """Queue one usage batch and one event per stage, not one per hook.
+
+ Both are written by the background observation writer; the stage never
+ waits for the database and a failed write cannot change its outcome.
+ """
+ if self.usage_recorder is not None and current.run_id:
+ observation_writer().submit(self.usage_recorder, record_attempt_batch, current.run_id, usage)
+ await self.event_sink.publish(
Event.create(
run_id=current.run_id,
event_type="hook_stage_finished",
phase=current.stage.value,
payload={
+ "operation_name": current.operation_name,
"decision": terminal.kind.value,
"executed_hooks": executed,
+ "attempts": attempts,
},
)
)
- return HookOutcome(current, terminal, tuple(executed))
async def enforce(self, invocation: Invocation) -> Invocation:
outcome = await self.run(invocation)
diff --git a/src/backend/core/harness/ledger.py b/src/backend/core/harness/ledger.py
index bb5cb2d7..d346f0cb 100644
--- a/src/backend/core/harness/ledger.py
+++ b/src/backend/core/harness/ledger.py
@@ -39,18 +39,59 @@ def _insert_cursor_if_missing(db, model: type[Any], values: dict[str, Any]) -> N
db.execute(statement)
-def _reserve(db, model: type[Any], run_id: str, column) -> int:
+def _reserve(db, model: type[Any], run_id: str, column, count: int = 1) -> int:
+ """Reserve ``count`` consecutive sequence numbers and return the last one."""
_insert_cursor_if_missing(db, model, {"run_id": run_id, column.key: 0})
value = db.execute(
update(model)
.where(model.run_id == run_id)
- .values({column.key: column + 1})
+ .values({column.key: column + count})
.returning(column)
.execution_options(synchronize_session=False)
).scalar_one()
return int(value)
+def _previous_failed_attempt(db, attempt: UsageAttempt) -> int | None:
+ """A tool effect's retry links to its latest failed physical attempt."""
+ if attempt.kind != "tool" or not attempt.effect_id:
+ return None
+ previous = (
+ db.query(HarnessUsageAttempt)
+ .filter(
+ HarnessUsageAttempt.run_id == attempt.run_id,
+ HarnessUsageAttempt.kind == attempt.kind,
+ HarnessUsageAttempt.operation_name == attempt.operation_name,
+ HarnessUsageAttempt.status.in_(("failed", "timeout")),
+ HarnessUsageAttempt.effect_id == attempt.effect_id,
+ )
+ .order_by(HarnessUsageAttempt.attempt_seq.desc())
+ .first()
+ )
+ return int(previous.attempt_seq) if previous is not None else None
+
+
+def _attempt_row(attempt: UsageAttempt, seq: int, retry_of: int | None) -> HarnessUsageAttempt:
+ return HarnessUsageAttempt(
+ attempt_id=f"hat_{uuid.uuid4().hex}",
+ run_id=attempt.run_id,
+ attempt_seq=seq,
+ kind=attempt.kind,
+ operation_name=attempt.operation_name,
+ provider=attempt.provider,
+ model=attempt.model,
+ effect_id=attempt.effect_id or None,
+ prompt_tokens=max(0, int(attempt.usage.prompt_tokens)),
+ completion_tokens=max(0, int(attempt.usage.completion_tokens)),
+ cache_read_tokens=max(0, int(attempt.usage.cache_read_tokens)),
+ cache_write_tokens=max(0, int(attempt.usage.cache_write_tokens)),
+ latency_ms=max(0, int(attempt.latency_ms)),
+ status=attempt.status,
+ retry_of=retry_of,
+ attempt_metadata=thaw_value(attempt.metadata),
+ )
+
+
class HarnessUsageLedger:
"""Append attempts and derive aggregates without mutable summary rows."""
@@ -67,48 +108,35 @@ def _validate(attempt: UsageAttempt) -> None:
raise ValueError(f"invalid attempt status: {attempt.status}")
def record_attempt(self, attempt: UsageAttempt) -> UsageAttempt:
- self._validate(attempt)
+ return self.record_attempts((attempt,))[0]
+
+ def record_attempts(self, attempts) -> tuple[UsageAttempt, ...]:
+ """Append attempts of one run in a single transaction and cursor update."""
+ attempts = tuple(attempts)
+ if not attempts:
+ return ()
+ for attempt in attempts:
+ self._validate(attempt)
+ run_id = attempts[0].run_id
+ if any(attempt.run_id != run_id for attempt in attempts):
+ raise ValueError("a usage batch must belong to one run")
with self._session_factory() as db:
- seq = _reserve(
+ last = _reserve(
db,
HarnessUsageCursor,
- attempt.run_id,
+ run_id,
HarnessUsageCursor.next_attempt_seq,
+ len(attempts),
)
- retry_of = attempt.retry_of
- if retry_of is None and attempt.kind == "tool" and attempt.effect_id:
- query = db.query(HarnessUsageAttempt).filter(
- HarnessUsageAttempt.run_id == attempt.run_id,
- HarnessUsageAttempt.kind == attempt.kind,
- HarnessUsageAttempt.operation_name == attempt.operation_name,
- HarnessUsageAttempt.status.in_(("failed", "timeout")),
- )
- query = query.filter(HarnessUsageAttempt.effect_id == attempt.effect_id)
- previous = query.order_by(
- HarnessUsageAttempt.attempt_seq.desc()
- ).first()
- retry_of = int(previous.attempt_seq) if previous is not None else None
- row = HarnessUsageAttempt(
- attempt_id=f"hat_{uuid.uuid4().hex}",
- run_id=attempt.run_id,
- attempt_seq=seq,
- kind=attempt.kind,
- operation_name=attempt.operation_name,
- provider=attempt.provider,
- model=attempt.model,
- effect_id=attempt.effect_id or None,
- prompt_tokens=max(0, int(attempt.usage.prompt_tokens)),
- completion_tokens=max(0, int(attempt.usage.completion_tokens)),
- cache_read_tokens=max(0, int(attempt.usage.cache_read_tokens)),
- cache_write_tokens=max(0, int(attempt.usage.cache_write_tokens)),
- latency_ms=max(0, int(attempt.latency_ms)),
- status=attempt.status,
- retry_of=retry_of,
- attempt_metadata=thaw_value(attempt.metadata),
- )
- db.add(row)
+ recorded = []
+ for seq, attempt in enumerate(attempts, start=last - len(attempts) + 1):
+ retry_of = attempt.retry_of
+ if retry_of is None:
+ retry_of = _previous_failed_attempt(db, attempt)
+ db.add(_attempt_row(attempt, seq, retry_of))
+ recorded.append(replace(attempt, attempt_seq=seq, retry_of=retry_of))
db.commit()
- return replace(attempt, attempt_seq=seq, retry_of=retry_of)
+ return tuple(recorded)
def attempts(self, run_id: str) -> tuple[UsageAttempt, ...]:
with self._session_factory() as db:
@@ -167,32 +195,39 @@ def __init__(self, session_factory=SessionLocal) -> None:
self._session_factory = session_factory
def __call__(self, event: Event) -> Event:
- if not event.run_id:
- return event
+ return self.store_many((event,))[0]
+
+ def store_many(self, events) -> tuple[Event, ...]:
+ """Append events in one transaction, one cursor update per run."""
+ recorded = list(events)
+ by_run: dict[str, list[int]] = {}
+ for index, event in enumerate(recorded):
+ if event.run_id:
+ by_run.setdefault(event.run_id, []).append(index)
+ if not by_run:
+ return tuple(recorded)
with self._session_factory() as db:
- seq = _reserve(
- db,
- HarnessEventCursor,
- event.run_id,
- HarnessEventCursor.next_event_seq,
- )
- db.add(
- HarnessEventLog(
- event_id=f"hev_{uuid.uuid4().hex}",
- run_id=event.run_id,
- event_seq=seq,
- event_type=event.event_type,
- phase=event.phase,
- payload=thaw_value(event.payload),
- created_at=event.created_at,
+ for run_id, indexes in by_run.items():
+ last = _reserve(
+ db,
+ HarnessEventCursor,
+ run_id,
+ HarnessEventCursor.next_event_seq,
+ len(indexes),
)
- )
+ for seq, index in enumerate(indexes, start=last - len(indexes) + 1):
+ event = recorded[index]
+ db.add(
+ HarnessEventLog(
+ event_id=f"hev_{uuid.uuid4().hex}",
+ run_id=run_id,
+ event_seq=seq,
+ event_type=event.event_type,
+ phase=event.phase,
+ payload=thaw_value(event.payload),
+ created_at=event.created_at,
+ )
+ )
+ recorded[index] = replace(event, payload=freeze_value(event.payload), event_seq=seq)
db.commit()
- return Event(
- run_id=event.run_id,
- event_type=event.event_type,
- phase=event.phase,
- payload=freeze_value(event.payload),
- created_at=event.created_at,
- event_seq=seq,
- )
+ return tuple(recorded)
diff --git a/src/backend/core/harness/log_audit.py b/src/backend/core/harness/log_audit.py
index 0bc8dd7e..7340a7cd 100644
--- a/src/backend/core/harness/log_audit.py
+++ b/src/backend/core/harness/log_audit.py
@@ -1,13 +1,14 @@
-"""log audit responsibilities."""
+"""Write sandbox tool calls as security audit events."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from core.db.engine import SessionLocal
from core.infra.logging import get_logger
-logger = get_logger(__name__)
from core.harness import log_context
+logger = get_logger(__name__)
+
async def resolve_sandbox_id(session_id: Optional[str]) -> Optional[str]:
"""Look up the current sandbox instance id from the sandbox provider by session key (default = chat_id). Pure query; None on failure."""
if not session_id:
diff --git a/src/backend/core/harness/log_context.py b/src/backend/core/harness/log_context.py
index c3d4dd95..d8474190 100644
--- a/src/backend/core/harness/log_context.py
+++ b/src/backend/core/harness/log_context.py
@@ -1,4 +1,4 @@
-"""log context responsibilities."""
+"""Shared helpers for call logs: ids, truncation and the current log scope."""
from __future__ import annotations
import json
import os
diff --git a/src/backend/core/harness/log_records.py b/src/backend/core/harness/log_records.py
index 86c12f19..66dc0eda 100644
--- a/src/backend/core/harness/log_records.py
+++ b/src/backend/core/harness/log_records.py
@@ -1,4 +1,4 @@
-"""log records responsibilities."""
+"""Synchronous writers for tool, sub-agent and skill call logs."""
from __future__ import annotations
from typing import Any, Dict
from sqlalchemy.orm import Session
@@ -6,18 +6,19 @@
from core.db.models import SkillCallLog, SubAgentCallLog, ToolCallLog, ToolEffectLedger
from core.infra.data_masking import mask_sensitive_data
from core.infra.logging import get_logger
-logger = get_logger(__name__)
from core.harness import log_context
-from core.harness import log_writer_state
+from core.harness import log_writer_constants
+
+logger = get_logger(__name__)
def write_tool_call_sync(record: Dict[str, Any]) -> None:
- if not log_writer_state.TOOL_LOG_ENABLED:
+ if not log_writer_constants.TOOL_LOG_ENABLED:
return
db: Session = SessionLocal()
try:
args_safe = log_context.sanitize_tool_args(record.get("tool_args"))
- args_safe, _ = log_context.truncate_json(args_safe, log_writer_state.MAX_RESULT_BYTES)
- result_safe, result_trunc = log_context.truncate_json(record.get("tool_result"), log_writer_state.MAX_RESULT_BYTES)
+ args_safe, _ = log_context.truncate_json(args_safe, log_writer_constants.MAX_RESULT_BYTES)
+ result_safe, result_trunc = log_context.truncate_json(record.get("tool_result"), log_writer_constants.MAX_RESULT_BYTES)
username = log_context.fetch_username(db, record.get("user_id", ""))
effect = None
effect_id = str(record.get("effect_id") or "")
@@ -87,11 +88,11 @@ def write_tool_call_sync(record: Dict[str, Any]) -> None:
def write_subagent_create_sync(record: Dict[str, Any]) -> None:
- if not log_writer_state.SUBAGENT_LOG_ENABLED:
+ if not log_writer_constants.SUBAGENT_LOG_ENABLED:
return
db: Session = SessionLocal()
try:
- input_safe, _ = log_context.truncate_json(record.get("input_messages"), log_writer_state.MAX_RESULT_BYTES)
+ input_safe, _ = log_context.truncate_json(record.get("input_messages"), log_writer_constants.MAX_RESULT_BYTES)
username = log_context.fetch_username(db, record.get("user_id", ""))
row = SubAgentCallLog(
id=record["id"],
@@ -130,7 +131,7 @@ def write_subagent_finish_sync(
status: str,
updates: Dict[str, Any],
) -> None:
- if not log_writer_state.SUBAGENT_LOG_ENABLED:
+ if not log_writer_constants.SUBAGENT_LOG_ENABLED:
return
db: Session = SessionLocal()
try:
@@ -139,10 +140,10 @@ def write_subagent_finish_sync(
return
row.status = status
if "output_content" in updates:
- output, _ = log_context.truncate_text(updates.get("output_content"), log_writer_state.MAX_RESULT_BYTES)
+ output, _ = log_context.truncate_text(updates.get("output_content"), log_writer_constants.MAX_RESULT_BYTES)
row.output_content = output
if "intermediate_steps" in updates:
- steps_safe, _ = log_context.truncate_json(updates.get("intermediate_steps"), log_writer_state.MAX_RESULT_BYTES)
+ steps_safe, _ = log_context.truncate_json(updates.get("intermediate_steps"), log_writer_constants.MAX_RESULT_BYTES)
row.intermediate_steps = steps_safe
if "token_usage" in updates:
row.token_usage = updates.get("token_usage")
@@ -164,15 +165,15 @@ def write_subagent_finish_sync(
def write_skill_call_sync(record: Dict[str, Any]) -> None:
- if not log_writer_state.SKILL_LOG_ENABLED:
+ if not log_writer_constants.SKILL_LOG_ENABLED:
return
db: Session = SessionLocal()
try:
args_safe = mask_sensitive_data(record.get("script_args"), field_patterns=log_context.REDACT_FIELDS)
- args_safe, _ = log_context.truncate_json(args_safe, log_writer_state.MAX_RESULT_BYTES)
- stdout_safe, trunc_out = log_context.truncate_text(record.get("script_stdout"), log_writer_state.MAX_STDOUT_BYTES)
- stderr_safe, trunc_err = log_context.truncate_text(record.get("script_stderr"), log_writer_state.MAX_STDOUT_BYTES)
- stdin_safe, _ = log_context.truncate_text(record.get("script_stdin"), log_writer_state.MAX_STDOUT_BYTES)
+ args_safe, _ = log_context.truncate_json(args_safe, log_writer_constants.MAX_RESULT_BYTES)
+ stdout_safe, trunc_out = log_context.truncate_text(record.get("script_stdout"), log_writer_constants.MAX_STDOUT_BYTES)
+ stderr_safe, trunc_err = log_context.truncate_text(record.get("script_stderr"), log_writer_constants.MAX_STDOUT_BYTES)
+ stdin_safe, _ = log_context.truncate_text(record.get("script_stdin"), log_writer_constants.MAX_STDOUT_BYTES)
username = log_context.fetch_username(db, record.get("user_id", ""))
row = SkillCallLog(
id=record.get("id") or log_context.new_id(),
diff --git a/src/backend/core/harness/log_writer.py b/src/backend/core/harness/log_writer.py
index b222aa5e..a4ccb852 100644
--- a/src/backend/core/harness/log_writer.py
+++ b/src/backend/core/harness/log_writer.py
@@ -1,14 +1,15 @@
-"""log writer responsibilities."""
+"""Async entry points that schedule call-log writes off the event loop."""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List, Optional
from core.infra.background import spawn
from core.infra.logging import get_logger
-logger = get_logger(__name__)
from core.harness import log_audit
from core.harness import log_context
from core.harness import log_records
-from core.harness import log_writer_state
+from core.harness import log_writer_constants
+
+logger = get_logger(__name__)
async def write_tool_call(record: Dict[str, Any]) -> None:
record.setdefault("source", log_context.current_source())
@@ -22,16 +23,16 @@ async def write_tool_call(record: Dict[str, Any]) -> None:
# key default = chat_id). Awaiting in place on this async path — provider
# reads its active-session table (synchronous in-memory read, no side
# effects) — is more reliable than passing a ContextVar across tasks.
- if record.get("tool_name") in log_writer_state.SANDBOX_TOOL_NAMES:
+ if record.get("tool_name") in log_writer_constants.SANDBOX_TOOL_NAMES:
if not record.get("sandbox_id"):
record["sandbox_id"] = await log_audit.resolve_sandbox_id(record.get("chat_id"))
# Security audit is independent of the tool-call log switch: write the audit first, then the (optional) tool log.
- if log_writer_state.SANDBOX_AUDIT_ENABLED:
+ if log_writer_constants.SANDBOX_AUDIT_ENABLED:
try:
await asyncio.to_thread(log_audit.write_sandbox_audit_sync, record)
except Exception as exc: # noqa: BLE001
logger.warning("sandbox_audit_task_failed", error=str(exc))
- if not log_writer_state.TOOL_LOG_ENABLED:
+ if not log_writer_constants.TOOL_LOG_ENABLED:
return
try:
await asyncio.to_thread(log_records.write_tool_call_sync, record)
@@ -42,7 +43,7 @@ async def write_tool_call(record: Dict[str, Any]) -> None:
async def start_subagent_log(record: Dict[str, Any]) -> str:
log_id = record.get("id") or log_context.new_id()
record["id"] = log_id
- if not log_writer_state.SUBAGENT_LOG_ENABLED:
+ if not log_writer_constants.SUBAGENT_LOG_ENABLED:
return log_id
ctx = log_context.context_ids()
record.setdefault("trace_id", ctx["trace_id"])
@@ -70,7 +71,7 @@ async def finish_subagent_log(
error_message: Optional[str] = None,
duration_ms: Optional[int] = None,
) -> None:
- if not log_writer_state.SUBAGENT_LOG_ENABLED or not subagent_log_id:
+ if not log_writer_constants.SUBAGENT_LOG_ENABLED or not subagent_log_id:
return
updates = {
"output_content": output_content,
@@ -96,14 +97,14 @@ async def write_skill_call(record: Dict[str, Any]) -> str:
record.setdefault("chat_id", ctx["chat_id"])
record.setdefault("message_id", log_context.current_message_id.get())
# Skill script execution (run_script) = running code in the sandbox → write a security audit (independent of the skill log switch).
- if log_writer_state.SANDBOX_AUDIT_ENABLED and record.get("invocation_type") == "run_script":
+ if log_writer_constants.SANDBOX_AUDIT_ENABLED and record.get("invocation_type") == "run_script":
if not record.get("sandbox_id"):
record["sandbox_id"] = await log_audit.resolve_sandbox_id(record.get("chat_id"))
try:
await asyncio.to_thread(log_audit.write_skill_sandbox_audit_sync, record)
except Exception as exc: # noqa: BLE001
logger.warning("skill_sandbox_audit_task_failed", error=str(exc))
- if not log_writer_state.SKILL_LOG_ENABLED:
+ if not log_writer_constants.SKILL_LOG_ENABLED:
return log_id
try:
await asyncio.to_thread(log_records.write_skill_call_sync, record)
diff --git a/src/backend/core/harness/log_writer_state.py b/src/backend/core/harness/log_writer_constants.py
similarity index 93%
rename from src/backend/core/harness/log_writer_state.py
rename to src/backend/core/harness/log_writer_constants.py
index d06882b0..ecaf3e65 100644
--- a/src/backend/core/harness/log_writer_state.py
+++ b/src/backend/core/harness/log_writer_constants.py
@@ -1,4 +1,4 @@
-"""log writer state responsibilities."""
+"""Switches and size limits for call logging."""
from __future__ import annotations
import os
diff --git a/src/backend/core/harness/observation_writer.py b/src/backend/core/harness/observation_writer.py
new file mode 100644
index 00000000..ad630370
--- /dev/null
+++ b/src/backend/core/harness/observation_writer.py
@@ -0,0 +1,134 @@
+"""Bounded background persistence for hook-path observability records.
+
+Hook stages and framework events are recorded on every model and tool step.
+Waiting for their database writes would put observability on the execution
+critical path, so they are queued here and written by one daemon thread.
+``submit`` never blocks: a full queue drops the record and logs the loss.
+"""
+
+from __future__ import annotations
+
+import atexit
+import logging
+import os
+import queue
+import threading
+import time
+from collections.abc import Callable, Sequence
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+MAX_PENDING = 10_000
+MAX_BATCH = 500
+DROP_LOG_EVERY = 1_000
+
+BatchWrite = Callable[[Any, Sequence[Any]], Any]
+
+
+class ObservationWriter:
+ """Drain ``(sink, write, run_id, records)`` items in per-sink, per-run batches."""
+
+ def __init__(self, *, max_pending: int = MAX_PENDING, max_batch: int = MAX_BATCH) -> None:
+ self._max_pending = max(1, int(max_pending))
+ self._max_batch = max(1, int(max_batch))
+ self._lock = threading.Lock()
+ self._queue: queue.Queue | None = None
+ self._pid: int | None = None
+ self.dropped = 0
+
+ def _ensure_started(self) -> queue.Queue:
+ with self._lock:
+ # A forked worker inherits the queue but not the thread.
+ if self._queue is None or self._pid != os.getpid():
+ self._queue = queue.Queue(self._max_pending)
+ self._pid = os.getpid()
+ threading.Thread(
+ target=self._run,
+ args=(self._queue,),
+ name="harness-observation-writer",
+ daemon=True,
+ ).start()
+ return self._queue
+
+ def submit(self, sink: Any, write: BatchWrite, run_id: str, records: Sequence[Any]) -> bool:
+ """Queue ``write(sink, records)``; return False when the record was dropped."""
+ if not records:
+ return True
+ try:
+ self._ensure_started().put_nowait((sink, write, run_id, tuple(records)))
+ return True
+ except queue.Full:
+ with self._lock:
+ self.dropped += 1
+ dropped = self.dropped
+ if dropped == 1 or dropped % DROP_LOG_EVERY == 0:
+ logger.warning(
+ "Observation queue full (%d pending); dropped %d record batch(es) so far",
+ self._max_pending,
+ dropped,
+ )
+ return False
+
+ def _run(self, pending: queue.Queue) -> None:
+ while True:
+ items = [pending.get()]
+ while len(items) < self._max_batch:
+ try:
+ items.append(pending.get_nowait())
+ except queue.Empty:
+ break
+ try:
+ self._write(items)
+ finally:
+ for _ in items:
+ pending.task_done()
+
+ @staticmethod
+ def _write(items) -> None:
+ # Order is preserved within a sink and run, which is all sequence
+ # allocation depends on; one call per group lets stores batch a run.
+ groups: dict[tuple[int, BatchWrite, str], tuple[Any, list]] = {}
+ for sink, write, run_id, records in items:
+ groups.setdefault((id(sink), write, run_id), (sink, []))[1].extend(records)
+ for (_, write, run_id), (sink, records) in groups.items():
+ try:
+ write(sink, records)
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "Observation write failed for run %s (%d record(s)): %s",
+ run_id,
+ len(records),
+ exc,
+ )
+
+ def flush(self, timeout: float = 5.0) -> bool:
+ """Wait until every queued record is written; False on timeout."""
+ pending = self._queue
+ if pending is None or self._pid != os.getpid():
+ return True
+ deadline = time.monotonic() + max(0.0, timeout)
+ with pending.all_tasks_done:
+ while pending.unfinished_tasks:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ return False
+ pending.all_tasks_done.wait(remaining)
+ return True
+
+
+_WRITER = ObservationWriter()
+
+
+def observation_writer() -> ObservationWriter:
+ return _WRITER
+
+
+def flush_observations(timeout: float = 5.0) -> bool:
+ flushed = _WRITER.flush(timeout)
+ if not flushed:
+ logger.warning("Observation writes still pending after %.1fs flush", timeout)
+ return flushed
+
+
+atexit.register(flush_observations)
diff --git a/src/backend/core/harness/registry/authentication.py b/src/backend/core/harness/registry/authentication.py
index e140357e..a02bcbad 100644
--- a/src/backend/core/harness/registry/authentication.py
+++ b/src/backend/core/harness/registry/authentication.py
@@ -1,33 +1,39 @@
"""Optional host authentication before the ordinary cookie/session resolver."""
from __future__ import annotations
from collections.abc import Callable
+from dataclasses import dataclass
from typing import Any
+def register_host_authentication(
+ claims: Callable[[Any], bool], resolver: Callable[[Any, Any], Any]
+) -> None:
+ """Install a host identity resolver.
-
-def register_host_authentication(resolver: Callable[[Any, Any], Any]) -> None:
- pass
+ ``claims(request)`` must answer from the request alone, without I/O: it is
+ asked on every authenticated request, and only a request it claims pays for
+ ``resolver(request, db)`` (database work, run off the event loop).
+ """
+ registration_state().claims = claims
registration_state().resolver = resolver
-def resolve_host_user(request, db):
- return registration_state().resolver(request, db) if registration_state().resolver is not None else None
-
-
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+def host_user_resolver(request) -> Callable[[Any, Any], Any] | None:
+ """The host resolver when one is registered and claims ``request``, else None."""
+ state = registration_state()
+ if state.resolver is None or state.claims is None or not state.claims(request):
+ return None
+ return state.resolver
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
+ claims: Callable[[Any], bool] | None = None
resolver: Callable[[Any, Any], Any] | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
+
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/capability_runtime.py b/src/backend/core/harness/registry/capability_runtime.py
index 05c4b03a..95326b57 100644
--- a/src/backend/core/harness/registry/capability_runtime.py
+++ b/src/backend/core/harness/registry/capability_runtime.py
@@ -1,5 +1,6 @@
"""Frozen capability authorization supplied by the execution host."""
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any, Protocol
from core.harness.capability_errors import ViewUnavailable
@@ -15,9 +16,6 @@ def require_root_tool_scope(self, run_id: str, user_id: str, tool_call_id: str,
def child_scope(self, parent_scope: str, kind: str, *identities: str) -> str: ...
-
-
-
def register_capability_runtime(provider: CapabilityRuntime) -> None:
pass
registration_state().provider = provider
@@ -64,19 +62,13 @@ def view_for_execution(run_id, user_id, *, scope_id=""):
return require_runtime().view_for_execution(run_id, user_id, scope_id=scope_id)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
provider: CapabilityRuntime | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/catalog_options.py b/src/backend/core/harness/registry/catalog_options.py
index 38ee4336..e7a70220 100644
--- a/src/backend/core/harness/registry/catalog_options.py
+++ b/src/backend/core/harness/registry/catalog_options.py
@@ -1,11 +1,10 @@
"""Optional domain-owned option lists used by agent administration."""
from __future__ import annotations
from collections.abc import Callable
+from dataclasses import dataclass, field
from typing import Any
-
-
def register_catalog_options(kind: str, provider: Callable[[Any], list[dict]]) -> None:
registration_state().providers[kind] = provider
@@ -15,19 +14,13 @@ def catalog_options(kind: str, db: Any) -> list[dict]:
return provider(db) if provider is not None else []
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class RegistrationState:
+ providers: dict[str, Callable[[Any], list[dict]]] = field(default_factory=dict)
-@state_dataclass
-class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
- providers: dict[str, Callable[[Any], list[dict]]] = state_field(default_factory=dict)
+_REGISTRATIONS = RegistrationState()
-@state_cache(maxsize=1)
def registration_state() -> RegistrationState:
- return RegistrationState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/evaluation.py b/src/backend/core/harness/registry/evaluation.py
index 7e953376..4c82baaa 100644
--- a/src/backend/core/harness/registry/evaluation.py
+++ b/src/backend/core/harness/registry/evaluation.py
@@ -1,5 +1,6 @@
"""Reserved execution policy installed by the evaluation host."""
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any, Protocol
@@ -12,9 +13,6 @@ def register_tools(self, toolkit: Any, scope: Any, **options: Any) -> None: ...
async def authorize(self, request: Any, user_id: str, *, agent_scoped: bool = False) -> None: ...
-
-
-
def register_evaluation_policy(policy: EvaluationPolicy) -> None:
pass
registration_state().policy = policy
@@ -62,19 +60,13 @@ async def authorize(request, user_id, *, agent_scoped=False):
await require_policy().authorize(request, user_id, agent_scoped=agent_scoped)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
policy: EvaluationPolicy | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/execution_account.py b/src/backend/core/harness/registry/execution_account.py
index 42e59f7a..8c9cd4ed 100644
--- a/src/backend/core/harness/registry/execution_account.py
+++ b/src/backend/core/harness/registry/execution_account.py
@@ -1,5 +1,6 @@
"""Host account authorization for account-scoped capability selection."""
from __future__ import annotations
+from dataclasses import dataclass
from typing import Protocol
class ExecutionAccount(Protocol):
@@ -8,7 +9,6 @@ def current_local_user_id(self) -> str | None: ...
def account_authorized_for(self, user_id: str | None) -> bool: ...
-
def register_execution_account(provider: ExecutionAccount | None) -> None:
pass
registration_state().provider = provider
@@ -23,19 +23,13 @@ def account_authorized_for(user_id: str | None) -> bool:
return bool(registration_state().provider is not None and registration_state().provider.account_authorized_for(user_id))
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
provider: ExecutionAccount | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
+
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/execution_catalog.py b/src/backend/core/harness/registry/execution_catalog.py
index 0fa377ed..fd90b656 100644
--- a/src/backend/core/harness/registry/execution_catalog.py
+++ b/src/backend/core/harness/registry/execution_catalog.py
@@ -1,5 +1,6 @@
"""Host-owned execution catalog selection, optional on cloud-only hosts."""
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any, Protocol
class ExecutionCatalog(Protocol):
@@ -10,7 +11,6 @@ def apply_to_enabled_skill_ids(self, ids: list[str] | None) -> list[str] | None:
def cloud_gateway_mcp_configs(self, enabled_mcp_ids=None, *, resolution_out=None, unavailable_out=None) -> dict: ...
-
def register_execution_catalog(provider: ExecutionCatalog | None) -> None:
pass
registration_state().provider = provider
@@ -39,19 +39,13 @@ def authorizes_manager(user_id, server_id, manager):
return bool(config and config.get("gateway_plugin") == manager)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
provider: ExecutionCatalog | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
+
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/execution_identity.py b/src/backend/core/harness/registry/execution_identity.py
index 86013957..dc0308d2 100644
--- a/src/backend/core/harness/registry/execution_identity.py
+++ b/src/backend/core/harness/registry/execution_identity.py
@@ -1,6 +1,7 @@
"""Host-owned execution identity contract for core model and tool operations."""
from __future__ import annotations
from contextlib import AbstractContextManager
+from dataclasses import dataclass
from typing import Any, Protocol
@@ -16,9 +17,6 @@ def account_scope(self, state: dict[str, Any]) -> AbstractContextManager: ...
def clear_state(self) -> None: ...
-
-
-
def register_execution_identity(provider: ExecutionIdentity | None) -> None:
pass
registration_state().provider = provider
@@ -68,19 +66,13 @@ def notify_cloud_changed():
return require_provider().notify_cloud_changed()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
provider: ExecutionIdentity | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/hook_factories.py b/src/backend/core/harness/registry/hook_factories.py
index b8ec0b07..11b41778 100644
--- a/src/backend/core/harness/registry/hook_factories.py
+++ b/src/backend/core/harness/registry/hook_factories.py
@@ -10,7 +10,6 @@
HookFactory = Callable[[Any], Iterable["HookSpec"]]
-
def register_hook_factory(factory: HookFactory) -> None:
"""Register once; invoke afresh for each agent assembly."""
if factory not in registration_state().factories:
@@ -23,7 +22,7 @@ def hook_specs(request: Any) -> Iterator[HookSpec]:
# Run-domain policies are created at assembly time, never shared between agents.
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from functools import lru_cache
@@ -58,19 +57,13 @@ def command_completion_hooks():
return factory()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class RegistrationState:
+ factories: list[HookFactory] = field(default_factory=list)
-@state_dataclass
-class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
- factories: list[HookFactory] = state_field(default_factory=list)
+_REGISTRATIONS = RegistrationState()
-@state_cache(maxsize=1)
def registration_state() -> RegistrationState:
- return RegistrationState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/local_publication.py b/src/backend/core/harness/registry/local_publication.py
index f5b5536a..f5a35e5e 100644
--- a/src/backend/core/harness/registry/local_publication.py
+++ b/src/backend/core/harness/registry/local_publication.py
@@ -1,5 +1,6 @@
"""Host-owned publication of local skill views and installation revisions."""
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any, Protocol
class LocalPublication(Protocol):
@@ -30,7 +31,6 @@ def stamp_installation(self, db: Any, install_id: str, stamp: Any) -> None: ...
def claim_revision(self, db: Any, install_id: str, expected_revision: str) -> int: ...
-
def register_local_publication(provider: LocalPublication) -> None:
pass
registration_state().provider = provider
@@ -57,19 +57,13 @@ async def ensure_native_servers(configs):
return await local_publication().ensure_native_servers(configs)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
provider: LocalPublication | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
+
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/marketplace_catalog.py b/src/backend/core/harness/registry/marketplace_catalog.py
index f3dfccdf..e74d634e 100644
--- a/src/backend/core/harness/registry/marketplace_catalog.py
+++ b/src/backend/core/harness/registry/marketplace_catalog.py
@@ -1,5 +1,6 @@
"""Marketplace-owned listings, grants and installation operations."""
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any, Protocol
class MarketplaceCatalog(Protocol):
@@ -7,7 +8,7 @@ def plugin_metadata(self, db: Any, slug: str) -> dict: ...
def mcp_version(self, db: Any, slug: str) -> tuple[Any, Any]: ...
def normalize_auth_config(self, config: Any, schema: list) -> dict: ...
def installation_secrets(self, version: Any, credentials: dict, existing: dict, *, auth_method: str) -> dict: ...
- async def install_market_item(self, db: Any, slug: str, **kwargs: Any) -> dict: ...
+ async def install_market_item(self, slug: str, **kwargs: Any) -> dict: ...
def market_package(self, db: Any, slug: str) -> Any: ...
def publish_package(self, db: Any, slug: str, fields: dict, *, created_by: str | None, now: Any) -> str: ...
def delete_package(self, db: Any, slug: str) -> None: ...
@@ -21,7 +22,6 @@ def install_marketplace_skill(self, db: Any, slug: str, *, owner_user_id: str |
def install_marketplace_agent(self, db: Any, slug: str, *, owner_user_id: str | None) -> dict: ...
-
def register_marketplace_catalog(provider: MarketplaceCatalog) -> None:
pass
registration_state().provider = provider
@@ -32,19 +32,13 @@ def marketplace_catalog() -> MarketplaceCatalog:
return registration_state().provider
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
provider: MarketplaceCatalog | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
+
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/mcp_clients.py b/src/backend/core/harness/registry/mcp_clients.py
index 744c9b69..65ca4de3 100644
--- a/src/backend/core/harness/registry/mcp_clients.py
+++ b/src/backend/core/harness/registry/mcp_clients.py
@@ -14,7 +14,7 @@ def client_factory() -> ClientFactory:
def register_mcp_client_factory(factory: Callable[..., Any]) -> None:
client_factory().create = factory
-def make_client(*args, **kwargs):
+def discover_tools(*args, **kwargs):
factory = client_factory().create
if factory is None:
raise RuntimeError("MCP client factory is not registered")
diff --git a/src/backend/core/harness/registry/model_credentials.py b/src/backend/core/harness/registry/model_credentials.py
index e3b488a1..85410553 100644
--- a/src/backend/core/harness/registry/model_credentials.py
+++ b/src/backend/core/harness/registry/model_credentials.py
@@ -1,11 +1,10 @@
"""Host-selected request authorization; no execution-environment imports."""
from __future__ import annotations
from collections.abc import Callable
+from dataclasses import dataclass
from typing import Protocol
-
-
def register_request_header_resolver(resolver: Callable | None) -> None:
pass
registration_state().resolver = resolver
@@ -25,9 +24,6 @@ def request_hook(self, reference, base_url): ...
def sync_client_kwargs(self, reference, base_url, *, timeout=600): ...
-
-
-
def register_model_credential_provider(provider):
pass
registration_state().provider = provider
@@ -61,20 +57,14 @@ def sync_client_kwargs(reference, base_url, *, timeout=600):
return require_provider().sync_client_kwargs(reference, base_url, timeout=timeout)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
resolver: Callable | None = None
provider: ModelCredentialProvider | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/model_services.py b/src/backend/core/harness/registry/model_services.py
index 4cdb66d2..590e2069 100644
--- a/src/backend/core/harness/registry/model_services.py
+++ b/src/backend/core/harness/registry/model_services.py
@@ -1,14 +1,9 @@
"""Host-provided model operations used by capability and evolution domains."""
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any, Callable
-
-
-
-
-
-
def register_model_services(*, ranker: Callable[..., list[str]], prompt_reader: Callable[[], list[tuple[str, dict[str, Any]]]], prompt_renderer: Callable, text_agent: Callable, environment: Callable) -> None:
pass
registration_state().ranker = ranker
@@ -48,13 +43,8 @@ def model_environment():
return registration_state().environment()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
ranker: Callable[..., list[str]] | None = None
prompt_reader: Callable[[], list[tuple[str, dict[str, Any]]]] | None = None
prompt_renderer: Callable | None = None
@@ -62,9 +52,8 @@ class RegistrationState:
environment: Callable | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/package_sources.py b/src/backend/core/harness/registry/package_sources.py
index a84cf8a7..ce2f485c 100644
--- a/src/backend/core/harness/registry/package_sources.py
+++ b/src/backend/core/harness/registry/package_sources.py
@@ -1,5 +1,6 @@
"""Source and upgrade contracts for plugin packages."""
from __future__ import annotations
+from dataclasses import dataclass, field
from typing import Any, Protocol
@@ -15,10 +16,6 @@ def reserve_local(self, existing: Any, revision: str, files: Any) -> None: ...
def prepare_local(self, db: Any, current: Any, prepared: Any) -> Any: ...
-
-
-
-
def register_package_source(name: str, source: PackageSource) -> None:
registration_state().sources[name] = source
@@ -38,20 +35,14 @@ def package_upgrade_policy() -> PackageUpgradePolicy:
return registration_state().upgrade_policy
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
- sources: dict[str, PackageSource] = state_field(default_factory=dict)
+ sources: dict[str, PackageSource] = field(default_factory=dict)
upgrade_policy: PackageUpgradePolicy | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/registry.py b/src/backend/core/harness/registry/registry.py
index 3a3dd280..9b7798ca 100644
--- a/src/backend/core/harness/registry/registry.py
+++ b/src/backend/core/harness/registry/registry.py
@@ -36,14 +36,6 @@ class AgentProfile:
native_skill_prompt: bool = True
-
-
-
-
-
-
-
-
def mark_enabled(name: str) -> None:
registration_state().enabled.add(name)
@@ -119,9 +111,6 @@ class SandboxCredentials:
prepare_order: int = 0
-
-
-
def register_sandbox_credentials(provider: SandboxCredentials) -> None:
registration_state().sandbox_credentials[provider.name] = provider
@@ -150,9 +139,6 @@ class NativeToolContribution:
stage: str
-
-
-
def register_tool(name, factory, enabled, *, stage):
"""Register a conditional tool at an ordered native assembly stage."""
registration_state().tools[name] = NativeToolContribution(name, factory, enabled, stage)
@@ -164,26 +150,20 @@ async def install_tools(stage, toolkit, context):
await contribution.factory(toolkit, context)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class RegistrationState:
+ enabled: set[str] = field(default_factory=set)
+ agent_profiles: dict[str, AgentProfile] = field(default_factory=dict)
+ sandbox_routes: list[tuple[str, Callable[[], Any]]] = field(default_factory=list)
+ channel_routes: list[tuple[Callable[[Any, Any], bool], Callable[..., Awaitable[None]]]] = field(default_factory=list)
+ lifecycle: list[tuple[Callable[[], Awaitable[None]], Callable[[], Awaitable[None]]]] = field(default_factory=list)
+ routers: list[tuple[Any, Optional[str]]] = field(default_factory=list)
+ sandbox_credentials: dict[str, SandboxCredentials] = field(default_factory=dict)
+ tools: dict[str, NativeToolContribution] = field(default_factory=dict)
-@state_dataclass
-class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
- enabled: set[str] = state_field(default_factory=set)
- agent_profiles: dict[str, AgentProfile] = state_field(default_factory=dict)
- sandbox_routes: list[tuple[str, Callable[[], Any]]] = state_field(default_factory=list)
- channel_routes: list[tuple[Callable[[Any, Any], bool], Callable[..., Awaitable[None]]]] = state_field(default_factory=list)
- lifecycle: list[tuple[Callable[[], Awaitable[None]], Callable[[], Awaitable[None]]]] = state_field(default_factory=list)
- routers: list[tuple[Any, Optional[str]]] = state_field(default_factory=list)
- sandbox_credentials: dict[str, SandboxCredentials] = state_field(default_factory=dict)
- tools: dict[str, NativeToolContribution] = state_field(default_factory=dict)
-
-
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/resource_owners.py b/src/backend/core/harness/registry/resource_owners.py
index 97861055..9edce5ac 100644
--- a/src/backend/core/harness/registry/resource_owners.py
+++ b/src/backend/core/harness/registry/resource_owners.py
@@ -25,9 +25,6 @@ class ResourceOwner:
revoke_session: Callable[..., Any]
-
-
-
def register_resource_owner(owner: ResourceOwner) -> None:
pass
registration_state().owner = owner
@@ -39,19 +36,13 @@ def resource_owner() -> ResourceOwner:
return registration_state().owner
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
owner: ResourceOwner | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/retrieval_context.py b/src/backend/core/harness/registry/retrieval_context.py
index b09acb71..ba24ff18 100644
--- a/src/backend/core/harness/registry/retrieval_context.py
+++ b/src/backend/core/harness/registry/retrieval_context.py
@@ -1,8 +1,7 @@
"""External retrieval context supplied by an installed domain."""
from __future__ import annotations
from collections.abc import Callable
-
-
+from dataclasses import dataclass
def register_retrieval_context(provider: Callable[[list[str]], tuple[dict, dict]] | None) -> None:
@@ -14,9 +13,6 @@ def runtime_request_context(selected_ids: list[str]) -> tuple[dict, dict]:
return registration_state().provider(selected_ids) if registration_state().provider is not None else ({}, {})
-
-
-
def register_retrieval_access_filter(provider: Callable) -> None:
pass
registration_state().access_filter = provider
@@ -28,20 +24,14 @@ def filter_accessible_kb_ids(db, user_id: str, selected_ids: list[str]) -> list[
return registration_state().access_filter(db, user_id, selected_ids)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-
-@state_dataclass
+@dataclass
class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
provider: Callable[[list[str]], tuple[dict, dict]] | None = None
access_filter: Callable | None = None
-@state_cache(maxsize=1)
-def registration_state() -> RegistrationState:
- return RegistrationState()
+_REGISTRATIONS = RegistrationState()
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+
+def registration_state() -> RegistrationState:
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/run_capabilities.py b/src/backend/core/harness/registry/run_capabilities.py
index 4b2444ee..d241094c 100644
--- a/src/backend/core/harness/registry/run_capabilities.py
+++ b/src/backend/core/harness/registry/run_capabilities.py
@@ -1,6 +1,7 @@
"""Per-run capability contracts supplied by an installed execution environment."""
from __future__ import annotations
from collections.abc import Callable, Mapping
+from dataclasses import dataclass, field
from typing import Any, Protocol
@@ -17,7 +18,7 @@ def validate(self, *, user_id: str) -> None: ...
def record_tool_scope(self, tool_call_id: str, tool_name: str) -> None: ...
def child_scope(self, kind: str, *identities: str) -> str: ...
def skill_loader(self) -> Any: ...
- def wrap_mcp_clients(self, clients: list, *, scope_id: str) -> list: ...
+ def tool_guard(self, *, scope_id: str): ...
def bind_mcp(self, servers: dict, resolution: Any) -> dict: ...
def preflight(self, **context: Any) -> PreparedCapabilities: ...
@@ -58,7 +59,6 @@ async def prepare(self, request, **selection):
_default = CloudCapabilities()
-
def register_run_capability_provider(name, provider, *, enabled):
registration_state().providers[name] = (enabled, provider)
@@ -102,19 +102,13 @@ def cloud_plugin_selection(plugin_id, user_id):
return run_capability_provider().cloud_plugin_selection(plugin_id, user_id)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class RegistrationState:
+ providers: dict[str, tuple[Callable[[], bool], RunCapabilityProvider]] = field(default_factory=dict)
-@state_dataclass
-class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
- providers: dict[str, tuple[Callable[[], bool], RunCapabilityProvider]] = state_field(default_factory=dict)
+_REGISTRATIONS = RegistrationState()
-@state_cache(maxsize=1)
def registration_state() -> RegistrationState:
- return RegistrationState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/schema_lifecycle.py b/src/backend/core/harness/registry/schema_lifecycle.py
index 25bfbd44..3e1a30d5 100644
--- a/src/backend/core/harness/registry/schema_lifecycle.py
+++ b/src/backend/core/harness/registry/schema_lifecycle.py
@@ -1,11 +1,10 @@
"""Synchronous installed-module lifecycle callbacks during local schema startup."""
from __future__ import annotations
from collections.abc import Callable, Iterable
+from dataclasses import dataclass, field
from importlib import import_module
-
-
def register_schema_lifecycle(name: str, callback: Callable[[], None]) -> None:
"""Register an idempotent local-SQLite initializer by stable owner name."""
registration_state().callbacks[name] = callback
@@ -25,19 +24,13 @@ def initialize_local_schema() -> None:
callback()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class RegistrationState:
+ callbacks: dict[str, Callable[[], None]] = field(default_factory=dict)
-@state_dataclass
-class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
- callbacks: dict[str, Callable[[], None]] = state_field(default_factory=dict)
+_REGISTRATIONS = RegistrationState()
-@state_cache(maxsize=1)
def registration_state() -> RegistrationState:
- return RegistrationState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/tool_operations.py b/src/backend/core/harness/registry/tool_operations.py
index 74d18939..916bda30 100644
--- a/src/backend/core/harness/registry/tool_operations.py
+++ b/src/backend/core/harness/registry/tool_operations.py
@@ -1,7 +1,7 @@
"""Host registrations for component-owned local dispatch and durable receipts."""
from __future__ import annotations
from collections.abc import Callable
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Any
@@ -20,9 +20,6 @@ class ToolOperations:
prepare_arguments: Callable[..., dict] | None = None
-
-
-
def register_tool_operations(component: str, operations: ToolOperations) -> None:
registration_state().components[component] = operations
@@ -43,7 +40,6 @@ async def local_tool_result(component, tool_name, arguments, headers, *, authori
return await provider.dispatch_local(component, tool_name, arguments, headers, authorize=authorize)
-
def prepare_tool_arguments(tool_name, arguments, user_id, chat_id):
provider = receipt_operations(tool_name)
if provider is None or provider.prepare_arguments is None:
@@ -51,19 +47,13 @@ def prepare_tool_arguments(tool_name, arguments, user_id, chat_id):
return provider.prepare_arguments(tool_name, arguments, user_id, chat_id)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class RegistrationState:
+ components: dict[str, ToolOperations] = field(default_factory=dict)
-@state_dataclass
-class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
- components: dict[str, ToolOperations] = state_field(default_factory=dict)
+_REGISTRATIONS = RegistrationState()
-@state_cache(maxsize=1)
def registration_state() -> RegistrationState:
- return RegistrationState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/transfer_channels.py b/src/backend/core/harness/registry/transfer_channels.py
index b0aba995..fd736fd4 100644
--- a/src/backend/core/harness/registry/transfer_channels.py
+++ b/src/backend/core/harness/registry/transfer_channels.py
@@ -8,7 +8,7 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
# Wire contract shared by the uploading client and the receiving gateway route.
@@ -34,11 +34,6 @@ class UploadReceiver:
publish: Callable[..., Any]
-
-
-
-
-
def register_upload_channel(source_plugin, tool_name, factory, *, receiver=None):
registration_state().CHANNELS.setdefault(source_plugin, {})[tool_name] = factory
if receiver is not None:
@@ -76,21 +71,15 @@ def result_localizer(source_plugin: str, tool_name: str):
return registration_state().LOCALIZERS.get(source_plugin, {}).get(tool_name)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class RegistrationState:
+ CHANNELS: Dict[str, Dict[str, Callable[[], UploadChannel]]] = field(default_factory=dict)
+ LOCALIZERS: Dict[str, Dict[str, Callable[..., Any]]] = field(default_factory=dict)
+ RECEIVERS: Dict[str, UploadReceiver] = field(default_factory=dict)
-@state_dataclass
-class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
- CHANNELS: Dict[str, Dict[str, Callable[[], UploadChannel]]] = state_field(default_factory=dict)
- LOCALIZERS: Dict[str, Dict[str, Callable[..., Any]]] = state_field(default_factory=dict)
- RECEIVERS: Dict[str, UploadReceiver] = state_field(default_factory=dict)
+_REGISTRATIONS = RegistrationState()
-@state_cache(maxsize=1)
def registration_state() -> RegistrationState:
- return RegistrationState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/registry/user_cleanup.py b/src/backend/core/harness/registry/user_cleanup.py
index 602d779b..862a4d14 100644
--- a/src/backend/core/harness/registry/user_cleanup.py
+++ b/src/backend/core/harness/registry/user_cleanup.py
@@ -1,11 +1,10 @@
"""Transactional cleanup contributions for ORM user deletion."""
from __future__ import annotations
from collections.abc import Callable
+from dataclasses import dataclass, field
from typing import Any
-
-
def register_user_cleanup(handler: Callable[[Any, Any], None]) -> None:
if handler not in registration_state().handlers:
registration_state().handlers.append(handler)
@@ -16,19 +15,13 @@ def cleanup_user(session: Any, user: Any) -> None:
handler(session, user)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class RegistrationState:
+ handlers: list[Callable[[Any, Any], None]] = field(default_factory=list)
-@state_dataclass
-class RegistrationState:
- """Process-owned registrations; replace only while the host is quiescent."""
- handlers: list[Callable[[Any, Any], None]] = state_field(default_factory=list)
+_REGISTRATIONS = RegistrationState()
-@state_cache(maxsize=1)
def registration_state() -> RegistrationState:
- return RegistrationState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-registration_state()
+ return _REGISTRATIONS
diff --git a/src/backend/core/harness/usage.py b/src/backend/core/harness/usage.py
index 230a0c9f..d6ad31fb 100644
--- a/src/backend/core/harness/usage.py
+++ b/src/backend/core/harness/usage.py
@@ -4,7 +4,7 @@
import asyncio
import threading
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Protocol
@@ -50,33 +50,56 @@ def record_attempt(self, attempt: UsageAttempt) -> UsageAttempt:
"""Append one attempt and return it with its allocated sequence."""
+def record_attempt_batch(recorder: UsageRecorder, attempts: Sequence[UsageAttempt]) -> tuple[UsageAttempt, ...]:
+ batch = getattr(recorder, "record_attempts", None)
+ if batch is not None:
+ return tuple(batch(attempts))
+ return tuple(recorder.record_attempt(attempt) for attempt in attempts)
+
+
def attempt_status_for_exception(exc: BaseException) -> str:
"""Classify provider/library timeout types without importing each SDK."""
name = type(exc).__name__.lower()
return "timeout" if isinstance(exc, TimeoutError) or "timeout" in name else "failed"
-async def record_usage_safely(
+async def record_attempts_safely(
recorder: UsageRecorder,
- attempt: UsageAttempt,
+ attempts: Sequence[UsageAttempt],
*,
timeout_ms: int = 1_000,
-) -> UsageAttempt | None:
- """Bound observability I/O so it cannot become an execution dependency."""
+) -> tuple[UsageAttempt, ...] | None:
+ """Bound observability I/O so it cannot become an execution dependency.
+
+ All attempts go to the recorder in one call, so batching recorders write
+ them in one transaction.
+ """
+ if not attempts:
+ return ()
try:
return await asyncio.wait_for(
- asyncio.to_thread(recorder.record_attempt, attempt),
+ asyncio.to_thread(record_attempt_batch, recorder, tuple(attempts)),
timeout=max(1, int(timeout_ms)) / 1_000,
)
except Exception: # noqa: BLE001
return None
+async def record_usage_safely(
+ recorder: UsageRecorder,
+ attempt: UsageAttempt,
+ *,
+ timeout_ms: int = 1_000,
+) -> UsageAttempt | None:
+ recorded = await record_attempts_safely(recorder, (attempt,), timeout_ms=timeout_ms)
+ return recorded[0] if recorded else None
+
+
class MemoryUsageRecorder:
"""Thread-safe append-only recorder for unit tests and embedded runtimes."""
def __init__(self) -> None:
- self._lock = threading.Lock()
+ self._lock = threading.RLock()
self._rows: dict[str, list[UsageAttempt]] = {}
def record_attempt(self, attempt: UsageAttempt) -> UsageAttempt:
@@ -91,6 +114,10 @@ def record_attempt(self, attempt: UsageAttempt) -> UsageAttempt:
rows.append(recorded)
return recorded
+ def record_attempts(self, attempts: Sequence[UsageAttempt]) -> tuple[UsageAttempt, ...]:
+ with self._lock:
+ return tuple(self.record_attempt(attempt) for attempt in attempts)
+
def attempts(self, run_id: str) -> tuple[UsageAttempt, ...]:
with self._lock:
return tuple(self._rows.get(run_id, ()))
diff --git a/src/backend/core/infra/background.py b/src/backend/core/infra/background.py
index c7960aa7..2083485f 100644
--- a/src/backend/core/infra/background.py
+++ b/src/backend/core/infra/background.py
@@ -18,6 +18,7 @@
from __future__ import annotations
import asyncio
+from dataclasses import dataclass, field
from typing import Any, Coroutine, Set
from core.infra.logging import get_logger
@@ -25,8 +26,6 @@
logger = get_logger(__name__)
-
-
def spawn(coro: Coroutine[Any, Any, Any], *, name: str) -> asyncio.Task:
"""把 *coro* 挂到当前事件循环后台执行,返回值不必接收。
@@ -56,18 +55,13 @@ def _forget(task: asyncio.Task) -> None:
logger.warning("background_task_failed", task=task.get_name(), error=str(exc))
+@dataclass
+class BackgroundState:
+ tasks: Set[asyncio.Task] = field(default_factory=lambda: set())
+
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+_STATE = BackgroundState()
-@state_dataclass
-class BackgroundState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- tasks: Set[asyncio.Task] = state_field(default_factory=lambda: set())
-@state_cache(maxsize=1)
def process_state() -> BackgroundState:
- return BackgroundState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+ return _STATE
diff --git a/src/backend/core/infra/crypto.py b/src/backend/core/infra/crypto.py
index 677115fc..72eff767 100644
--- a/src/backend/core/infra/crypto.py
+++ b/src/backend/core/infra/crypto.py
@@ -20,6 +20,7 @@
import hashlib
import logging
import os
+from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
@@ -29,7 +30,6 @@
_DEV_FALLBACK = "hugagent-email-dev-secret-please-override"
-
def _deployment_secret() -> str:
sec = (os.environ.get("EMAIL_SECRET_KEY") or "").strip()
if sec:
@@ -49,7 +49,6 @@ def _deployment_secret() -> str:
# The derived key comes from the deployment-level secret and is constant at runtime → lazily construct it once and reuse, avoiding the SHA256+Fernet init on every encrypt/decrypt.
-
def _fernet():
if process_state().fernet_cache is None:
from cryptography.fernet import Fernet
@@ -75,18 +74,14 @@ def decrypt_secret(token: Optional[str]) -> Optional[str]:
return None
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class CryptoState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
warned: object = False
fernet_cache: object = None
-@state_cache(maxsize=1)
-def process_state() -> CryptoState:
- return CryptoState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = CryptoState()
+
+
+def process_state() -> CryptoState:
+ return _STATE
diff --git a/src/backend/core/infra/dns_cache.py b/src/backend/core/infra/dns_cache.py
index fc8271c4..64b3a90c 100644
--- a/src/backend/core/infra/dns_cache.py
+++ b/src/backend/core/infra/dns_cache.py
@@ -29,14 +29,13 @@
import socket
import threading
import time
+from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Tuple
DEFAULT_TTL_S = 300.0
MAX_ENTRIES = 256
-
-
_ttl_s = DEFAULT_TTL_S
@@ -94,19 +93,15 @@ def installed() -> bool:
return process_state().original is not None
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class DnsCacheState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- lock: object = state_field(default_factory=lambda: threading.Lock())
- cache: Dict[Tuple[Any, ...], Tuple[float, Any]] = state_field(default_factory=lambda: {})
+ lock: object = field(default_factory=lambda: threading.Lock())
+ cache: Dict[Tuple[Any, ...], Tuple[float, Any]] = field(default_factory=lambda: {})
original: object = None
-@state_cache(maxsize=1)
-def process_state() -> DnsCacheState:
- return DnsCacheState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = DnsCacheState()
+
+
+def process_state() -> DnsCacheState:
+ return _STATE
diff --git a/src/backend/core/infra/leader.py b/src/backend/core/infra/leader.py
index c91b284d..d3b28370 100644
--- a/src/backend/core/infra/leader.py
+++ b/src/backend/core/infra/leader.py
@@ -1,207 +1,138 @@
-"""Work that must happen in exactly one backend process.
-
-The backend serves requests from several uvicorn workers, but a large part of
-what it starts at boot is not per-worker work: reapers, schedulers, recovery
-passes, outbound channel connections and the seeding of default rows. Running
-those once per worker is not merely wasteful — a scheduler tick fires a task N
-times, a recovery pass fights itself over the same orphan rows, and two seeders
-race on the same unique key.
-
-Two shapes cover every such step, and both are expressed against the
-``hold``/``claim`` mutex in :mod:`core.infra.ephemeral`, so a deployment
-without Redis (the desktop's single-process backend) gets the same semantics
-from the in-process store it already uses for every other short-lived key.
-
-``Leadership``
- A renewed lease for the long-lived loops. One worker wins it and starts
- them; it keeps the lease alive for as long as it is healthy. If that worker
- dies, the lease lapses and the next worker to look takes over and starts
- them there — which is the property a bare "decide once at boot" check does
- not have, and the reason this is a loop rather than a single check.
-
-``run_once``
- A barrier for the one-shot boot steps. Exactly one worker runs the step;
- the others wait for it to finish before continuing, so no worker begins
- serving against half-seeded state.
-"""
-
+"""Lease-owned background work and fail-closed worker startup barriers."""
from __future__ import annotations
import asyncio
import contextlib
+import math
import os
import socket
import uuid
-from typing import Awaitable, Callable, Optional
+from typing import Awaitable, Callable
from core.infra.ephemeral import get_ephemeral_state
from core.infra.logging import get_logger
from core.infra.worker_count import resolve as resolved_workers
logger = get_logger(__name__)
-
-_LEADER_PREFIX = "jx:leader:"
-_ONCE_PREFIX = "jx:boot:"
-
-# The lease has to outlive a renewal that is merely slow — a worker pausing for
-# a GC or a busy disk must not hand its loops to a second process — while still
-# expiring soon enough that a *dead* worker's loops restart promptly. Renewal
-# runs at a third of it, so two consecutive renewals can be lost before the
-# lease actually lapses.
_LEASE_SECONDS = 45
-_RENEW_SECONDS = _LEASE_SECONDS / 3
-
-# How often a worker waiting on a boot barrier looks to see whether the worker
-# running the step has finished.
_POLL_SECONDS = 0.2
+Callback = Callable[[], Awaitable[None]]
def _token() -> str:
- """An identity no other process can accidentally present."""
- return f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
+ return f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex}"
def coordination_required() -> bool:
- """Whether this deployment actually runs more than one backend process.
-
- A single-process backend has nobody to coordinate with, and saying so is
- not an optimisation — it is the difference between booting and not: the
- desktop profile runs one process with no Redis at all, and neither it nor a
- deployment whose Redis is briefly down should acquire a dependency on a
- mutex that can only ever be uncontended.
-
- It asks :func:`~core.infra.worker_count.resolve` rather than reading
- ``WEB_CONCURRENCY``, and that distinction is load-bearing twice over. The
- resolved count already refuses multi-worker without Redis, which is exactly
- when :func:`get_ephemeral_state` would hand back the per-process store and
- this lease would quietly stop being a lease. And a launch path that never
- ran the entrypoint — ``cli.py``, ``api.app.main`` — would otherwise read the
- *requested* value and believe a single process is a cluster.
- """
return resolved_workers() > 1
class Leadership:
- """A renewed lease naming one process the owner of a role's singleton work."""
+ """Stop owned work before releasing the token; uncertainty revokes ownership."""
def __init__(self, role: str, *, lease_seconds: int = _LEASE_SECONDS) -> None:
- self._key = f"{_LEADER_PREFIX}{role}"
+ self._key = f"jx:leader:{role}"
self._role = role
self._token = _token()
self._lease = int(lease_seconds)
self._elected = False
- self._task: Optional[asyncio.Task] = None
- self._work: Optional[asyncio.Task] = None
+ self._task: asyncio.Task | None = None
+ self._work: asyncio.Task | None = None
+ self._on_deposed: Callback | None = None
async def _hold(self) -> bool:
if not coordination_required():
return True
try:
- return await get_ephemeral_state().hold(self._key, self._token, ttl=self._lease)
- except Exception as exc: # noqa: BLE001 - a store blip must not depose a healthy leader
+ state = get_ephemeral_state()
+ # Renewal must never recreate an expired lease.
+ operation = (
+ state.compare_exchange(self._key, self._token, self._token, ttl=self._lease)
+ if self._elected
+ else state.hold(self._key, self._token, ttl=self._lease)
+ )
+ return await asyncio.wait_for(operation, timeout=self._lease / 3)
+ except Exception as exc:
logger.warning("leader_hold_failed", role=self._role, error=str(exc))
- return self._elected
+ return False
+
+ async def _depose(self) -> None:
+ if not self._elected:
+ return
+ if self._work is not None:
+ if not self._work.done():
+ self._work.cancel()
+ with contextlib.suppress(asyncio.CancelledError, Exception):
+ await self._work
+ self._work = None
+ # A failed stop must not release ownership or permit a new local start.
+ if self._on_deposed is not None:
+ await self._on_deposed()
+ self._elected = False
+ if coordination_required():
+ try:
+ await get_ephemeral_state().release(self._key, self._token)
+ except Exception as exc:
+ logger.warning("leader_release_failed", role=self._role, error=str(exc))
- async def _supervise(
- self,
- on_elected: Callable[[], Awaitable[None]],
- on_deposed: Callable[[], Awaitable[None]],
- ) -> None:
+ async def _supervise(self, on_elected: Callback) -> None:
while True:
holding = await self._hold()
- if holding and not self._elected:
+ failed = self._work is not None and self._work.done() and (
+ self._work.cancelled() or self._work.exception() is not None
+ )
+ if self._elected and (not holding or failed):
+ logger.warning("leader_deposed", role=self._role, startup_failed=failed)
+ await self._depose()
+ elif holding and not self._elected:
self._elected = True
- logger.info("leader_elected", role=self._role, token=self._token)
- # Started, never awaited: the callback brings up every singleton
- # loop in turn and can easily outlast a lease. Awaiting it here
- # would stop renewing for that whole stretch, the lease would
- # lapse mid-startup, and a second worker would elect itself and
- # bring up a duplicate set — the exact outcome this exists to
- # prevent, now paid for twice.
self._work = asyncio.create_task(on_elected(), name=f"leader_work:{self._role}")
- if not coordination_required():
- # Uncontended by construction: nothing can take this role,
- # so there is nothing left to supervise. Returning keeps
- # single-process deployments — the default, and the
- # battery-powered desktop one — free of a forever timer.
- return
- elif not holding and self._elected:
- # Losing a lease we were renewing means this process stalled
- # past the whole lease; another worker owns the loops now and
- # ours have to stop, or both run.
- self._elected = False
- logger.warning("leader_deposed", role=self._role, token=self._token)
- await self._cancel_work()
- await on_deposed()
- await asyncio.sleep(_RENEW_SECONDS)
-
- async def _cancel_work(self) -> None:
- if self._work is None or self._work.done():
- return
- self._work.cancel()
- with contextlib.suppress(asyncio.CancelledError):
- await self._work
-
- def start(
- self,
- *,
- on_elected: Callable[[], Awaitable[None]],
- on_deposed: Callable[[], Awaitable[None]],
- ) -> None:
- self._task = asyncio.create_task(
- self._supervise(on_elected, on_deposed), name=f"leader:{self._role}"
- )
+ await asyncio.sleep(self._lease / 3)
+
+ def start(self, *, on_elected: Callback, on_deposed: Callback) -> None:
+ if self._task is not None:
+ raise RuntimeError("Leadership already started")
+ self._on_deposed = on_deposed
+ self._task = asyncio.create_task(self._supervise(on_elected), name=f"leader:{self._role}")
async def stop(self) -> None:
- if self._task is not None and not self._task.done():
- self._task.cancel()
+ if self._task is not None:
+ if not self._task.done():
+ self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
- # The elected work used to live in the deferred-startup task, which the
- # shutdown path cancels; now it is ours, so cancelling it is too.
- await self._cancel_work()
- if self._elected:
- self._elected = False
- try:
- await get_ephemeral_state().drop(self._key)
- except Exception as exc: # noqa: BLE001 - shutdown must not raise
- logger.warning("leader_release_failed", role=self._role, error=str(exc))
+ self._task = None
+ await self._depose()
async def run_once(
- name: str, step: Callable[[], Awaitable[None]], *, timeout: float = 300.0
+ name: str, step: Callback, *, timeout: float = 300.0
) -> None:
- """Run *step* in one worker of this boot; the rest wait for it to finish.
-
- The workers of a single boot share a parent — uvicorn's master — so its pid
- names this boot without any coordination between them. The done marker is
- deliberately short-lived: a later restart is *supposed* to run the boot
- steps again, and only workers of the same boot, which all start within a
- few seconds of one another, ever need to see it.
- """
+ """All workers observe success, failure or timeout of required initialization."""
if not coordination_required():
await step()
return
-
state = get_ephemeral_state()
- boot = f"{_ONCE_PREFIX}{socket.gethostname()}:{os.getppid()}:{name}"
+ boot = f"jx:boot:{socket.gethostname()}:{os.getppid()}:{name}"
lock, done = f"{boot}:lock", f"{boot}:done"
-
- if await state.claim(lock, ttl=int(timeout)):
- try:
- await step()
- finally:
- # The marker is written even when the step raised: a failed boot
- # step is this worker's problem to log, not a reason to hang every
- # other worker until the timeout.
- await state.put(done, "1", ttl=60)
- return
-
- for _ in range(int(timeout / _POLL_SECONDS)):
- if await state.get(done) is not None:
+ deadline = asyncio.get_running_loop().time() + timeout
+ while True:
+ outcome = await state.get(done)
+ if outcome == "ok":
+ return
+ if outcome is not None:
+ raise RuntimeError(f"Required startup step failed: {name}")
+ if await state.claim(lock, ttl=max(1, math.ceil(timeout) + 1)):
+ try:
+ await asyncio.wait_for(step(), timeout=timeout)
+ except BaseException:
+ await state.put(done, "failed", ttl=max(60, math.ceil(timeout) * 2))
+ raise
+ await state.put(done, "ok", ttl=max(60, math.ceil(timeout) * 2))
return
+ if asyncio.get_running_loop().time() >= deadline:
+ raise TimeoutError(f"Required startup step timed out: {name}")
await asyncio.sleep(_POLL_SECONDS)
- logger.warning("boot_step_wait_timeout", name=name, after_seconds=timeout)
__all__ = ["Leadership", "coordination_required", "run_once"]
diff --git a/src/backend/core/infra/local_runtime/startup.py b/src/backend/core/infra/local_runtime/startup.py
index fce304b3..35d92eb1 100644
--- a/src/backend/core/infra/local_runtime/startup.py
+++ b/src/backend/core/infra/local_runtime/startup.py
@@ -317,6 +317,33 @@ async def _tcp_port_ready(host: str, port: int) -> bool:
except (OSError, asyncio.TimeoutError):
return False
+async def _http_port_ready(host: str, port: int) -> bool:
+ """Wait for an HTTP response before disconnecting a new MCP listener.
+
+ A connect-and-close probe can abort Windows Proactor's pending AcceptEx and
+ permanently stop that listener. Receiving response headers proves the server
+ accepted the connection; a missing root route (404/405) is still ready.
+ """
+ writer = None
+ try:
+ async with asyncio.timeout(2.0):
+ reader, writer = await asyncio.open_connection(host, port)
+ writer.write(
+ f"HEAD / HTTP/1.1\r\nHost: {host}:{port}\r\n"
+ "Connection: close\r\n\r\n".encode("ascii")
+ )
+ await writer.drain()
+ headers = await reader.readuntil(b"\r\n\r\n")
+ return headers.startswith((b"HTTP/1.0 ", b"HTTP/1.1 "))
+ except (OSError, TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError):
+ return False
+ finally:
+ if writer is not None:
+ writer.close()
+ with contextlib.suppress(OSError, TimeoutError):
+ await asyncio.wait_for(writer.wait_closed(), timeout=0.5)
+
+
async def _wait_for_mcp_ports(
launcher: "asyncio.subprocess.Process",
ports: dict[str, int],
@@ -330,7 +357,7 @@ async def _wait_for_mcp_ports(
if launcher.returncode is not None:
raise RuntimeError(f"MCP 启动器提前退出(exit={launcher.returncode})")
checks = await asyncio.gather(
- *(_tcp_port_ready("127.0.0.1", port) for port in pending.values())
+ *(_http_port_ready("127.0.0.1", port) for port in pending.values())
)
pending = {
server_id: port
@@ -345,19 +372,18 @@ async def _wait_for_mcp_ports(
async def _list_mcp_tool_names(server_id: str, port: int) -> set[str]:
"""Use the production MCP client to verify a server's actual tool list."""
- from core.harness.registry.mcp_clients import make_client
+ from core.harness.registry.mcp_clients import discover_tools
- client = make_client(
+ client = await discover_tools(
server_id,
{
"transport": "streamable_http",
"url": f"http://127.0.0.1:{port}/mcp/",
"transport_timeout": 5,
},
- is_stateful=False,
)
try:
- tools = await client.list_tools()
+ tools = client.tools
return {str(name) for tool in tools if (name := getattr(tool, "name", None))}
finally:
try:
diff --git a/src/backend/core/infra/probes/__init__.py b/src/backend/core/infra/probes/__init__.py
index 2718df01..4d450022 100644
--- a/src/backend/core/infra/probes/__init__.py
+++ b/src/backend/core/infra/probes/__init__.py
@@ -1 +1 @@
-"""probes domain package."""
+"""Health probes for the services the backend depends on."""
diff --git a/src/backend/core/infra/rate_limit.py b/src/backend/core/infra/rate_limit.py
deleted file mode 100644
index eb106ba3..00000000
--- a/src/backend/core/infra/rate_limit.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""HTTP rate limiter configuration. Circuit state lives in circuit_breaker."""
-
-from core.config.settings import settings
-from slowapi import Limiter
-from slowapi.util import get_remote_address
-
-
-def get_rate_limit_enabled() -> bool:
- return settings.rate_limit.enabled
-
-
-def get_rate_limit_storage() -> str:
- return settings.rate_limit.storage
-
-
-limiter = Limiter(
- key_func=get_remote_address,
- storage_uri=get_rate_limit_storage(),
- enabled=get_rate_limit_enabled(),
- headers_enabled=True,
-)
diff --git a/src/backend/core/infra/redis.py b/src/backend/core/infra/redis.py
index 102db02f..f361e7a1 100644
--- a/src/backend/core/infra/redis.py
+++ b/src/backend/core/infra/redis.py
@@ -3,6 +3,7 @@
import asyncio
import threading
+from dataclasses import dataclass, field
from weakref import WeakKeyDictionary
import redis.asyncio as aioredis
@@ -32,7 +33,6 @@
)
-
def redis_configured() -> bool:
"""Whether this deployment has a Redis to talk to.
@@ -140,17 +140,13 @@ async def close_redis() -> None:
logger.info("redis_pool_closed", event_loop=id(loop))
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class RedisState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- redis_pools_lock: object = state_field(default_factory=lambda: threading.Lock())
+ redis_pools_lock: object = field(default_factory=lambda: threading.Lock())
-@state_cache(maxsize=1)
-def process_state() -> RedisState:
- return RedisState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = RedisState()
+
+
+def process_state() -> RedisState:
+ return _STATE
diff --git a/src/backend/core/infra/runtime_state.py b/src/backend/core/infra/runtime_state.py
index 5045f855..d00de8e9 100644
--- a/src/backend/core/infra/runtime_state.py
+++ b/src/backend/core/infra/runtime_state.py
@@ -9,11 +9,10 @@
"""
from __future__ import annotations
+from dataclasses import dataclass, field
from typing import Any, Dict
-
-
def register(name: str, handle: Any) -> None:
"""Register (or replace) a named background task/scheduler handle."""
process_state().REGISTRY[name] = handle
@@ -29,17 +28,13 @@ def clear() -> None:
process_state().REGISTRY.clear()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class RuntimeState:
+ REGISTRY: Dict[str, Any] = field(default_factory=lambda: {})
+
-@state_dataclass
-class RuntimeStateState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- REGISTRY: Dict[str, Any] = state_field(default_factory=lambda: {})
+_STATE = RuntimeState()
-@state_cache(maxsize=1)
-def process_state() -> RuntimeStateState:
- return RuntimeStateState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+def process_state() -> RuntimeState:
+ return _STATE
diff --git a/src/backend/core/infra/time.py b/src/backend/core/infra/time.py
index 118562f9..a646ef02 100644
--- a/src/backend/core/infra/time.py
+++ b/src/backend/core/infra/time.py
@@ -1,17 +1,29 @@
"""Application instants use aware UTC; calendar schedules keep their explicit zone."""
from datetime import datetime, timezone
+from typing import Optional, overload
def utc_now() -> datetime:
return datetime.now(timezone.utc)
-def as_utc(value: datetime) -> datetime:
+@overload
+def as_utc(value: datetime) -> datetime: ...
+
+
+@overload
+def as_utc(value: None) -> None: ...
+
+
+def as_utc(value: Optional[datetime]) -> Optional[datetime]:
"""Normalize an instant; legacy timezone-less application values mean UTC.
+ ``None`` passes through so nullable columns can be normalized in place.
User-entered local calendar values must first be localized with their
declared business timezone, not passed here as naive timestamps.
"""
+ if value is None:
+ return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
diff --git a/src/backend/core/infra/worker_count.py b/src/backend/core/infra/worker_count.py
index adee4233..6dc228a5 100644
--- a/src/backend/core/infra/worker_count.py
+++ b/src/backend/core/infra/worker_count.py
@@ -33,6 +33,13 @@
only launch path: ``cli.py`` and ``api.app.main`` call ``uvicorn.run`` in
process, where nothing exported the resolved value. Deriving the answer instead
of trusting the environment keeps those paths from believing they are a cluster.
+
+Each worker also brings its own database pool, and that pool is not a separate
+knob: a process can only use as many connections as it has threads doing
+database work, so :func:`core.db.engine.thread_budget` derives the pool from
+``WORKER_THREADS`` (anyio's limiter) and the event loop's executor, and every
+worker applies those limits at startup. Postgres therefore needs
+``max_connections >= resolve() * (pool_size + overflow)`` for the backend alone.
"""
from __future__ import annotations
diff --git a/src/backend/core/jobs/owner.py b/src/backend/core/jobs/owner.py
index 93a4ff2a..8db024bb 100644
--- a/src/backend/core/jobs/owner.py
+++ b/src/backend/core/jobs/owner.py
@@ -5,13 +5,11 @@
import functools
import logging
import threading
+from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
-
-
-
def bind():
current = asyncio.get_running_loop()
with process_state().guard:
@@ -65,19 +63,15 @@ async def operation():
return wrapper
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class OwnerState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
loop: object = None
- guard: object = state_field(default_factory=lambda: threading.Lock())
- tasks: object = state_field(default_factory=lambda: set())
+ guard: object = field(default_factory=lambda: threading.Lock())
+ tasks: object = field(default_factory=lambda: set())
-@state_cache(maxsize=1)
-def process_state() -> OwnerState:
- return OwnerState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = OwnerState()
+
+
+def process_state() -> OwnerState:
+ return _STATE
diff --git a/src/backend/core/jobs/reminder_hook.py b/src/backend/core/jobs/reminder_hook.py
index d728379b..26375f33 100644
--- a/src/backend/core/jobs/reminder_hook.py
+++ b/src/backend/core/jobs/reminder_hook.py
@@ -1,4 +1,5 @@
"""Job-ledger reminder contributed by the execution host."""
+import asyncio
from typing import Optional
from core.harness.hooks import Decision, FailureMode, HookSpec, HookStage
@@ -9,19 +10,7 @@ def __init__(self, *, chat_id: Optional[str], user_id: Optional[str]) -> None:
self._user_id = user_id or ""
self._last_key: tuple | None = None
- def __call__(self, invocation):
- if invocation.operation_name != "agent_reasoning":
- return Decision()
- runtime = invocation.data["runtime"]
- if not self._chat_id:
- return
- key = (
- runtime.get("reply_id", "") or "",
- int(runtime.get("cur_iter", 0) or 0),
- )
- if key == self._last_key:
- return
-
+ def _ledger_lines(self) -> list[str]:
from core.db.engine import SessionLocal
from core.db.models import Job
from core.jobs.store import JobService
@@ -34,8 +23,6 @@ def __call__(self, invocation):
.limit(3)
.all()
)
- if not jobs:
- return
svc = JobService(db)
lines = []
for job in jobs:
@@ -49,6 +36,23 @@ def __call__(self, invocation):
f"查无 {stats.get('not_found', 0)},待办 {stats.get('pending', 0)},"
f"失败 {stats.get('failed', 0)}"
)
+ return lines
+
+ async def __call__(self, invocation):
+ if invocation.operation_name != "agent_reasoning":
+ return Decision()
+ runtime = invocation.data["runtime"]
+ if not self._chat_id:
+ return
+ key = (
+ runtime.get("reply_id", "") or "",
+ int(runtime.get("cur_iter", 0) or 0),
+ )
+ if key == self._last_key:
+ return
+ # HookBus runs sync handlers inline on the event loop; keep the ledger
+ # query off it.
+ lines = await asyncio.to_thread(self._ledger_lines)
if not lines:
return
diff --git a/src/backend/core/jobs/runtime.py b/src/backend/core/jobs/runtime.py
index 03310dbb..b0265fa8 100644
--- a/src/backend/core/jobs/runtime.py
+++ b/src/backend/core/jobs/runtime.py
@@ -3,18 +3,18 @@
import asyncio
import logging
+from dataclasses import dataclass, field
import core.jobs.owner as owner
import core.jobs.process as process
import core.jobs.state as state
import core.jobs.supervisor as supervisor
-from core.db.engine import SessionLocal
from core.jobs.store import JobService
+from core.db.threads import run_in_session
logger = logging.getLogger(__name__)
-
async def _launch(jid, user_id, *, resume=False):
attempt = state.begin_attempt(jid, user_id, resume=resume)
process_state().launches[jid] = asyncio.current_task()
@@ -54,7 +54,7 @@ async def start_job(
start_params=None,
interpreter="${PY_BIN:-python3}",
):
- with SessionLocal() as db:
+ def _create_job(db):
job = JobService(db).create(
user_id=user_id,
chat_id=chat_id,
@@ -66,6 +66,8 @@ async def start_job(
start_params={**(start_params or {}), "interpreter": interpreter},
)
jid = job.job_id
+ return jid
+ jid = await run_in_session(_create_job)
return await _launch(jid, user_id)
@@ -87,10 +89,11 @@ async def cancel_job(job_row_id, *, user_id):
if not attempt:
# Legacy nohup jobs have no attributable process handle. Never broad-kill
# another job or claim to have stopped a process we cannot identify.
- with SessionLocal() as db:
+ def _cancel_legacy_job(db):
JobService(db).finish(
job_row_id, "cancelled", error="旧作业已撤销回调权限,进程状态无法确认"
)
+ await run_in_session(_cancel_legacy_job)
raise RuntimeError("旧作业已撤销回调权限,但无持久进程引用,无法确认停止;请勿自动重跑")
state.finish(job_row_id, attempt, "cancelled", "用户取消", require_owner=False)
state.take_control(job_row_id, attempt)
@@ -170,17 +173,13 @@ async def run_job_reaper_loop():
await supervisor.shutdown()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class RuntimeState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- launches: object = state_field(default_factory=lambda: {})
+ launches: object = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> RuntimeState:
- return RuntimeState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = RuntimeState()
+
+
+def process_state() -> RuntimeState:
+ return _STATE
diff --git a/src/backend/core/jobs/supervisor.py b/src/backend/core/jobs/supervisor.py
index 7f8957b8..0913658e 100644
--- a/src/backend/core/jobs/supervisor.py
+++ b/src/backend/core/jobs/supervisor.py
@@ -15,8 +15,6 @@
PROGRESS_WAKE_SECONDS = 300.0
-
-
def _notify(coro):
async def deliver():
try:
@@ -200,18 +198,14 @@ async def shutdown():
await asyncio.gather(*notices, return_exceptions=True)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class SupervisorState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- entries: object = state_field(default_factory=lambda: {})
- notifications: object = state_field(default_factory=lambda: set())
+ entries: object = field(default_factory=lambda: {})
+ notifications: object = field(default_factory=lambda: set())
-@state_cache(maxsize=1)
-def process_state() -> SupervisorState:
- return SupervisorState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = SupervisorState()
+
+
+def process_state() -> SupervisorState:
+ return _STATE
diff --git a/src/backend/core/jobs/tool_operations.py b/src/backend/core/jobs/tool_operations.py
index 94359cb4..aa62206a 100644
--- a/src/backend/core/jobs/tool_operations.py
+++ b/src/backend/core/jobs/tool_operations.py
@@ -16,6 +16,7 @@
from core.sandbox.workspace.resolution import quote_shell_path as _quote_path
+from core.db.threads import run_db, run_in_session
logger = logging.getLogger(__name__)
@@ -59,6 +60,77 @@ def detached_payload(job_id: str, *, resumed: bool = False) -> Dict[str, Any]:
}
+
+def _resolve_job(db, job_id: str, user_id: str, chat_id: str):
+ """Return ``(job_id, None)`` for an owned job, else ``(None, error payload)``."""
+ from core.db.models import Job
+ from core.jobs.store import JobService
+
+ svc = JobService(db)
+ resolved = svc.get(job_id)
+ if (resolved is None or resolved.user_id != user_id) and not job_id.startswith("job_"):
+ cand = svc.get(f"job_{job_id}")
+ if cand is not None and cand.user_id == user_id:
+ resolved, job_id = cand, f"job_{job_id}"
+ if resolved is not None and resolved.user_id == user_id:
+ return job_id, None
+ rows = (
+ db.query(Job)
+ .filter(Job.user_id == user_id, Job.chat_id == chat_id)
+ .order_by(Job.created_at.desc())
+ .limit(5)
+ .all()
+ )
+ return None, {
+ "ok": False,
+ "error": f"job 不存在或无权访问: {job_id}",
+ "jobs_in_this_chat": [
+ {"job_id": r.job_id, "name": r.name, "status": r.status, "stats": svc.stats(r.job_id)}
+ for r in rows
+ ],
+ "hint": "用上面列表里的完整 job_id 重试(注意 job_ 前缀);"
+ "已经跑完的作业**不要重跑**,直接 export 取结果。",
+ }
+
+
+def _job_status(db, job_id: str, user_id: str) -> Dict[str, Any]:
+ from core.jobs.store import JobService
+
+ svc = JobService(db)
+ job = svc.get(job_id)
+ if job is None or job.user_id != user_id:
+ return {"ok": False, "error": "job 不存在或无权访问"}
+ return {
+ "ok": True,
+ "job_id": job_id,
+ "status": job.status,
+ "stats": svc.stats(job_id),
+ "usage": dict(job.usage or {}),
+ "budget_left": svc.budget_left(job_id),
+ "error": job.error_message,
+ }
+
+
+def _ledger_rows(db, job_id: str, user_id: str, status: str) -> Dict[str, Any]:
+ from core.jobs.store import JobService
+
+ svc = JobService(db)
+ job = svc.get(job_id)
+ if job is None or job.user_id != user_id:
+ return {"ok": False, "error": "job 不存在或无权访问"}
+ wanted = [s.strip() for s in (status or "").split(",") if s.strip()] or [
+ "done",
+ "not_found",
+ "failed",
+ "needs_review",
+ "pending",
+ ]
+ rows = []
+ for st in wanted:
+ rows.extend(svc.pending(job_id, status=st))
+ return {"rows": rows, "stats": svc.stats(job_id)}
+
+
def job_operations(
*,
user_id: str,
@@ -97,7 +169,6 @@ async def run_job(
) -> Dict[str, Any]:
import core.jobs.files as job_files
import core.jobs.runtime as job_runtime
- from core.db.engine import SessionLocal
from core.db.models import Job
from core.jobs.store import JobService
@@ -119,7 +190,7 @@ async def run_job(
conflict = (on_conflict or "block").strip().lower()
if chat_id and conflict != "parallel":
# 字段必须在 session 内取出:出了 with 块 ORM 实例就 detached,再读属性会炸
- with SessionLocal() as db:
+ def _live_job(db):
live = (
db.query(Job)
.filter(
@@ -131,6 +202,8 @@ async def run_job(
)
live_id = live.job_id if live is not None else ""
live_name = (live.name or "未命名") if live is not None else ""
+ return live_id, live_name
+ live_id, live_name = await run_in_session(_live_job)
if live_id and conflict == "replace":
await job_runtime.cancel_job(live_id, user_id=user_id)
logger.info("[run_job] replaced live job %s in chat %s", live_id, chat_id)
@@ -223,12 +296,14 @@ async def run_job(
jid, chat_id=chat_id, detach_after=FOREGROUND_WAIT_CAP_S
)
if res.get("status") == "detached":
- return detached_payload(jid)
- with SessionLocal() as db:
+ return await run_db(detached_payload, jid)
+ def _finished_job(db):
svc = JobService(db)
stats = svc.stats(jid)
job = svc.get(jid)
usage = dict(job.usage or {}) if job else {}
+ return stats, usage
+ stats, usage = await run_in_session(_finished_job)
payload = {
"ok": res.get("status") == "completed",
"job_id": jid,
@@ -262,77 +337,23 @@ async def run_job(
# job_id 容错 + 找不到时把本会话的作业列出来。
# 实测踩过:模型凭记忆抄 id 时漏了 "job_" 前缀,只回一句「job 不存在」它无从自救,
# 结果决定把 568 次子调用整个重跑一遍。错误信息必须自带下一步。
- with SessionLocal() as db:
- svc = JobService(db)
- resolved = svc.get(job_id)
- if (resolved is None or resolved.user_id != user_id) and not job_id.startswith("job_"):
- cand = svc.get(f"job_{job_id}")
- if cand is not None and cand.user_id == user_id:
- resolved, job_id = cand, f"job_{job_id}"
- if resolved is None or resolved.user_id != user_id:
- from core.db.models import Job
-
- rows = (
- db.query(Job)
- .filter(Job.user_id == user_id, Job.chat_id == chat_id)
- .order_by(Job.created_at.desc())
- .limit(5)
- .all()
- )
- return {
- "ok": False,
- "error": f"job 不存在或无权访问: {job_id}",
- "jobs_in_this_chat": [
- {
- "job_id": r.job_id,
- "name": r.name,
- "status": r.status,
- "stats": svc.stats(r.job_id),
- }
- for r in rows
- ],
- "hint": "用上面列表里的完整 job_id 重试(注意 job_ 前缀);"
- "已经跑完的作业**不要重跑**,直接 export 取结果。",
- }
+ resolved_id, missing = await run_in_session(_resolve_job, job_id, user_id, chat_id)
+ if missing is not None:
+ return missing
+ job_id = resolved_id
if act == "status":
- with SessionLocal() as db:
- svc = JobService(db)
- job = svc.get(job_id)
- if job is None or job.user_id != user_id:
- return {"ok": False, "error": "job 不存在或无权访问"}
- return {
- "ok": True,
- "job_id": job_id,
- "status": job.status,
- "stats": svc.stats(job_id),
- "usage": dict(job.usage or {}),
- "budget_left": svc.budget_left(job_id),
- "error": job.error_message,
- }
+ return await run_in_session(_job_status, job_id, user_id)
if act == "export":
# 台账在 DB,逐项结果**不进对话上下文**——所以导出的方式是写成沙箱里的
# JSONL 文件,之后用 Bash/python 处理(写 Excel、跑校验、喂评审)。
if not sandbox_session_id:
return {"ok": False, "error": "当前会话没有可用沙箱"}
- with SessionLocal() as db:
- svc = JobService(db)
- job = svc.get(job_id)
- if job is None or job.user_id != user_id:
- return {"ok": False, "error": "job 不存在或无权访问"}
- wanted = [s.strip() for s in (status or "").split(",") if s.strip()] or [
- "done",
- "not_found",
- "failed",
- "needs_review",
- "pending",
- ]
- rows = []
- for st in wanted:
- rows.extend(svc.pending(job_id, status=st))
- stats = svc.stats(job_id)
-
+ ledger = await run_in_session(_ledger_rows, job_id, user_id, status)
+ if "error" in ledger:
+ return ledger
+ rows, stats = ledger["rows"], ledger["stats"]
dest = dest_path or f"jobs/{job_id}_ledger.jsonl"
body = "\n".join(_json.dumps(r, ensure_ascii=False) for r in rows)
# 分块写 + 读回校验:单条 Bash 携带大 base64 到约 170KB 会**静默失败**
@@ -372,9 +393,11 @@ async def run_job(
job_id, chat_id=chat_id, detach_after=FOREGROUND_WAIT_CAP_S
)
if done.get("status") == "detached":
- return detached_payload(job_id, resumed=True)
- with SessionLocal() as db:
+ return await run_db(detached_payload, job_id, resumed=True)
+ def _job_stats(db):
stats = JobService(db).stats(job_id)
+ return stats
+ stats = await run_in_session(_job_stats)
return {
"ok": done.get("status") == "completed",
"job_id": job_id,
diff --git a/src/backend/core/llm/__init__.py b/src/backend/core/llm/__init__.py
index a82e3937..0546c2bc 100644
--- a/src/backend/core/llm/__init__.py
+++ b/src/backend/core/llm/__init__.py
@@ -1,17 +1,5 @@
"""LLM package.
Keep package import framework-neutral: pure context/manifest modules must be
-usable in tests and offline tooling without importing AgentScope. Legacy
-attribute exports remain available lazily.
+usable in tests and offline tooling without importing AgentScope.
"""
-
-
-def __getattr__(name): # noqa: ANN001, ANN202
- if name in {"make_chat_model", "get_default_model"}:
- from core.models import chat_models
-
- return getattr(chat_models, name)
- raise AttributeError(name)
-
-
-__all__ = ["get_default_model", "make_chat_model"]
diff --git a/src/backend/core/llm/api_runtime/__init__.py b/src/backend/core/llm/api_runtime/__init__.py
index 9499f51e..14f23927 100644
--- a/src/backend/core/llm/api_runtime/__init__.py
+++ b/src/backend/core/llm/api_runtime/__init__.py
@@ -1 +1 @@
-"""api runtime domain package."""
+"""Agent runtime for authenticated API-key requests."""
diff --git a/src/backend/core/llm/api_runtime/agent_tools.py b/src/backend/core/llm/api_runtime/agent_tools.py
index 7fe010f6..7149bf29 100644
--- a/src/backend/core/llm/api_runtime/agent_tools.py
+++ b/src/backend/core/llm/api_runtime/agent_tools.py
@@ -1,5 +1,6 @@
"""Native tools for an API agent: private account data is never mounted."""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import base64
@@ -77,11 +78,11 @@ async def read_artifact(
"""读取当前 API 会话中生成的文件,不能读取账号其他文件。"""
if not await asyncio.to_thread(artifact_in_scope, file_id, scope):
return _resp_json({"error": "文件不属于当前 API 会话或已删除"})
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools.read_artifact_tool import register_read_artifact
- collector = ToolCollector()
+ collector = ToolAssembly()
register_read_artifact(collector, user_id=scope.owner_user_id)
- return await collector.get_tool("read_artifact")._func(
+ return await next(t for t in collector if t.name == "read_artifact")._func(
file_id=file_id, offset=offset, limit=limit,
sheet_name=sheet_name, slide_index=slide_index,
)
@@ -192,7 +193,7 @@ async def sandbox_get_artifact(src_path: str, name: str = ""):
target.unlink(missing_ok=True)
for tool in (read_artifact, sandbox_get_artifact, pin_to_workspace):
- toolkit.register_tool_function(tool, namesake_strategy="override")
+ toolkit.append(function_tool(tool))
if not read_only:
for tool in (Bash, write_stdin, sandbox_put_artifact):
- toolkit.register_tool_function(tool, namesake_strategy="override")
+ toolkit.append(function_tool(tool))
diff --git a/src/backend/core/llm/citations/__init__.py b/src/backend/core/llm/citations/__init__.py
index f0feb15e..242360b5 100644
--- a/src/backend/core/llm/citations/__init__.py
+++ b/src/backend/core/llm/citations/__init__.py
@@ -1 +1 @@
-"""citations domain package."""
+"""Number tool-result sources as citation anchors the model can cite."""
diff --git a/src/backend/core/llm/citations/allocator.py b/src/backend/core/llm/citations/allocator.py
index 63b5dce9..b481a98d 100644
--- a/src/backend/core/llm/citations/allocator.py
+++ b/src/backend/core/llm/citations/allocator.py
@@ -1,11 +1,12 @@
-"""allocator responsibilities."""
+"""Per-chat citation anchor allocator and registry."""
from __future__ import annotations
from contextvars import ContextVar
from typing import Any, Dict, List, Optional, Sequence
import logging
+from core.llm.citations import anchor_state
+from core.llm.citations import types
+
logger = logging.getLogger(__name__)
-from core.llm.citations import anchor_state as anchor_state_module
-from core.llm.citations import types as types_module
class AnchorAllocator:
"""会话内单调锚点计数器 + 按 tool_id 的引用注册表。
@@ -16,22 +17,22 @@ class AnchorAllocator:
def __init__(self, start: int = 1) -> None:
self._next = max(1, int(start or 1))
- self._by_tool_id: Dict[str, List[types_module.AnchorCitation]] = {}
- self.all: List[types_module.AnchorCitation] = []
+ self._by_tool_id: Dict[str, List[types.AnchorCitation]] = {}
+ self.all: List[types.AnchorCitation] = []
def next_id(self) -> str:
aid = f"e{self._next}"
self._next += 1
return aid
- def register(self, tool_id: Optional[str], items: Sequence[types_module.AnchorCitation]) -> None:
+ def register(self, tool_id: Optional[str], items: Sequence[types.AnchorCitation]) -> None:
if not items:
return
self.all.extend(items)
if tool_id:
self._by_tool_id.setdefault(str(tool_id), []).extend(items)
- def citations_for(self, tool_id: Optional[str]) -> List[types_module.AnchorCitation]:
+ def citations_for(self, tool_id: Optional[str]) -> List[types.AnchorCitation]:
if not tool_id:
return []
return list(self._by_tool_id.get(str(tool_id), []))
@@ -97,7 +98,7 @@ def anchor_start_for_chat(chat_id: Optional[str]) -> int:
)
for (extra,) in rows:
for cit in (extra or {}).get("citations") or []:
- m = anchor_state_module.ANCHOR_RE.match(str(cit.get("id", "")) if isinstance(cit, dict) else "")
+ m = anchor_state.ANCHOR_RE.match(str(cit.get("id", "")) if isinstance(cit, dict) else "")
if m:
max_seen = max(max_seen, int(m.group(1)))
return max_seen + 1
diff --git a/src/backend/core/llm/citations/anchor.py b/src/backend/core/llm/citations/anchor.py
index c2c2eb62..2843cafd 100644
--- a/src/backend/core/llm/citations/anchor.py
+++ b/src/backend/core/llm/citations/anchor.py
@@ -1,26 +1,27 @@
-"""anchor responsibilities."""
+"""Annotate tool results with citation anchors."""
from __future__ import annotations
import json
from typing import List, Optional, Tuple
import logging
+from core.llm.citations.allocator import AnchorAllocator
+from core.llm.citations import anchor_state
+from core.llm.citations import extraction
+from core.llm.citations import types
+
logger = logging.getLogger(__name__)
-from core.llm.citations import allocator as allocator_module
-from core.llm.citations import anchor_state as anchor_state_module
-from core.llm.citations import extraction as extraction_module
-from core.llm.citations import types as types_module
def annotate_tool_result(
tool_name: str,
tool_id: Optional[str],
text: str,
- allocator: allocator_module.AnchorAllocator,
-) -> Tuple[str, List[types_module.AnchorCitation]]:
+ allocator: AnchorAllocator,
+) -> Tuple[str, List[types.AnchorCitation]]:
"""给一份工具结果文本注号。
返回 (注号后的文本, 登记的引用条目)。不可注号(跳过名单 / 空结果 /
解析失败且判定无引用价值)时原样返回 (text, [])。**绝不抛错**。
"""
- if not text or tool_name in anchor_state_module.SKIP_TOOLS:
+ if not text or tool_name in anchor_state.SKIP_TOOLS:
return text, []
try:
parsed = json.loads(text)
@@ -29,30 +30,30 @@ def annotate_tool_result(
try:
if isinstance(parsed, dict):
- items = extraction_module.extract_and_inject(tool_name, tool_id, parsed, allocator)
+ items = extraction.extract_and_inject(tool_name, tool_id, parsed, allocator)
if not items:
return text, []
return json.dumps(parsed, ensure_ascii=False), items
if isinstance(parsed, list):
# 裸数组结果:包壳后按启发式处理,回注仍输出裸数组语义(保持兼容 → 整份 1 条)
parsed = {"items": parsed}
- items = extraction_module.extract_and_inject(tool_name, tool_id, parsed, allocator)
+ items = extraction.extract_and_inject(tool_name, tool_id, parsed, allocator)
if not items:
return text, []
return json.dumps(parsed["items"], ensure_ascii=False), items
# 纯文本结果:整份 1 条,文末追加锚点行
- spec = anchor_state_module.TOOL_SPECS.get(tool_name)
+ spec = anchor_state.TOOL_SPECS.get(tool_name)
if spec and spec.mode == "list":
# 声明为列表型但拿到纯文本 → 形态异常,放行不注号
return text, []
aid = allocator.next_id()
- item = types_module.AnchorCitation(
+ item = types.AnchorCitation(
id=aid,
tool_name=tool_name,
tool_id=tool_id,
- title=extraction_module.display_title(tool_name, spec),
+ title=extraction.display_title(tool_name, spec),
url="",
- snippet=str(text)[:anchor_state_module.SNIPPET_MAX],
+ snippet=str(text)[:anchor_state.SNIPPET_MAX],
source_type=(spec.source_type if spec else "unknown"),
item_index=-1,
)
diff --git a/src/backend/core/llm/citations/anchor_state.py b/src/backend/core/llm/citations/anchor_state.py
index 92752c75..a5e99dd3 100644
--- a/src/backend/core/llm/citations/anchor_state.py
+++ b/src/backend/core/llm/citations/anchor_state.py
@@ -1,8 +1,8 @@
-"""anchor state responsibilities."""
+"""Anchor patterns, length limits and tool lists for citations."""
from __future__ import annotations
import re
from typing import Dict, Tuple
-from core.llm.citations import types as types_module
+from core.llm.citations import types
ANCHOR_RE = re.compile(r"^e(\d+)$")
@@ -75,27 +75,27 @@
)
-TOOL_SPECS: Dict[str, types_module.CitationSpec] = {
+TOOL_SPECS: Dict[str, types.CitationSpec] = {
# 检索/知识类(逐条)
- "internet_search": types_module.CitationSpec(
+ "internet_search": types.CitationSpec(
mode="list",
items_paths=(("result", "results"), ("results",)),
source_type="internet",
),
- "retrieve_dataset_content": types_module.CitationSpec(
+ "retrieve_dataset_content": types.CitationSpec(
mode="list", items_paths=(("items",),), source_type="knowledge_base"
),
- "retrieve_local_kb": types_module.CitationSpec(
+ "retrieve_local_kb": types.CitationSpec(
mode="list", items_paths=(("items",),), source_type="knowledge_base"
),
- "wiki_fetch_source": types_module.CitationSpec(
+ "wiki_fetch_source": types.CitationSpec(
mode="list", items_paths=(("items",),), source_type="knowledge_base"
),
- "wiki_locate": types_module.CitationSpec(
+ "wiki_locate": types.CitationSpec(
mode="list", items_paths=(("pages",),), source_type="knowledge_base"
),
- "wiki_read_page": types_module.CitationSpec(mode="whole", title="Wiki 页面", source_type="knowledge_base"),
- "web_fetch": types_module.CitationSpec(mode="whole", title="网页内容", source_type="internet"),
+ "wiki_read_page": types.CitationSpec(mode="whole", title="Wiki 页面", source_type="knowledge_base"),
+ "web_fetch": types.CitationSpec(mode="whole", title="网页内容", source_type="internet"),
# 数据/报告类(整份)
- "query_database": types_module.CitationSpec(mode="whole", title="数据库查询结果", source_type="database"),
+ "query_database": types.CitationSpec(mode="whole", title="数据库查询结果", source_type="database"),
}
diff --git a/src/backend/core/llm/citations/extraction.py b/src/backend/core/llm/citations/extraction.py
index ee17764e..f0abbc17 100644
--- a/src/backend/core/llm/citations/extraction.py
+++ b/src/backend/core/llm/citations/extraction.py
@@ -1,11 +1,11 @@
-"""extraction responsibilities."""
+"""Extract citation items from tool results and inject their ids."""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional, Sequence, Tuple
-from core.llm.citations import allocator as allocator_module
-from core.llm.citations import anchor_state as anchor_state_module
-from core.llm.citations import types as types_module
+from core.llm.citations.allocator import AnchorAllocator
+from core.llm.citations import anchor_state
+from core.llm.citations import types
def first_str(item: Dict[str, Any], keys: Sequence[str]) -> str:
for key in keys:
@@ -59,7 +59,7 @@ def dig(data: Dict[str, Any], path: Sequence[str]) -> Any:
return cur
-def display_title(tool_name: str, spec: Optional[types_module.CitationSpec]) -> str:
+def display_title(tool_name: str, spec: Optional[types.CitationSpec]) -> str:
if spec and spec.title:
return spec.title
return f"{tool_name} 结果"
@@ -69,23 +69,23 @@ def item_citation(
aid: str,
tool_name: str,
tool_id: Optional[str],
- spec: types_module.CitationSpec,
+ spec: types.CitationSpec,
item: Dict[str, Any],
index: int,
-) -> types_module.AnchorCitation:
- title = first_str(item, spec.title_keys)[:anchor_state_module.TITLE_MAX] or display_title(tool_name, spec)
+) -> types.AnchorCitation:
+ title = first_str(item, spec.title_keys)[:anchor_state.TITLE_MAX] or display_title(tool_name, spec)
if spec.snippet_join:
parts = [first_str(item, (k,)) for k in spec.snippet_join]
snippet = " · ".join(p for p in parts if p)
else:
snippet = first_str(item, spec.snippet_keys)
- return types_module.AnchorCitation(
+ return types.AnchorCitation(
id=aid,
tool_name=tool_name,
tool_id=tool_id,
title=title,
url=normalize_citation_url(first_str(item, spec.url_keys)),
- snippet=snippet[:anchor_state_module.SNIPPET_MAX],
+ snippet=snippet[:anchor_state.SNIPPET_MAX],
source_type=spec.source_type,
item_index=index,
)
@@ -95,7 +95,7 @@ def looks_like_error(data: Dict[str, Any]) -> bool:
if not data.get("error"):
return False
# 列表字段仍有内容时不算整体失败(部分工具 error+items 并存)
- for key in anchor_state_module.LIST_FIELD_ALIASES:
+ for key in anchor_state.LIST_FIELD_ALIASES:
val = data.get(key)
if isinstance(val, list) and val:
return False
@@ -109,7 +109,7 @@ def heuristic_list_field(data: Dict[str, Any]) -> Optional[Tuple[Tuple[str, ...]
if isinstance(inner, dict):
scopes.append((("result",), inner))
for prefix, scope in scopes:
- for alias in anchor_state_module.LIST_FIELD_ALIASES:
+ for alias in anchor_state.LIST_FIELD_ALIASES:
val = scope.get(alias)
if isinstance(val, list) and val and all(isinstance(x, dict) for x in val):
return prefix + (alias,), val
@@ -129,27 +129,27 @@ def extract_and_inject(
tool_name: str,
tool_id: Optional[str],
data: Dict[str, Any],
- allocator: allocator_module.AnchorAllocator,
-) -> List[types_module.AnchorCitation]:
+ allocator: AnchorAllocator,
+) -> List[types.AnchorCitation]:
"""在 data 上就地注入 cite_id,返回登记的引用条目。"""
- spec = anchor_state_module.TOOL_SPECS.get(tool_name)
+ spec = anchor_state.TOOL_SPECS.get(tool_name)
# L1:工具自声明 __citations__(优先级最高,条目顺序即 item_index)
declared = data.get("__citations__")
if isinstance(declared, list) and declared and all(isinstance(x, dict) for x in declared):
- base = spec or types_module.CitationSpec(source_type="unknown")
- out: List[types_module.AnchorCitation] = []
+ base = spec or types.CitationSpec(source_type="unknown")
+ out: List[types.AnchorCitation] = []
for idx, entry in enumerate(declared):
aid = allocator.next_id()
entry["cite_id"] = aid
out.append(
- types_module.AnchorCitation(
+ types.AnchorCitation(
id=aid,
tool_name=tool_name,
tool_id=tool_id,
- title=str(entry.get("title") or display_title(tool_name, spec))[:anchor_state_module.TITLE_MAX],
+ title=str(entry.get("title") or display_title(tool_name, spec))[:anchor_state.TITLE_MAX],
url=normalize_citation_url(str(entry.get("url") or "")),
- snippet=str(entry.get("snippet") or "")[:anchor_state_module.SNIPPET_MAX],
+ snippet=str(entry.get("snippet") or "")[:anchor_state.SNIPPET_MAX],
source_type=str(entry.get("source_type") or base.source_type),
item_index=idx,
)
@@ -179,7 +179,7 @@ def extract_and_inject(
found = heuristic_list_field(data)
if found is not None:
_path, items = found
- generic = types_module.CitationSpec(mode="list", source_type="unknown")
+ generic = types.CitationSpec(mode="list", source_type="unknown")
out = []
for item in items:
if item.get("error"):
@@ -193,14 +193,14 @@ def extract_and_inject(
# L4:整份 1 条(显式 whole spec 或彻底认不出)
aid = allocator.next_id()
data["cite_id"] = aid
- snippet = json.dumps(data, ensure_ascii=False)[:anchor_state_module.WHOLE_SNIPPET_MAX]
+ snippet = json.dumps(data, ensure_ascii=False)[:anchor_state.WHOLE_SNIPPET_MAX]
return [
- types_module.AnchorCitation(
+ types.AnchorCitation(
id=aid,
tool_name=tool_name,
tool_id=tool_id,
title=display_title(tool_name, spec),
- url=normalize_citation_url(first_str(data, (spec or types_module.CitationSpec()).url_keys)),
+ url=normalize_citation_url(first_str(data, (spec or types.CitationSpec()).url_keys)),
snippet=snippet,
source_type=(spec.source_type if spec else "unknown"),
item_index=-1,
diff --git a/src/backend/core/llm/citations/types.py b/src/backend/core/llm/citations/types.py
index 7cb78d06..b1809f0d 100644
--- a/src/backend/core/llm/citations/types.py
+++ b/src/backend/core/llm/citations/types.py
@@ -1,4 +1,4 @@
-"""types responsibilities."""
+"""Citation item and per-tool extraction spec types."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any, Dict, Optional, Tuple
@@ -20,8 +20,6 @@ def to_dict(self) -> Dict[str, Any]:
return asdict(self)
-
-
DEFAULT_TITLE_KEYS: Tuple[str, ...] = (
"title",
"标题",
diff --git a/src/backend/core/llm/context/__init__.py b/src/backend/core/llm/context/__init__.py
index 9f77ce93..454203f7 100644
--- a/src/backend/core/llm/context/__init__.py
+++ b/src/backend/core/llm/context/__init__.py
@@ -1 +1 @@
-"""context domain package."""
+"""Framework-neutral context IR: items, assembly and conversion to provider messages."""
diff --git a/src/backend/core/llm/context/adapter.py b/src/backend/core/llm/context/adapter.py
index a37f8084..a5da94ad 100644
--- a/src/backend/core/llm/context/adapter.py
+++ b/src/backend/core/llm/context/adapter.py
@@ -1,4 +1,4 @@
-"""adapter responsibilities."""
+"""Translate between the context IR and AgentScope messages."""
from __future__ import annotations
from core.llm.context import provider_conversion
from core.llm.context import reference_conversion
@@ -7,10 +7,32 @@
from typing import Any, Iterable, Mapping, Optional, Sequence
from agentscope.message import Msg, TextBlock
from core.infra.immutable import thaw_json
-from core.llm.context.ir_state import CONTEXT_SEQUENCE_STRIDE, KIND_COMPACTION, KIND_USER_INPUT, POLICY_DROP, POLICY_HEAD_TAIL, POLICY_NEVER, SESSION_CONTEXT_META_KEY, STEP_KINDS, VISIBILITY_MODEL
+from core.llm.context.ir_constants import CONTEXT_SEQUENCE_STRIDE, KIND_COMPACTION, KIND_USER_INPUT, POLICY_DROP, POLICY_HEAD_TAIL, POLICY_NEVER, SESSION_CONTEXT_META_KEY, STEP_KINDS, VISIBILITY_MODEL
from core.llm.context.items import ContextItem
-from core.llm.context import adapter_state as adapter_state_module
-from core.llm.context import blocks as blocks_module
+
+
+def _block_payload(block: Any) -> Any:
+ if isinstance(block, Mapping):
+ return dict(block)
+ if hasattr(block, "model_dump"):
+ return block.model_dump(mode="json")
+ return block
+
+
+def _block_type(block: Any) -> str:
+ if isinstance(block, Mapping):
+ return str(block.get("type") or "")
+ return str(getattr(block, "type", "") or "")
+
+
+def _block_text(block: Any) -> str:
+ if isinstance(block, Mapping):
+ return str(block.get("text") or "")
+ return str(getattr(block, "text", "") or "")
+
+
+CONTEXT_META_KEY = "harness_context_items"
+
class AgentScopeContextAdapter:
"""Translate the IR while preserving current model-facing roles/blocks."""
@@ -31,7 +53,7 @@ def _defaults(role: str, block_type: str) -> dict[str, Any]:
@staticmethod
def _metadata_entries(message: Msg) -> list[Mapping[str, Any]]:
metadata = getattr(message, "metadata", None) or {}
- raw = metadata.get(adapter_state_module.CONTEXT_META_KEY) if isinstance(metadata, Mapping) else None
+ raw = metadata.get(CONTEXT_META_KEY) if isinstance(metadata, Mapping) else None
if not isinstance(raw, list):
return []
return [entry for entry in raw if isinstance(entry, Mapping)]
@@ -62,8 +84,8 @@ def items_from_messages(
blocks = list(getattr(message, "content", None) or [])
entries = self._metadata_entries(message)
for block_index, block in enumerate(blocks):
- block_type = blocks_module.block_type(block)
- payload = blocks_module.block_text(block) if block_type == "text" else blocks_module.block_payload(block)
+ block_type = _block_type(block)
+ payload = _block_text(block) if block_type == "text" else _block_payload(block)
defaults = self._defaults(role, block_type)
explicit = entries[block_index] if block_index < len(entries) else {}
if (
@@ -207,7 +229,7 @@ def items_from_session_dict(
name=str(message.get("name") or role),
role=role if role in {"system", "assistant", "user"} else "user",
content=blocks,
- metadata={adapter_state_module.CONTEXT_META_KEY: metadata_entries} if metadata_entries else {},
+ metadata={CONTEXT_META_KEY: metadata_entries} if metadata_entries else {},
)
# A persisted row is converted in isolation, so it cannot know whether
# it is the current user instruction. Promotion happens once, later,
@@ -252,7 +274,7 @@ def message_from_session_dict(
name=str(message.get("name") or role),
role=role,
content=[],
- metadata={adapter_state_module.CONTEXT_META_KEY: []},
+ metadata={CONTEXT_META_KEY: []},
)
def messages_from_items(self, items: Iterable[ContextItem]) -> list[Msg]:
@@ -279,16 +301,16 @@ def flush() -> None:
for block_index, block in enumerate(content):
blocks.append(block)
normalized = (
- blocks_module.block_text(block)
- if blocks_module.block_type(block) == "text"
- else blocks_module.block_payload(block)
+ _block_text(block)
+ if _block_type(block) == "text"
+ else _block_payload(block)
)
derived = replace(
item,
item_id=f"{item.item_id}:block:{block_index}",
truncation_policy=(
POLICY_DROP
- if blocks_module.block_type(block) == "data"
+ if _block_type(block) == "data"
else item.truncation_policy
),
).with_content(normalized)
@@ -303,7 +325,7 @@ def flush() -> None:
name=name,
role=current_group[1],
content=blocks,
- metadata={adapter_state_module.CONTEXT_META_KEY: manifests},
+ metadata={CONTEXT_META_KEY: manifests},
)
)
current_group = None
diff --git a/src/backend/core/llm/context/adapter_state.py b/src/backend/core/llm/context/adapter_state.py
deleted file mode 100644
index 8923f8cd..00000000
--- a/src/backend/core/llm/context/adapter_state.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""adapter state responsibilities."""
-from __future__ import annotations
-
-
-CONTEXT_META_KEY = "harness_context_items"
diff --git a/src/backend/core/llm/context/blocks.py b/src/backend/core/llm/context/blocks.py
deleted file mode 100644
index dafb486a..00000000
--- a/src/backend/core/llm/context/blocks.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""blocks responsibilities."""
-from __future__ import annotations
-from typing import Any, Mapping
-
-def block_payload(block: Any) -> Any:
- if isinstance(block, Mapping):
- return dict(block)
- if hasattr(block, "model_dump"):
- return block.model_dump(mode="json")
- return block
-
-
-def block_type(block: Any) -> str:
- if isinstance(block, Mapping):
- return str(block.get("type") or "")
- return str(getattr(block, "type", "") or "")
-
-
-def block_text(block: Any) -> str:
- if isinstance(block, Mapping):
- return str(block.get("text") or "")
- return str(getattr(block, "text", "") or "")
diff --git a/src/backend/core/llm/context/compacting_agent.py b/src/backend/core/llm/context/compacting_agent.py
index 2b703934..679e2748 100644
--- a/src/backend/core/llm/context/compacting_agent.py
+++ b/src/backend/core/llm/context/compacting_agent.py
@@ -271,7 +271,7 @@ def _estimate_prompt_tokens(self) -> int:
# ── MidTurn compaction ───────────────────────────────────────────────────
- async def compress_context(self, context_config: ContextConfig | None = None) -> None:
+ async def compress_context(self, context_config: ContextConfig | None = None, instructions=None) -> None:
"""Compact at a ReAct step boundary without replacing failed work with a summary."""
del context_config
await enforce_context_budget(self)
diff --git a/src/backend/core/llm/context/compaction.py b/src/backend/core/llm/context/compaction.py
index e69df1f7..7fb835b2 100644
--- a/src/backend/core/llm/context/compaction.py
+++ b/src/backend/core/llm/context/compaction.py
@@ -6,7 +6,7 @@
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
-from core.llm.context.ir_state import KIND_COMPACTION, SESSION_CONTEXT_META_KEY
+from core.llm.context.ir_constants import KIND_COMPACTION, SESSION_CONTEXT_META_KEY
from core.llm.context.items import make_text_context_item
from core.llm.context.content import image_token_reserve
from core.llm.runtime.model_steps import explode_history_rows
diff --git a/src/backend/core/llm/context/content.py b/src/backend/core/llm/context/content.py
index ad93dded..e3ea04a3 100644
--- a/src/backend/core/llm/context/content.py
+++ b/src/backend/core/llm/context/content.py
@@ -1,10 +1,10 @@
-"""content responsibilities."""
+"""Normalise, measure and truncate context content."""
from __future__ import annotations
from core.models.constants import IMAGE_TOKEN_RESERVE
from typing import Any, Mapping
from core.infra.immutable import thaw_json
from core.harness.execution_manifest import canonical_json
-from core.llm.context import ir_state as ir_state_module
+from core.llm.context import ir_constants
def normalize_context_content(content: Any) -> Any:
"""Convert SDK block objects into stable, framework-neutral JSON values."""
@@ -134,11 +134,11 @@ def truncate_content(content: Any, max_tokens: int, policy: str) -> Any:
if max_tokens <= 0:
return ""
if isinstance(content, str):
- return truncate_text(content, max_tokens, tail_only=policy == ir_state_module.POLICY_TAIL)
+ return truncate_text(content, max_tokens, tail_only=policy == ir_constants.POLICY_TAIL)
if isinstance(content, Mapping):
mutable = thaw_json(content)
block_type = str(mutable.get("type") or "")
- if block_type not in ir_state_module.TRUNCATABLE_BLOCK_TYPES:
+ if block_type not in ir_constants.TRUNCATABLE_BLOCK_TYPES:
return mutable
for key in ("output", "content", "text"):
value = mutable.get(key)
@@ -147,7 +147,7 @@ def truncate_content(content: Any, max_tokens: int, policy: str) -> Any:
mutable[key] = truncate_text(
value,
max(1, max_tokens - overhead),
- tail_only=policy == ir_state_module.POLICY_TAIL,
+ tail_only=policy == ir_constants.POLICY_TAIL,
)
return mutable
if key == "output" and isinstance(value, (list, Mapping)):
@@ -165,7 +165,7 @@ def truncate_content(content: Any, max_tokens: int, policy: str) -> Any:
output = [{"type": "text", "text": ""}, *images]
overhead = estimate_context_tokens({**mutable, key: output})
output[0]["text"] = truncate_text(
- text, max(1, max_tokens - overhead), tail_only=policy == ir_state_module.POLICY_TAIL
+ text, max(1, max_tokens - overhead), tail_only=policy == ir_constants.POLICY_TAIL
)
mutable[key] = output
return mutable
@@ -185,7 +185,7 @@ def truncate_content(content: Any, max_tokens: int, policy: str) -> Any:
mutable[key] = truncate_text(
flattened,
max(1, max_tokens - overhead),
- tail_only=policy == ir_state_module.POLICY_TAIL,
+ tail_only=policy == ir_constants.POLICY_TAIL,
)
return mutable
return mutable
@@ -200,13 +200,13 @@ def prune_tool_output(content: Any, original_tokens: int) -> Any:
images = tool_result_images(mutable)
if images:
mutable["output"] = [
- {"type": "text", "text": ir_state_module.PRUNED_OUTPUT_TEMPLATE.format(tokens=original_tokens)},
+ {"type": "text", "text": ir_constants.PRUNED_OUTPUT_TEMPLATE.format(tokens=original_tokens)},
*images,
]
return mutable
for key in ("output", "content", "text"):
if key in mutable:
- mutable[key] = ir_state_module.PRUNED_OUTPUT_TEMPLATE.format(tokens=original_tokens)
+ mutable[key] = ir_constants.PRUNED_OUTPUT_TEMPLATE.format(tokens=original_tokens)
return mutable
- mutable["output"] = ir_state_module.PRUNED_OUTPUT_TEMPLATE.format(tokens=original_tokens)
+ mutable["output"] = ir_constants.PRUNED_OUTPUT_TEMPLATE.format(tokens=original_tokens)
return mutable
diff --git a/src/backend/core/llm/context/ir.py b/src/backend/core/llm/context/ir.py
index 96a3d32f..bb1f8a84 100644
--- a/src/backend/core/llm/context/ir.py
+++ b/src/backend/core/llm/context/ir.py
@@ -1,11 +1,11 @@
-"""ir responsibilities."""
+"""Assemble context items into the window sent to the model."""
from __future__ import annotations
from dataclasses import replace
from typing import Any, Iterable, Mapping, Optional, Sequence
from core.harness.execution_manifest import stable_hash
-from core.llm.context import content as content_module
-from core.llm.context import ir_state as ir_state_module
-from core.llm.context import items as items_module
+from core.llm.context import content
+from core.llm.context import ir_constants
+from core.llm.context.items import ContextAssembly, ContextItem
class ContextAssembler:
"""Explicit, unit-level selection in context order.
@@ -43,15 +43,15 @@ def __init__(
}
@staticmethod
- def _mandatory(item: items_module.ContextItem) -> bool:
- if item.visibility == ir_state_module.VISIBILITY_MANIFEST_ONLY:
+ def _mandatory(item: ContextItem) -> bool:
+ if item.visibility == ir_constants.VISIBILITY_MANIFEST_ONLY:
return True
- if item.kind in ir_state_module.STEP_KINDS:
+ if item.kind in ir_constants.STEP_KINDS:
return False
- return item.truncation_policy == ir_state_module.POLICY_NEVER
+ return item.truncation_policy == ir_constants.POLICY_NEVER
@staticmethod
- def _unit_key(items: Sequence[items_module.ContextItem]) -> tuple[Any, ...]:
+ def _unit_key(items: Sequence[ContextItem]) -> tuple[Any, ...]:
return (
-max(item.priority for item in items),
-max(item.created_seq for item in items),
@@ -59,23 +59,23 @@ def _unit_key(items: Sequence[items_module.ContextItem]) -> tuple[Any, ...]:
)
@staticmethod
- def _tokens(item: items_module.ContextItem) -> int:
- return 0 if item.visibility == ir_state_module.VISIBILITY_MANIFEST_ONLY else item.token_estimate
+ def _tokens(item: ContextItem) -> int:
+ return 0 if item.visibility == ir_constants.VISIBILITY_MANIFEST_ONLY else item.token_estimate
def _cap_item(
self,
- item: items_module.ContextItem,
+ item: ContextItem,
records: dict[str, dict[str, Any]],
- ) -> Optional[items_module.ContextItem]:
+ ) -> Optional[ContextItem]:
original = item.token_estimate
# Media still contributes to whole-context occupancy, but cannot spend
# or be cut by the per-item text allowance.
budget = item.token_budget + sum(
- content_module.estimate_context_tokens(block) for block in content_module.tool_result_images(item.content)
+ content.estimate_context_tokens(block) for block in content.tool_result_images(item.content)
)
if (
- item.visibility == ir_state_module.VISIBILITY_MANIFEST_ONLY
- or item.truncation_policy == ir_state_module.POLICY_NEVER
+ item.visibility == ir_constants.VISIBILITY_MANIFEST_ONLY
+ or item.truncation_policy == ir_constants.POLICY_NEVER
or original <= budget
):
records[item.item_id] = {
@@ -84,10 +84,10 @@ def _cap_item(
"final_tokens": original,
}
return item
- if item.truncation_policy in {ir_state_module.POLICY_HEAD_TAIL, ir_state_module.POLICY_TAIL} and item.token_budget > 0:
+ if item.truncation_policy in {ir_constants.POLICY_HEAD_TAIL, ir_constants.POLICY_TAIL} and item.token_budget > 0:
target = budget
capped = item.with_content(
- content_module.truncate_content(item.content, target, item.truncation_policy)
+ content.truncate_content(item.content, target, item.truncation_policy)
)
# JSON escaping of newlines/quotes can exceed the initial text
# allowance. Refit using the measured cost, never discard an image
@@ -95,7 +95,7 @@ def _cap_item(
while capped.token_estimate > budget and target > 1:
target = max(1, target - (capped.token_estimate - budget))
capped = item.with_content(
- content_module.truncate_content(item.content, target, item.truncation_policy)
+ content.truncate_content(item.content, target, item.truncation_policy)
)
if capped.token_estimate <= budget:
records[item.item_id] = {
@@ -113,21 +113,21 @@ def _cap_item(
return None
@staticmethod
- def _tool_pairs_balanced(unit: Sequence[items_module.ContextItem]) -> bool:
- pairs: dict[str, list[items_module.ContextItem]] = {}
+ def _tool_pairs_balanced(unit: Sequence[ContextItem]) -> bool:
+ pairs: dict[str, list[ContextItem]] = {}
for item in unit:
- if item.kind in ir_state_module.TOOL_KINDS:
+ if item.kind in ir_constants.TOOL_KINDS:
if not item.pair_id:
return False
pairs.setdefault(item.pair_id, []).append(item)
for pair_items in pairs.values():
- calls = [item for item in pair_items if item.kind == ir_state_module.KIND_TOOL_CALL]
- results = [item for item in pair_items if item.kind == ir_state_module.KIND_TOOL_RESULT]
+ calls = [item for item in pair_items if item.kind == ir_constants.KIND_TOOL_CALL]
+ results = [item for item in pair_items if item.kind == ir_constants.KIND_TOOL_RESULT]
if not calls or len(calls) != len(results):
return False
return True
- def assemble(self, items: Iterable[items_module.ContextItem]) -> items_module.ContextAssembly:
+ def assemble(self, items: Iterable[ContextItem]) -> ContextAssembly:
raw_items = tuple(items)
item_ids = [item.item_id for item in raw_items]
duplicates = sorted(
@@ -138,9 +138,9 @@ def assemble(self, items: Iterable[items_module.ContextItem]) -> items_module.Co
original_items = list(raw_items)
arrival = {item.item_id: index for index, item in enumerate(original_items)}
records: dict[str, dict[str, Any]] = {}
- excluded: list[items_module.ContextItem] = []
+ excluded: list[ContextItem] = []
- capped: list[items_module.ContextItem] = []
+ capped: list[ContextItem] = []
for item in original_items:
candidate = self._cap_item(item, records)
if candidate is None:
@@ -153,21 +153,21 @@ def assemble(self, items: Iterable[items_module.ContextItem]) -> items_module.Co
call_units: dict[str, str] = {}
ambiguous_pairs: set[str] = set()
for item in capped:
- if item.kind == ir_state_module.KIND_TOOL_CALL and item.pair_id:
+ if item.kind == ir_constants.KIND_TOOL_CALL and item.pair_id:
# One pair id may name a provider parallel batch (several calls
# in one unit); the same id across two units is unresolvable.
if call_units.get(item.pair_id, item.unit_id) != item.unit_id:
ambiguous_pairs.add(item.pair_id)
call_units[item.pair_id] = item.unit_id
- units: dict[str, list[items_module.ContextItem]] = {}
+ units: dict[str, list[ContextItem]] = {}
for candidate in capped:
- if candidate.kind in ir_state_module.TOOL_KINDS and candidate.pair_id in ambiguous_pairs:
+ if candidate.kind in ir_constants.TOOL_KINDS and candidate.pair_id in ambiguous_pairs:
records[candidate.item_id].update(
action="excluded", reason="malformed_tool_pair", final_tokens=0
)
excluded.append(candidate)
continue
- if candidate.kind == ir_state_module.KIND_TOOL_RESULT and candidate.pair_id in call_units:
+ if candidate.kind == ir_constants.KIND_TOOL_RESULT and candidate.pair_id in call_units:
candidate = replace(candidate, unit_id=call_units[candidate.pair_id])
units.setdefault(candidate.unit_id, []).append(candidate)
@@ -176,21 +176,21 @@ def assemble(self, items: Iterable[items_module.ContextItem]) -> items_module.Co
request_index = -1
for item in original_items:
if (
- item.kind == ir_state_module.KIND_USER_INPUT
- and item.truncation_policy == ir_state_module.POLICY_NEVER
- and item.visibility == ir_state_module.VISIBILITY_MODEL
+ item.kind == ir_constants.KIND_USER_INPUT
+ and item.truncation_policy == ir_constants.POLICY_NEVER
+ and item.visibility == ir_constants.VISIBILITY_MODEL
):
request_index = arrival[item.item_id]
- def unit_start(unit: Sequence[items_module.ContextItem]) -> int:
+ def unit_start(unit: Sequence[ContextItem]) -> int:
return min(arrival[item.item_id] for item in unit)
- mandatory: list[list[items_module.ContextItem]] = []
- protected: list[list[items_module.ContextItem]] = []
- aside: list[list[items_module.ContextItem]] = []
- conversation: list[list[items_module.ContextItem]] = []
+ mandatory: list[list[ContextItem]] = []
+ protected: list[list[ContextItem]] = []
+ aside: list[list[ContextItem]] = []
+ conversation: list[list[ContextItem]] = []
for unit in units.values():
- is_step = any(item.kind in ir_state_module.STEP_KINDS for item in unit)
+ is_step = any(item.kind in ir_constants.STEP_KINDS for item in unit)
if any(self._mandatory(item) for item in unit):
mandatory.append(unit)
elif is_step and request_index >= 0 and unit_start(unit) > request_index:
@@ -201,7 +201,7 @@ def unit_start(unit: Sequence[items_module.ContextItem]) -> int:
action="excluded", reason="malformed_tool_pair", final_tokens=0
)
excluded.append(item)
- elif all(item.kind in ir_state_module.CONVERSATION_KINDS for item in unit):
+ elif all(item.kind in ir_constants.CONVERSATION_KINDS for item in unit):
conversation.append(unit)
else:
aside.append(unit)
@@ -213,10 +213,10 @@ def unit_start(unit: Sequence[items_module.ContextItem]) -> int:
# the output, which is re-sorted into context order.
aside.sort(key=self._unit_key)
- def unit_tokens(unit: Sequence[items_module.ContextItem]) -> int:
+ def unit_tokens(unit: Sequence[ContextItem]) -> int:
return sum(self._tokens(item) for item in unit)
- included: list[items_module.ContextItem] = []
+ included: list[ContextItem] = []
used = 0
for unit in mandatory + protected:
included.extend(unit)
@@ -234,10 +234,10 @@ def unit_tokens(unit: Sequence[items_module.ContextItem]) -> int:
if (
len(unit) == 1
and remaining > 0
- and item.truncation_policy in {ir_state_module.POLICY_HEAD_TAIL, ir_state_module.POLICY_TAIL}
+ and item.truncation_policy in {ir_constants.POLICY_HEAD_TAIL, ir_constants.POLICY_TAIL}
):
shrunk = item.with_content(
- content_module.truncate_content(item.content, remaining, item.truncation_policy)
+ content.truncate_content(item.content, remaining, item.truncation_policy)
)
if self._tokens(shrunk) < self._tokens(item) and self._tokens(shrunk) <= remaining:
records[item.item_id].update(
@@ -261,9 +261,9 @@ def unit_tokens(unit: Sequence[items_module.ContextItem]) -> int:
for index, item in enumerate(unit):
if used + conversation_tokens <= self.total_budget:
break
- if item.kind != ir_state_module.KIND_TOOL_RESULT or item.truncation_policy == ir_state_module.POLICY_NEVER:
+ if item.kind != ir_constants.KIND_TOOL_RESULT or item.truncation_policy == ir_constants.POLICY_NEVER:
continue
- pruned = item.with_content(content_module.prune_tool_output(item.content, item.token_estimate))
+ pruned = item.with_content(content.prune_tool_output(item.content, item.token_estimate))
if self._tokens(pruned) >= self._tokens(item):
continue
conversation_tokens -= self._tokens(item) - self._tokens(pruned)
@@ -301,7 +301,7 @@ def unit_tokens(unit: Sequence[items_module.ContextItem]) -> int:
excluded_manifest.append(entry)
payload = {
- "schema_version": ir_state_module.CONTEXT_SCHEMA_VERSION,
+ "schema_version": ir_constants.CONTEXT_SCHEMA_VERSION,
"total_budget": self.total_budget,
"used_tokens": used,
"over_budget": over_budget,
@@ -314,7 +314,7 @@ def unit_tokens(unit: Sequence[items_module.ContextItem]) -> int:
if self.budget_details:
payload["budget_details"] = dict(self.budget_details)
manifest_hash = stable_hash(payload)
- return items_module.ContextAssembly(
+ return ContextAssembly(
included=tuple(included),
excluded=tuple(excluded),
_manifest=payload,
diff --git a/src/backend/core/llm/context/ir_state.py b/src/backend/core/llm/context/ir_constants.py
similarity index 95%
rename from src/backend/core/llm/context/ir_state.py
rename to src/backend/core/llm/context/ir_constants.py
index 84b58c18..931faf2b 100644
--- a/src/backend/core/llm/context/ir_state.py
+++ b/src/backend/core/llm/context/ir_constants.py
@@ -1,4 +1,4 @@
-"""ir state responsibilities."""
+"""Context item kinds, policies and metadata keys."""
from __future__ import annotations
diff --git a/src/backend/core/llm/context/items.py b/src/backend/core/llm/context/items.py
index 0fd0fd3b..22618c10 100644
--- a/src/backend/core/llm/context/items.py
+++ b/src/backend/core/llm/context/items.py
@@ -1,11 +1,11 @@
-"""items responsibilities."""
+"""Context items and their provenance metadata."""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Any, Mapping, Optional
from core.infra.immutable import FrozenDict, freeze_json, thaw_json
from core.harness.execution_manifest import stable_hash
-from core.llm.context import content as content_module
-from core.llm.context import ir_state as ir_state_module
+from core.llm.context.content import estimate_context_tokens, normalize_context_content
+from core.llm.context import ir_constants
@dataclass(frozen=True)
class ContextItem:
@@ -61,12 +61,12 @@ def create(
render_name: str = "",
metadata: Optional[Mapping[str, Any]] = None,
) -> "ContextItem":
- normalized_content = content_module.normalize_context_content(content)
+ normalized_content = normalize_context_content(content)
if (
isinstance(normalized_content, Mapping)
and str(normalized_content.get("type") or "") == "data"
):
- truncation_policy = ir_state_module.POLICY_DROP
+ truncation_policy = ir_constants.POLICY_DROP
computed_hash = stable_hash(normalized_content)
supplied_hash = str(content_hash or "")
external_reference = normalized_content is None and bool(supplied_hash)
@@ -74,7 +74,7 @@ def create(
estimate = (
max(0, int(token_estimate or 0))
if external_reference
- else content_module.estimate_context_tokens(normalized_content)
+ else estimate_context_tokens(normalized_content)
)
reference = (
str(content_ref)
@@ -106,14 +106,14 @@ def create(
)
def with_content(self, content: Any) -> "ContextItem":
- normalized_content = content_module.normalize_context_content(content)
+ normalized_content = normalize_context_content(content)
digest = stable_hash(normalized_content)
return replace(
self,
content=freeze_json(normalized_content),
content_hash=digest,
content_ref=f"sha256:{digest}",
- token_estimate=content_module.estimate_context_tokens(normalized_content),
+ token_estimate=estimate_context_tokens(normalized_content),
)
def to_manifest(self) -> dict[str, Any]:
@@ -149,7 +149,7 @@ def make_text_context_item(
created_seq: int,
priority: int = 700,
token_budget: int = 4_000,
- truncation_policy: str = ir_state_module.POLICY_HEAD_TAIL,
+ truncation_policy: str = ir_constants.POLICY_HEAD_TAIL,
render_role: str = "user",
cache_class: str = "dynamic",
) -> ContextItem:
@@ -159,7 +159,7 @@ def make_text_context_item(
kind=kind,
origin=origin,
trust=trust,
- visibility=ir_state_module.VISIBILITY_MODEL,
+ visibility=ir_constants.VISIBILITY_MODEL,
priority=priority,
token_budget=token_budget,
truncation_policy=truncation_policy,
diff --git a/src/backend/core/llm/context/message_defaults.py b/src/backend/core/llm/context/message_defaults.py
index cfdc186f..ef5ce116 100644
--- a/src/backend/core/llm/context/message_defaults.py
+++ b/src/backend/core/llm/context/message_defaults.py
@@ -1,7 +1,7 @@
-"""message defaults operations."""
+"""Default kind and truncation policy for each message role and block type."""
from __future__ import annotations
from typing import Any
-from core.llm.context.ir_state import KIND_ASSISTANT, KIND_ATTACHMENT, KIND_SYSTEM_RULE, KIND_THINKING, KIND_TOOL_CALL, KIND_TOOL_RESULT, KIND_USER_INPUT, POLICY_DROP, POLICY_HEAD_TAIL, POLICY_NEVER
+from core.llm.context.ir_constants import KIND_ASSISTANT, KIND_ATTACHMENT, KIND_SYSTEM_RULE, KIND_THINKING, KIND_TOOL_CALL, KIND_TOOL_RESULT, KIND_USER_INPUT, POLICY_DROP, POLICY_HEAD_TAIL, POLICY_NEVER
def defaults(role: str, block_type: str) -> dict[str, Any]:
if block_type == "thinking":
diff --git a/src/backend/core/llm/context/protocol.py b/src/backend/core/llm/context/protocol.py
deleted file mode 100644
index bb85f0d4..00000000
--- a/src/backend/core/llm/context/protocol.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""protocol responsibilities."""
-from __future__ import annotations
-from typing import Any, Iterable, Mapping, Protocol, Sequence
-from core.llm.context.items import ContextItem
-
-class ContextAdapterProtocol(Protocol):
- """Framework-neutral seam for turning transport rows into canonical IR."""
-
- def items_from_messages(
- self,
- messages: Sequence[Any],
- *,
- summary_text: Any = None,
- promote_latest_user: bool = True,
- ) -> list["ContextItem"]: ...
-
- def messages_from_items(self, items: Iterable["ContextItem"]) -> list[Any]: ...
-
- def reference_items_from_execution_manifest(
- self, manifest: Any
- ) -> list["ContextItem"]: ...
-
- def items_from_provider_messages(
- self,
- messages: Sequence[Mapping[str, Any]],
- ) -> list["ContextItem"]: ...
-
- def provider_messages_from_items(
- self,
- items: Iterable["ContextItem"],
- ) -> list[Mapping[str, Any]]: ...
diff --git a/src/backend/core/llm/context/provider_conversion.py b/src/backend/core/llm/context/provider_conversion.py
index 2e3d0185..901d19a6 100644
--- a/src/backend/core/llm/context/provider_conversion.py
+++ b/src/backend/core/llm/context/provider_conversion.py
@@ -1,11 +1,11 @@
-"""provider conversion operations."""
+"""Carry context provenance through provider-formatted messages."""
from __future__ import annotations
from core.models.constants import PROVIDER_CONTEXT_META_KEY
from typing import Any, Iterable, Mapping, Sequence
from agentscope.message import Msg
from core.infra.immutable import thaw_json
from core.harness.execution_manifest import stable_hash
-from core.llm.context.ir_state import CONTEXT_SEQUENCE_STRIDE, KIND_ASSISTANT, KIND_ATTACHMENT, KIND_SYSTEM_RULE, KIND_TOOL_CALL, KIND_TOOL_RESULT, KIND_USER_INPUT, POLICY_NEVER, VISIBILITY_MODEL
+from core.llm.context.ir_constants import CONTEXT_SEQUENCE_STRIDE, KIND_ASSISTANT, KIND_ATTACHMENT, KIND_SYSTEM_RULE, KIND_TOOL_CALL, KIND_TOOL_RESULT, KIND_USER_INPUT, POLICY_NEVER, VISIBILITY_MODEL
from core.llm.context.items import ContextItem
from core.llm.context.content import estimate_context_tokens
diff --git a/src/backend/core/llm/context/reference_conversion.py b/src/backend/core/llm/context/reference_conversion.py
index d7a6ac2c..426832eb 100644
--- a/src/backend/core/llm/context/reference_conversion.py
+++ b/src/backend/core/llm/context/reference_conversion.py
@@ -1,7 +1,7 @@
-"""reference conversion operations."""
+"""Context items for the references listed in an execution manifest."""
from __future__ import annotations
from typing import Any, Mapping
-from core.llm.context.ir_state import KIND_PROJECT, KIND_SYSTEM_RULE, POLICY_HEAD_TAIL, POLICY_NEVER, VISIBILITY_MANIFEST_ONLY, VISIBILITY_MODEL
+from core.llm.context.ir_constants import KIND_PROJECT, KIND_SYSTEM_RULE, POLICY_HEAD_TAIL, POLICY_NEVER, VISIBILITY_MANIFEST_ONLY, VISIBILITY_MODEL
from core.llm.context.items import ContextItem
def reference_items_from_execution_manifest(self, manifest: Any) -> list[ContextItem]:
diff --git a/src/backend/core/llm/context/rendering.py b/src/backend/core/llm/context/rendering.py
index 852f7a31..9eff0361 100644
--- a/src/backend/core/llm/context/rendering.py
+++ b/src/backend/core/llm/context/rendering.py
@@ -1,16 +1,15 @@
-"""rendering responsibilities."""
+"""Render context items into AgentScope state and allocate their sequence numbers."""
from __future__ import annotations
-from core.llm.context import adapter as adapter_module_
+from core.llm.context import adapter
from dataclasses import replace
from typing import Any, Mapping, Sequence
from agentscope.message import Msg, TextBlock
-from core.llm.context.ir_state import CONTEXT_SEQUENCE_STRIDE, POLICY_HEAD_TAIL
+from core.llm.context.ir_constants import CONTEXT_SEQUENCE_STRIDE, POLICY_HEAD_TAIL
from core.llm.context.items import ContextItem, make_text_context_item
-from core.llm.context import adapter as adapter_module
def append_state_context_item(state: Any, item: ContextItem) -> Msg:
"""Render and append one explicitly-provenanced item to AgentScope state."""
- messages = adapter_module_.AgentScopeContextAdapter().messages_from_items([item])
+ messages = adapter.AgentScopeContextAdapter().messages_from_items([item])
if len(messages) != 1:
raise ValueError("context item did not render to exactly one message")
message = messages[0]
@@ -27,7 +26,7 @@ def next_context_sequence(messages: Sequence[Any]) -> int:
"""Allocate one monotonic sequence across replay and live injections."""
max_sequence = -CONTEXT_SEQUENCE_STRIDE
for message_index, message in enumerate(messages):
- entries = adapter_module_.AgentScopeContextAdapter._metadata_entries(message)
+ entries = adapter.AgentScopeContextAdapter._metadata_entries(message)
blocks = list(getattr(message, "content", None) or [])
for block_index, _block in enumerate(blocks or [None]):
explicit = entries[block_index] if block_index < len(entries) else {}
@@ -57,7 +56,7 @@ def next_request_sequence(messages: Sequence[Any]) -> int:
def render_context_item(item: ContextItem) -> Msg:
"""Render one IR item for ``Agent.reply`` without mutating agent state."""
- messages = adapter_module_.AgentScopeContextAdapter().messages_from_items([item])
+ messages = adapter.AgentScopeContextAdapter().messages_from_items([item])
if len(messages) != 1:
raise ValueError("context item did not render to exactly one message")
return messages[0]
@@ -65,8 +64,8 @@ def render_context_item(item: ContextItem) -> Msg:
def render_session_input(message: Mapping[str, Any], *, created_seq: int) -> Msg:
"""Render one pending positional row at an explicit live request sequence."""
- adapter = adapter_module_.AgentScopeContextAdapter()
- items = adapter.items_from_session_dict(message, created_seq=0)
+ context_adapter = adapter.AgentScopeContextAdapter()
+ items = context_adapter.items_from_session_dict(message, created_seq=0)
positioned = [
replace(
item,
@@ -75,7 +74,7 @@ def render_session_input(message: Mapping[str, Any], *, created_seq: int) -> Msg
)
for index, item in enumerate(items)
]
- rendered = adapter.messages_from_items(positioned)
+ rendered = context_adapter.messages_from_items(positioned)
if len(rendered) != 1:
raise ValueError("session input did not render to exactly one message")
return rendered[0]
diff --git a/src/backend/core/llm/context/system_reminder.py b/src/backend/core/llm/context/system_reminder.py
index 976cc80f..543dd68a 100644
--- a/src/backend/core/llm/context/system_reminder.py
+++ b/src/backend/core/llm/context/system_reminder.py
@@ -17,7 +17,7 @@
from typing import Any
from core.llm.context.rendering import append_context_text
-from core.llm.context.ir_state import KIND_REMINDER
+from core.llm.context.ir_constants import KIND_REMINDER
logger = logging.getLogger(__name__)
diff --git a/src/backend/core/llm/context/usage.py b/src/backend/core/llm/context/usage.py
index 6d596cb4..c32537c3 100644
--- a/src/backend/core/llm/context/usage.py
+++ b/src/backend/core/llm/context/usage.py
@@ -9,7 +9,7 @@
import math
import time
-from typing import Any, Dict, Mapping, Optional
+from typing import Any, Dict, Mapping
SCHEMA_VERSION = "context-usage.v2"
BREAKDOWN_KEYS = ("messages", "tools", "thinking", "files", "system", "input")
diff --git a/src/backend/core/llm/context/workspace_hooks.py b/src/backend/core/llm/context/workspace_hooks.py
index 0c513f21..dc1d604c 100644
--- a/src/backend/core/llm/context/workspace_hooks.py
+++ b/src/backend/core/llm/context/workspace_hooks.py
@@ -4,7 +4,7 @@
from core.harness.events import thaw_value
from core.harness.hooks import Decision, FailureMode, HookSpec, HookStage
from core.llm.context.reasoning_hooks import reminder
-from core.llm.runtime.hooks_state import FILE_ID_RE, PIN_HINT_SKIP_TOOLS
+from core.llm.runtime.hooks_constants import FILE_ID_RE, PIN_HINT_SKIP_TOOLS
from core.llm.runtime.message_compat import flatten_tool_output
from core.llm.tools.plan_update import parse_plan_update_args
diff --git a/src/backend/core/llm/factory/README.md b/src/backend/core/llm/factory/README.md
index 9bd6c136..e9839f53 100644
--- a/src/backend/core/llm/factory/README.md
+++ b/src/backend/core/llm/factory/README.md
@@ -1,7 +1,7 @@
# Agent factory
Public entry: `from core.llm.factory import create_agent_executor`.
-`warmup_mcp_tools` is the startup entry for shared MCP discovery.
+`mcp_connections` discovers native MCP capabilities within the authorized run.
The initializer loads these entries lazily so importing selection helpers does
not initialize the model/tool stack. No legacy `agent_factory` module remains.
diff --git a/src/backend/core/llm/factory/__init__.py b/src/backend/core/llm/factory/__init__.py
index 2e2b49c5..a5f0a66a 100644
--- a/src/backend/core/llm/factory/__init__.py
+++ b/src/backend/core/llm/factory/__init__.py
@@ -6,11 +6,7 @@ def __getattr__(name):
from .build import create_agent_executor
return create_agent_executor
- if name == "warmup_mcp_tools":
- from .tools.mcp_config import warmup_mcp_tools
-
- return warmup_mcp_tools
raise AttributeError(name)
-__all__ = ["create_agent_executor", "warmup_mcp_tools"]
+__all__ = ["create_agent_executor"]
diff --git a/src/backend/core/llm/factory/assembly.py b/src/backend/core/llm/factory/assembly.py
index 2f6e2d50..eb682a07 100644
--- a/src/backend/core/llm/factory/assembly.py
+++ b/src/backend/core/llm/factory/assembly.py
@@ -177,11 +177,9 @@ async def assemble_agent(request: AgentRequest):
_required_connector_tool_names,
_required_plugin_mcp_tool_names,
_tool_approval_available,
- http_clients,
+ mcp_resources,
loader,
- mcp_clients,
toolkit,
- transient_mcp_clients,
) = await mcp_connections(
request,
_api_scope=_api_scope,
@@ -261,9 +259,8 @@ async def assemble_agent(request: AgentRequest):
required=required,
_required_connector_tool_names=_required_connector_tool_names,
_required_plugin_mcp_tool_names=_required_plugin_mcp_tool_names,
- http_clients=http_clients,
+ mcp_resources=mcp_resources,
loader=loader,
- mcp_clients=mcp_clients,
skill_ids_to_register=skill_ids_to_register,
toolkit=toolkit,
)
@@ -400,10 +397,9 @@ async def assemble_agent(request: AgentRequest):
_manifest_for_surface=_manifest_for_surface,
_plugin_runtime=_plugin_runtime,
agent=agent,
- http_clients=http_clients,
+ mcp_resources=mcp_resources,
profile=profile,
skill_selection=skill_selection,
system_prompt=system_prompt,
toolkit=toolkit,
- transient_mcp_clients=transient_mcp_clients,
)
diff --git a/src/backend/core/llm/factory/build.py b/src/backend/core/llm/factory/build.py
index 8f1c1967..39c40de5 100644
--- a/src/backend/core/llm/factory/build.py
+++ b/src/backend/core/llm/factory/build.py
@@ -115,7 +115,7 @@ async def create_agent_executor(
required_plugin_name: Optional[str] = None,
required_plugin_skill_ids: Optional[List[str]] = None,
required_plugin_mcp_ids: Optional[List[str]] = None,
- # mode_spec: 对话模式的装配契约(core/services/chat_mode_service.ChatModeSpec)。
+ # mode_spec: 对话模式的装配契约(core.chat.mode_types.ChatModeSpec)。
# 「模式」把原来写死的极速模式泛化成一张表:工具面 / 技能 / 插件 / 提示词 kind /
# 迭代上限都由它给。turbo_mode 现在的含义是"这个模式要收窄工具面",收窄成什么
# 由 mode_spec 说了算。缺省(老调用方没传)时退回历史的 turbo.* 配置键读取,
diff --git a/src/backend/core/llm/factory/defaults.py b/src/backend/core/llm/factory/defaults.py
index 7be056eb..d6705a88 100644
--- a/src/backend/core/llm/factory/defaults.py
+++ b/src/backend/core/llm/factory/defaults.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from dataclasses import dataclass, field
from typing import Dict
from core.config.settings import settings as _settings
@@ -91,9 +92,6 @@
)
-
-_HTTP_MCP_FAIL_COOLDOWN_S = 60.0
-
KB_MCP_HTTP_URL = (
f"http://{_settings.server.mcp_host}:{_MCP_PORTS['retrieve_dataset_content']}/mcp/"
)
@@ -111,17 +109,13 @@
)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class DefaultsState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- HTTP_MCP_FAIL_AT: Dict[str, float] = state_field(default_factory=lambda: {})
+ HTTP_MCP_FAIL_AT: Dict[str, float] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> DefaultsState:
- return DefaultsState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = DefaultsState()
+
+
+def process_state() -> DefaultsState:
+ return _STATE
diff --git a/src/backend/core/llm/factory/prompts/policy.py b/src/backend/core/llm/factory/prompts/policy.py
index eabe1708..cfeff1b8 100644
--- a/src/backend/core/llm/factory/prompts/policy.py
+++ b/src/backend/core/llm/factory/prompts/policy.py
@@ -4,9 +4,10 @@
import logging
-logger = logging.getLogger(__name__)
from core.llm.factory.defaults import DYNAMIC_BLOCK_HEADER
+logger = logging.getLogger(__name__)
+
def _render_turn_budget_hint(max_iters: int) -> str:
"""Tell an agent that *does* have a turn budget how to spend it.
diff --git a/src/backend/core/llm/factory/prompts/system.py b/src/backend/core/llm/factory/prompts/system.py
index ac2af0d6..7aec1737 100644
--- a/src/backend/core/llm/factory/prompts/system.py
+++ b/src/backend/core/llm/factory/prompts/system.py
@@ -2,7 +2,7 @@
from __future__ import annotations
from core.llm.tools.plugin_activation import register_load_plugin
-from core.llm import prompt_rendering as prompt_rendering_module
+from core.llm import prompt_rendering
import os
from typing import Any, Dict
@@ -148,14 +148,13 @@ async def system_prompt(
# 能配自己的提示词。之前把它写在 turbo 分支里,非收窄模式配了也不生效。
_mode_prompt = ""
if request.mode_spec is not None:
- from core.llm import prompt_versions as _pvs_mode
_mode_prompt_text = getattr(request.mode_spec, "prompt_text", None)
_mode_prompt_kind = getattr(request.mode_spec, "prompt_kind", None)
if _mode_prompt_text:
_mode_prompt = str(_mode_prompt_text)
elif _mode_prompt_kind and _mode_prompt_kind != "turbo":
- _mode_prompt = prompt_rendering_module.render_kind_segment(_mode_prompt_kind, fs_fallback=False)
+ _mode_prompt = prompt_rendering.render_kind_segment(_mode_prompt_kind, fs_fallback=False)
if request.turbo_mode and not _turbo_code_exec:
# Turbo swaps in the standalone prompt (DB "turbo" active version →
@@ -164,10 +163,9 @@ async def system_prompt(
# (开了代码执行位的收窄模式不进这条:装配确实带沙箱/文件工具,
# turbo 正文"秒级检索、不执行代码"的叙事反而是错的——没配专属
# 提示词时走默认装配,工具段按实际 toolkit 动态生成。)
- from core.llm import prompt_versions as _pvs_turbo
# 收窄模式没配专属提示词时退回历史的 turbo 正文——极速模式绑的就是它。
- system_prompt = _mode_prompt or prompt_rendering_module.render_turbo_system_prompt()
+ system_prompt = _mode_prompt or prompt_rendering.render_turbo_system_prompt()
from core.config.local_mode import local_mode_enabled
if local_mode_enabled():
@@ -279,7 +277,7 @@ async def system_prompt(
try:
from core.llm import prompt_versions as _pvs
- _code_exec_text = prompt_rendering_module.render_kind_segment("code_exec")
+ _code_exec_text = prompt_rendering.render_kind_segment("code_exec")
except Exception:
_code_exec_text = ""
if _code_exec_text:
diff --git a/src/backend/core/llm/factory/runtime/agent_construction.py b/src/backend/core/llm/factory/runtime/agent_construction.py
index b6a2bd8a..8ca8118e 100644
--- a/src/backend/core/llm/factory/runtime/agent_construction.py
+++ b/src/backend/core/llm/factory/runtime/agent_construction.py
@@ -4,14 +4,15 @@
import logging
-logger = logging.getLogger(__name__)
import os
from agentscope.agent import ReActConfig
-from agentscope.agent._config import ModelConfig
+from agentscope.agent import ModelConfig, InjectionConfig
from core.llm.context.compacting_agent import CompactingAgent
from core.llm.factory.request import AgentRequest
+logger = logging.getLogger(__name__)
+
async def agent_construction(
request: AgentRequest,
@@ -93,7 +94,8 @@ async def agent_construction(
state=_state,
context_config=context_config,
model_config=ModelConfig(max_retries=3, fallback_model=None),
- react_config=ReActConfig(max_iters=_max_iters),
+ injection_config=InjectionConfig(inject_runtime_state=False),
+ react_config=ReActConfig(max_iters=_max_iters, interruption_raise_cancelled_error=True),
offloader=_offloader,
)
# Compaction trigger accounting reuses the exact already-frozen execution
diff --git a/src/backend/core/llm/factory/runtime/listeners.py b/src/backend/core/llm/factory/runtime/listeners.py
index 10df4dc9..5ace4861 100644
--- a/src/backend/core/llm/factory/runtime/listeners.py
+++ b/src/backend/core/llm/factory/runtime/listeners.py
@@ -15,12 +15,11 @@ async def runtime_listeners(
_manifest_for_surface,
_plugin_runtime,
agent,
- http_clients,
+ mcp_resources,
profile,
skill_selection,
system_prompt,
toolkit,
- transient_mcp_clients,
):
async def _publish_context_manifest(request_manifest): # noqa: ANN001, ANN202
"""Bind the exact post-budget context manifest used for this request."""
@@ -102,12 +101,15 @@ async def _publish_surface_generation(surface): # noqa: ANN001, ANN202
_log.info("[factory] +%s agent created, TOTAL setup done", _elapsed())
- # Only transient (per-request) stdio clients + HTTP clients get closed;
- # pooled stable clients stay open for reuse (closing them would defeat the
- # pool and hand dead clients to the next request).
- all_transient = [*transient_mcp_clients, *http_clients]
+ for resource in mcp_resources:
+ if resource.interactions is not None:
+ resource.interactions.agent = agent
+ # Release each run's leases; chat-owned STDIO sessions expire independently.
+ all_transient = list(mcp_resources)
# Clients connected by a mid-run load_plugin activation are appended to this
# same list object, so the caller's close_clients() teardown covers them.
if _plugin_runtime is not None:
_plugin_runtime["close_list"] = all_transient
+ _plugin_runtime["agent"] = agent
+ _plugin_runtime["request"] = request
return agent, all_transient
diff --git a/src/backend/core/llm/factory/runtime/middleware_stack.py b/src/backend/core/llm/factory/runtime/middleware_stack.py
index 8f40ce8b..493ff855 100644
--- a/src/backend/core/llm/factory/runtime/middleware_stack.py
+++ b/src/backend/core/llm/factory/runtime/middleware_stack.py
@@ -31,7 +31,7 @@ async def middleware_stack(
from core.sandbox.permissions.tool_specs import ToolPermissionRegistry
from core.llm.tool_permissions import ToolPermissionService
- _builtin_tool_names = {ft.name for ft in toolkit.function_tools}
+ _builtin_tool_names = {ft.name for ft in list(toolkit)}
_all_tool_names = {
str(s.get("function", {}).get("name") or "")
for s in tool_schemas
diff --git a/src/backend/core/llm/factory/selection/capabilities.py b/src/backend/core/llm/factory/selection/capabilities.py
index f8b8cfe7..f490a25d 100644
--- a/src/backend/core/llm/factory/selection/capabilities.py
+++ b/src/backend/core/llm/factory/selection/capabilities.py
@@ -1,13 +1,12 @@
"""Agent assembly: capabilities. """
from __future__ import annotations
-from core.capabilities.skills import loader_factory as loader_factory_module
+from core.capabilities.skills import loader_factory
from core.harness.registry import execution_account as skill_account
from core.harness.registry import run_capabilities as skills
from typing import Optional
-import core.capabilities.skills.loader as skill_loader
import core.capabilities.catalog.api as catalog
@@ -182,7 +181,7 @@ def available(names):
return []
try:
- loader = loader_factory_module.get_skill_loader()
+ loader = loader_factory.get_skill_loader()
discovered = sorted(loader.load_all_metadata().keys())
if discovered:
return available(discovered)
@@ -198,7 +197,7 @@ def _mcp_ids_bound_to_skills(skill_ids: list[str]) -> list[str]:
if not skill_ids:
return []
try:
- metadata = loader_factory_module.get_skill_loader().load_all_metadata()
+ metadata = loader_factory.get_skill_loader().load_all_metadata()
except Exception:
return []
result: list[str] = []
diff --git a/src/backend/core/llm/factory/selection/profile_selection.py b/src/backend/core/llm/factory/selection/profile_selection.py
index 32482c71..cb57c964 100644
--- a/src/backend/core/llm/factory/selection/profile_selection.py
+++ b/src/backend/core/llm/factory/selection/profile_selection.py
@@ -6,7 +6,6 @@
from core.llm.factory.models import RequiredCapabilities, StickyCapabilities
-logger = logging.getLogger(__name__)
import asyncio
import core.capabilities.catalog.api as catalog
@@ -16,6 +15,8 @@
from core.harness.execution_manifest import PromptManifestBuilder
from core.llm.factory.request import AgentRequest
+logger = logging.getLogger(__name__)
+
async def profile_selection(
request: AgentRequest,
diff --git a/src/backend/core/llm/factory/tools/definitions.py b/src/backend/core/llm/factory/tools/definitions.py
new file mode 100644
index 00000000..fddc89e0
--- /dev/null
+++ b/src/backend/core/llm/factory/tools/definitions.py
@@ -0,0 +1,35 @@
+"""Native tool values and their domain policy declarations during assembly."""
+from agentscope.tool import FunctionTool, ToolBase
+from agentscope.permission import PermissionBehavior, PermissionDecision
+from core.sandbox.permissions.tool_specs import builtin_tool_permission
+
+
+class ToolAssembly(list[ToolBase]):
+ def __init__(self):
+ super().__init__()
+ self.skill_loaders = []
+
+ @property
+ def permission_specs(self):
+ from extensions.mcp_servers.registry import SERVERS
+ specs = {}
+ for server in SERVERS.values():
+ specs.update(server.permission_specs())
+ for tool in self:
+ spec = getattr(tool, "dispatch_spec", None) or builtin_tool_permission(tool.name)
+ if spec is not None:
+ previous = specs.get(tool.name)
+ if previous is not None and previous.key != spec.key:
+ raise ValueError(f"Conflicting permission declaration for {tool.name!r}")
+ specs[tool.name] = spec
+ return specs
+
+
+def function_tool(func, *, description=None, dispatch_spec=None):
+ tool = FunctionTool(
+ func, description=description, is_concurrency_safe=False,
+ permission=PermissionDecision(behavior=PermissionBehavior.ALLOW,
+ message="Subject to the run's execution policy"),
+ )
+ tool.dispatch_spec = dispatch_spec
+ return tool
diff --git a/src/backend/core/llm/factory/tools/mcp_config.py b/src/backend/core/llm/factory/tools/mcp_config.py
index 7ead7199..e5b2956d 100644
--- a/src/backend/core/llm/factory/tools/mcp_config.py
+++ b/src/backend/core/llm/factory/tools/mcp_config.py
@@ -7,7 +7,6 @@
import core.capabilities.catalog.api as catalog
from core.capabilities.catalog.loader import DB_HIDDEN_SERVERS, DB_UMBRELLA_ID
from core.llm.factory.specs import AgentSpec
-from core.llm.mcp.pool import MCPConnectionPool
from core.capabilities.mcp.service import McpServerConfigService
@@ -176,41 +175,3 @@ def inject_runtime_headers(
c["env"] = env_cfg
out[key] = c
return out
-
-
-async def warmup_mcp_tools() -> None:
- """Initialize the MCP connection pool at startup.
-
- Reads MCP server configs from DB (via McpServerConfigService) and
- connects to all stable servers. Per-request servers (e.g.
- retrieve_dataset_content) are spawned on demand.
- """
- import logging
- import time
-
- log = logging.getLogger(__name__)
-
- # DB overlays (model config, system config) are already applied inside
- # McpServerConfigService._build_env(), so no manual overlay needed here.
- svc = McpServerConfigService.get_instance()
- servers = svc.get_all_servers(enabled_only=True)
-
- if not servers:
- log.info("[warmup] No MCP servers configured – skipping warmup")
- return
-
- log.info("[warmup] Initializing MCP connection pool for %d server(s)…", len(servers))
- start = time.monotonic()
-
- try:
- pool = MCPConnectionPool.get_instance()
- await pool.initialize(servers)
- elapsed = time.monotonic() - start
- log.info(
- "[warmup] MCP pool initialized: %d stable connections in %.2fs",
- pool.stable_client_count,
- elapsed,
- )
- except Exception as exc:
- elapsed = time.monotonic() - start
- log.warning("[warmup] MCP pool initialization failed after %.2fs: %s", elapsed, exc)
diff --git a/src/backend/core/llm/factory/tools/mcp_connections.py b/src/backend/core/llm/factory/tools/mcp_connections.py
index 907543c2..b09dc69c 100644
--- a/src/backend/core/llm/factory/tools/mcp_connections.py
+++ b/src/backend/core/llm/factory/tools/mcp_connections.py
@@ -1,21 +1,25 @@
"""Agent assembly phase: mcp connections. """
from __future__ import annotations
-from core.capabilities.skills import loader_factory as loader_factory_module
+from core.capabilities.skills import loader_factory
import asyncio
-import time
from typing import List
-import core.capabilities.skills.loader as skill_loader
-from agentscope.mcp import MCPClient
-from core.llm.factory.defaults import process_state as _state_core_llm_factory_defaults
-from core.llm.factory.defaults import _HTTP_MCP_FAIL_COOLDOWN_S, _SKILL_INSTRUCTION_TEMPLATE, KB_MCP_HTTP_URL
+from core.llm.factory.defaults import _SKILL_INSTRUCTION_TEMPLATE
from core.llm.factory.models import RequiredCapabilities
from core.llm.factory.request import AgentRequest
from core.llm.mcp.manager import own_factory_client
-from core.llm.mcp.pool import MCPConnectionPool
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly
+
+
+def _leaf_errors(exc):
+ """Task groups wrap the transport's real error; report the leaves, not the wrapper."""
+ if isinstance(exc, BaseExceptionGroup):
+ for inner in exc.exceptions:
+ yield from _leaf_errors(inner)
+ else:
+ yield exc
async def mcp_connections(
@@ -33,7 +37,7 @@ async def mcp_connections(
):
def _preload_skill_metadata():
"""Pre-warm skill metadata cache so registration is fast."""
- loader = loader_factory_module.get_skill_loader()
+ loader = loader_factory.get_skill_loader()
loader.load_all_metadata()
return loader
@@ -45,24 +49,8 @@ def _preload_skill_metadata():
loader = await asyncio.to_thread(_preload_skill_metadata)
_log.info("[factory] +%s skill metadata pre-loaded", _elapsed())
- # ── Phase 2: MCP toolkit (async, may spawn per-request subprocesses) ──
- mcp_clients: List[MCPClient] = []
- # Stable (pooled) clients must be reused and must never be closed at the end
- # of a request; only transient (per-request spawned) stdio clients go on the
- # close list. mcp_clients contains stable+transient (for Toolkit
- # construction); transient_mcp_clients is only for closing.
- transient_mcp_clients: List[MCPClient] = []
- http_clients: List[MCPClient] = []
- # AgentScope 2.0: the Toolkit is constructed once — there are no incremental
- # register_* calls. Use ToolCollector to duck-type-compatibly collect our
- # in-house tools/skills (the register_* functions barely change), then
- # construct the real Toolkit at the end.
- toolkit = ToolCollector()
- # Policies must exist before deferred plugins become visible mid-run.
- from extensions.mcp_servers.registry import SERVERS
- for server in SERVERS.values():
- for name, spec in server.permission_specs().items():
- toolkit.register_tool_permission(name, spec)
+ mcp_resources = []
+ toolkit = ToolAssembly()
# ⚠️ This is a Jinja2 template, rendered by toolkit.get_skill_instructions()
# with the ``skills`` variable. It MUST contain the
# ``{% for skill in skills %}`` loop to actually list the skills — otherwise
@@ -86,160 +74,41 @@ def _preload_skill_metadata():
)
if not request.disable_tools and enabled_servers:
- from core.llm.mcp.pool import HTTP_TRANSPORTS, make_client, uses_manifest_schema
-
- http_server_cfgs = {
- k: v for k, v in enabled_servers.items() if v.get("transport") in HTTP_TRANSPORTS
- }
- stdio_servers = {
- k: v for k, v in enabled_servers.items() if v.get("transport") not in HTTP_TRANSPORTS
- }
-
- # ``isolated`` callers run in their own event loop (subagent_tool
- # worker threads), so they MUST NOT touch the shared MCP pool — pool
- # clients are bound to the main loop's task scope and would crash
- # anyio on cross-loop teardown. Spawn fresh per-request stdio + HTTP
- # instead, and rely on close_clients() in the caller's loop.
- if request.isolated or _api_scope is not None:
- from core.llm.mcp.manager import connect_mcp_clients
-
- mcp_clients = await connect_mcp_clients(stdio_servers)
- transient_mcp_clients = mcp_clients # all freshly spawned → all closable
- per_request_http = http_server_cfgs
- else:
- pool = MCPConnectionPool.get_instance()
- pool_managed = pool.stable_server_ids if pool.is_initialized else frozenset()
- per_request_http = {k: v for k, v in http_server_cfgs.items() if k not in pool_managed}
- if pool.is_initialized:
- per_request_stdio = {
- k: v for k, v in stdio_servers.items() if k not in pool_managed
- }
- # 2.0: the pool returns a list of connected MCPClients
- # (stable+transient); the Toolkit(mcps=...) is constructed
- # uniformly below.
- mcp_clients, transient_mcp_clients = await pool.get_request_clients(
- enabled_keys=enabled_mcp_keys,
- per_request_servers_cfg=per_request_stdio,
- )
- else:
- from core.llm.mcp.manager import connect_mcp_clients
-
- mcp_clients = await connect_mcp_clients(stdio_servers)
- transient_mcp_clients = mcp_clients # pool off → all transient
+ from core.llm.mcp.lifetime import discover
+ from core.harness.registry.local_publication import ensure_native_servers
- # Per-request HTTP — pool can't carry per-request headers that some
- # servers (e.g. retrieve_dataset_content) require. BaseException is
- # caught because the mcp HTTP client's SSE task can propagate
- # CancelledError on transient failures.
- async def _connect_http(key: str, cfg: dict):
- start = time.monotonic()
- last_fail = _state_core_llm_factory_defaults().HTTP_MCP_FAIL_AT.get(key, 0.0)
- if last_fail and start - last_fail < _HTTP_MCP_FAIL_COOLDOWN_S:
- return None
- # ⚠️ 2.0 key point: HTTP MCP uses is_stateful=False (a new connection
- # per call), avoiding the stateful client's task-binding problem
- # (the connect task differs from the request task → cancel-scope
- # crash, so tool_result is never received). Stateless clients need
- # not and must not connect(); a single list_tools serves as the
- # liveness probe (lazy connect + enumerate, verifying reachability),
- # after which the Toolkit opens a fresh connection on every call.
- _http_cfg = {**cfg, "url": cfg.get("url", KB_MCP_HTTP_URL),
- "read_only": request.read_only}
- if _http_cfg.get("schema_source") == "cloud_manifest" and not uses_manifest_schema(
- _http_cfg
- ):
- _log.warning(
- "[factory] HTTP MCP '%s' ignored because its cloud manifest is invalid",
- key,
- )
- return None
- client = own_factory_client(make_client(key, _http_cfg, is_stateful=False))
- if uses_manifest_schema(_http_cfg):
- # The complete, authorized schema arrived in the dynamic cloud
- # manifest. Agent construction is network-free; only a model-
- # selected tool call reaches the JSON gateway.
- _state_core_llm_factory_defaults().HTTP_MCP_FAIL_AT.pop(key, None)
- _log.info(
- "[factory] HTTP MCP '%s' loaded from cloud manifest (%d tools)",
- key,
- len(_http_cfg.get("manifest_tools") or []),
- )
- return client
+ async def load(key, cfg):
+ cfg = {**cfg, "read_only": request.read_only}
try:
- from core.harness.registry.local_publication import ensure_native_servers
-
- await ensure_native_servers({key: _http_cfg})
- await client.list_tools()
- _state_core_llm_factory_defaults().HTTP_MCP_FAIL_AT.pop(key, None)
- _log.info(
- "[factory] HTTP MCP '%s' (stateless) probed in %.0fms",
- key,
- (time.monotonic() - start) * 1000,
- )
- return client
- except BaseException as exc:
- _state_core_llm_factory_defaults().HTTP_MCP_FAIL_AT[key] = time.monotonic()
- _log.warning(
- "[factory] HTTP MCP '%s' connect failed (%s, cooldown %.0fs): %s",
- key,
- type(exc).__name__,
- _HTTP_MCP_FAIL_COOLDOWN_S,
- exc,
- )
- # Only propagate CancelledError when the *outer* task is itself
- # being cancelled (real user/system cancel). anyio's SSE-client
- # cleanup raises CancelledError as a scope-exit signal even when
- # nobody cancelled us — re-raising those was killing the whole
- # chat run whenever any single HTTP MCP (e.g. a freshly-removed
- # word_mcp / ppt_mcp / excel_mcp / pdf_mcp whose admin_mcp_servers row was still
- # ``is_enabled=true``) was unreachable.
- if isinstance(exc, asyncio.CancelledError):
- current = asyncio.current_task()
- if current is not None and getattr(current, "cancelling", lambda: 0)() > 0:
- raise
+ if cfg.get("schema_source") not in {"cloud_manifest", "plugin_manifest"}:
+ await ensure_native_servers({key: cfg})
+ from core.llm.mcp.callbacks import Interactions
+ interactions = Interactions(request, key, _tool_approval_available)
+ scope = ((request.current_user_id, request.chat_id, request.capability_scope)
+ if request.current_user_id and request.chat_id and not request.isolated else None)
+ resource = await discover(key, cfg, interactions=interactions, scope=scope)
+ return own_factory_client(resource)
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ target = str(cfg.get("url") or cfg.get("command") or "").split("?", 1)[0]
+ _log.warning("MCP %s discovery failed (%s %s): %s", key, cfg.get("transport") or "stdio", target,
+ "; ".join(f"{type(e).__name__}: {e}"[:300] for e in _leaf_errors(exc)))
return None
- if per_request_http:
- results = await asyncio.gather(
- *(_connect_http(k, v) for k, v in per_request_http.items()),
- return_exceptions=False,
- )
- http_clients.extend(c for c in results if c is not None)
-
- _log.info(
- "[factory] +%s MCP tools loaded (transient_stdio=%d, http=%d)",
- _elapsed(),
- len(mcp_clients),
- len(http_clients),
- )
+ results = await asyncio.gather(*(load(k, c) for k, c in enabled_servers.items()))
+ mcp_resources = [resource for resource in results if resource is not None]
+ failed = [key for key, resource in zip(enabled_servers, results) if resource is None]
+ if failed:
+ _note_unavailable("以下 MCP 服务连接失败,本轮无法使用其工具:" + "、".join(failed))
- connected_clients = {
- str(getattr(client, "name", "") or ""): client for client in [*mcp_clients, *http_clients]
- }
+ connected_clients = {resource.name: resource for resource in mcp_resources}
async def _mcp_tool_names_for_servers(server_keys: List[str], *, log_prefix: str) -> List[str]:
- connected_keys = [key for key in server_keys if key in connected_clients]
- if not connected_keys:
- return []
- listed_tools = await asyncio.gather(
- *(connected_clients[key].list_tools() for key in connected_keys),
- return_exceptions=True,
- )
- names: List[str] = []
- for server_key, result in zip(connected_keys, listed_tools):
- if isinstance(result, BaseException):
- _log.warning(
- "[%s] list_tools failed for %s: %s",
- log_prefix,
- server_key,
- result,
- )
- continue
- for tool in result:
- tool_name = str(getattr(tool, "name", "") or "")
- if tool_name and tool_name not in names:
- names.append(tool_name)
- return names
+ return list(dict.fromkeys(
+ tool.name for key in server_keys if key in connected_clients
+ for tool in connected_clients[key].tools
+ ))
_required_connector_tool_names = await _mcp_tool_names_for_servers(
_required_connector_server_keys,
@@ -264,9 +133,7 @@ async def _mcp_tool_names_for_servers(server_keys: List[str], *, log_prefix: str
_required_connector_tool_names,
_required_plugin_mcp_tool_names,
_tool_approval_available,
- http_clients,
+ mcp_resources,
loader,
- mcp_clients,
toolkit,
- transient_mcp_clients,
)
diff --git a/src/backend/core/llm/factory/tools/native.py b/src/backend/core/llm/factory/tools/native.py
index 7ee8e51c..830e7185 100644
--- a/src/backend/core/llm/factory/tools/native.py
+++ b/src/backend/core/llm/factory/tools/native.py
@@ -66,12 +66,11 @@ async def native_tools(
# Skill files reach the sandbox via the unified /workspace/skills bind
# mount (built-in synced at startup, DB skills materialized on demand —
# see agent_skills.config.get_sandbox_skills_dir), so Bash needs no
- # per-call sync. loader/loaded_skill_ids kept for backward compat.
+ # per-call sync.
if request.allow_bash:
register_bash(
toolkit,
loader=loader,
- loaded_skill_ids=loaded_skill_ids,
source_run_id=request.run_id,
chat_id=request.chat_id,
sandbox_session_id=_sbx_sess,
diff --git a/src/backend/core/llm/factory/tools/tool_surface.py b/src/backend/core/llm/factory/tools/tool_surface.py
index b81ef97c..18a7b697 100644
--- a/src/backend/core/llm/factory/tools/tool_surface.py
+++ b/src/backend/core/llm/factory/tools/tool_surface.py
@@ -6,6 +6,7 @@
from typing import Any, Dict, Optional
from agentscope.tool import Toolkit
+from core.capabilities.skills.runtime_loader import ParallelRuntimeSkillLoader
from core.llm.factory.models import RequiredCapabilities
from core.llm.factory.request import AgentRequest
from core.llm.tools.ontology_toolkit import OntologyFilteredToolkit
@@ -23,9 +24,8 @@ async def tool_surface(
required: RequiredCapabilities,
_required_connector_tool_names,
_required_plugin_mcp_tool_names,
- http_clients,
+ mcp_resources,
loader,
- mcp_clients,
skill_ids_to_register,
toolkit,
):
@@ -71,7 +71,7 @@ async def tool_surface(
}
def _build_toolkit(*, include_skills: bool = True) -> Toolkit:
- # ``toolkit`` here is still the ToolCollector; construct the real Toolkit from the current collection state.
+ # Construct the native Toolkit from the assembled native tool values.
#
# "Skill" (AgentScope's builtin SkillViewer) is hidden unconditionally —
# distinct from the ontology forbidden_tools above. The Toolkit injects
@@ -81,12 +81,8 @@ def _build_toolkit(*, include_skills: bool = True) -> Toolkit:
# observability). A model that called Skill instead would bypass all of
# that and receive backend-path content unusable in the sandbox — so the
# schema is pure per-round prefill waste plus a wrong door.
- _visible_mcps = [*mcp_clients, *http_clients]
- if _prepared_capabilities is not None:
- _visible_mcps = _prepared_capabilities.wrap_mcp_clients(
- _visible_mcps, scope_id=request.capability_scope
- )
- selected_tools = toolkit.function_tools
+ mcp_tools = [tool for resource in mcp_resources for tool in resource.tools]
+ selected_tools = list(toolkit)
if request.profile:
from core.harness.registry import agent_profile
@@ -96,16 +92,21 @@ def _build_toolkit(*, include_skills: bool = True) -> Toolkit:
# The existing MCP resolver already intersects the explicit per-agent list.
# A profile that does not inherit defaults gets none when the list is empty.
if not _profile.inherit_default_mcps and not request.enabled_mcp_ids:
- _visible_mcps = []
+ mcp_tools = []
if not _profile.native_skill_prompt:
include_skills = False
- return OntologyFilteredToolkit(
- tools=selected_tools,
- mcps=_visible_mcps,
- skills_or_loaders=(toolkit.skill_loaders or None) if include_skills else None,
+ names = [tool.name for tool in [*selected_tools, *mcp_tools]]
+ if len(names) != len(set(names)):
+ raise ValueError("Duplicate model-facing tool names")
+ built = OntologyFilteredToolkit(
+ tools=[*selected_tools, *mcp_tools],
+ skills_or_loaders=[ParallelRuntimeSkillLoader(toolkit.skill_loaders)] if include_skills and toolkit.skill_loaders else None,
skill_instruction_template=_SKILL_INSTRUCTION_TEMPLATE,
hidden_tools={*_ontology_hidden_tools, "Skill"},
)
+ if _prepared_capabilities is not None:
+ built.capability_guard = _prepared_capabilities.tool_guard(scope_id=request.capability_scope)
+ return built
# Compute schemas first in the "subagent tools not yet registered" state
# (consistent with 1.x: subagent tools are registered after
diff --git a/src/backend/core/llm/host_services.py b/src/backend/core/llm/host_services.py
index 97114d11..95796e18 100644
--- a/src/backend/core/llm/host_services.py
+++ b/src/backend/core/llm/host_services.py
@@ -17,8 +17,8 @@ def active_prompt_versions():
def register():
from core.harness.registry.mcp_clients import register_mcp_client_factory
- from core.llm.mcp.pool import make_client
- register_mcp_client_factory(make_client)
+ from core.llm.mcp.lifetime import discover
+ register_mcp_client_factory(discover)
from core.models.config import ModelConfigService
from core.llm.prompt_rendering import render_active_prompt_part
from core.llm.runtime.review_text_agent import run_text_agent
diff --git a/src/backend/core/llm/mcp/__init__.py b/src/backend/core/llm/mcp/__init__.py
index a2441c92..62e5be1f 100644
--- a/src/backend/core/llm/mcp/__init__.py
+++ b/src/backend/core/llm/mcp/__init__.py
@@ -1 +1 @@
-"""mcp domain package."""
+"""MCP client pool, tool invocation and the gateway tool for agents."""
diff --git a/src/backend/core/llm/mcp/administration.py b/src/backend/core/llm/mcp/administration.py
deleted file mode 100644
index a838ed76..00000000
--- a/src/backend/core/llm/mcp/administration.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""Refresh live MCP connections after configuration changes."""
-import logging
-logger = logging.getLogger(__name__)
-
-async def reinitialize_mcp_pool(source: str = "service_configs") -> None:
- """Invalidate MCP config cache and reinitialize stable connections.
-
- Called after system config changes so that MCP sub-processes pick up
- new env vars (e.g. updated API URLs/keys).
- """
- try:
- from core.capabilities.mcp.service import McpServerConfigService
-
- mcp_svc = McpServerConfigService.get_instance()
- mcp_svc.invalidate_cache()
-
- from core.llm.mcp.pool import MCPConnectionPool
-
- pool = MCPConnectionPool.get_instance()
- if pool.is_initialized:
- new_configs = mcp_svc.get_all_servers()
- await pool.reinitialize_if_config_changed(new_configs)
- logger.info("[%s] MCP pool reinitialize triggered after config update", source)
- except Exception as exc:
- logger.warning("[%s] MCP pool reinitialize failed: %s", source, exc)
diff --git a/src/backend/core/llm/mcp/callbacks.py b/src/backend/core/llm/mcp/callbacks.py
new file mode 100644
index 00000000..2499287a
--- /dev/null
+++ b/src/backend/core/llm/mcp/callbacks.py
@@ -0,0 +1,168 @@
+"""Run-bound MCP interactions. Remote services never receive the main conversation.
+
+A server request (sampling, elicitation) is answered only on behalf of the outer
+tool call that caused it. On 2026-07-28 connections the SDK runs those callbacks
+inside the calling task, so `CURRENT_CALL` names the call exactly; handshake-era
+servers deliver them from the session task, where only a sole in-flight call can
+be credited.
+"""
+import asyncio
+import json
+from contextlib import aclosing, nullcontext
+from contextvars import ContextVar
+from dataclasses import dataclass
+from urllib.parse import urlsplit
+import jsonschema
+from agentscope.message import Msg, TextBlock
+from mcp import types
+from core.llm.tools import user_questions
+
+CURRENT_CALL: ContextVar["CallScope | None"] = ContextVar("mcp_current_call", default=None)
+
+
+def route(session):
+ call = CURRENT_CALL.get()
+ if call is not None and call.session is session:
+ return call
+ return next(iter(session.in_flight)) if len(session.in_flight) == 1 else None
+
+
+@dataclass
+class Interactions:
+ """What one run allows a server to ask of it; shared by that run's calls."""
+ request: object
+ server: str
+ interactive: bool
+ agent: object = None
+ samples: int = 0
+
+ def open_call(self, session=None):
+ return CallScope(self, session)
+
+
+class CallScope:
+ """One outer tool call; server requests made on its behalf end with it."""
+
+ def __init__(self, run, session=None):
+ self.run, self.session = run, session
+ self.active = True
+ self.pending = set()
+ self.budget = None
+
+ async def dispatch(self, method, context, params):
+ task = asyncio.create_task(getattr(self, method)(context, params))
+ self.pending.add(task)
+ try:
+ return await task
+ finally:
+ self.pending.discard(task)
+
+ async def finish(self):
+ self.active = False
+ tasks = list(self.pending)
+ for task in tasks:
+ task.cancel()
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ async def validate(self):
+ if not self.active or self.run.agent is None:
+ raise RuntimeError("MCP interaction is outside an active run")
+ prepared = getattr(self.run.agent, "_jx_prepared_capabilities", None)
+ if prepared is not None:
+ await asyncio.to_thread(prepared.validate, user_id=str(self.run.request.current_user_id or ""))
+ if not await asyncio.to_thread(prepared.validate_tool_call, self.run.server):
+ raise PermissionError("MCP capability was revoked")
+ guard = getattr(getattr(self.run.agent, "toolkit", None), "capability_guard", None)
+ if guard is not None and not await asyncio.to_thread(guard, self.run.server):
+ raise PermissionError("MCP capability was revoked")
+
+ async def sampling(self, _context, params):
+ if not self.active or self.run.agent is None or self.run.samples >= 4:
+ return types.ErrorData(code=-32600, message="Sampling unavailable or budget exhausted")
+ if params.tools or params.include_context not in (None, "none"):
+ return types.ErrorData(code=-32602, message="Sampling accepts isolated text without tools or shared context")
+ messages = []
+ for message in params.messages:
+ content = message.content if isinstance(message.content, list) else [message.content]
+ if any(not isinstance(item, types.TextContent) for item in content):
+ return types.ErrorData(code=-32602, message="Only text sampling is enabled")
+ messages.append(Msg(name=self.run.server, role=message.role,
+ content=[TextBlock(text=item.text) for item in content]))
+ if params.system_prompt:
+ messages.insert(0, Msg(name=self.run.server, role="system", content=params.system_prompt))
+ if len(params.model_dump_json()) > 32000 or not 0 < params.max_tokens <= 4096:
+ return types.ErrorData(code=-32602, message="Sampling request exceeds the run budget")
+ self.run.samples += 1
+ await self.validate()
+ from core.models.usage import model_usage_scope
+ from core.harness.ledger import HarnessUsageLedger
+ with model_usage_scope(self.run.request.run_id or "", HarnessUsageLedger()):
+ async with asyncio.timeout(60):
+ response = await self.run.agent.model(messages, tools=None, max_tokens=params.max_tokens)
+ response_value = None
+ if hasattr(response, "__aiter__"):
+ async with aclosing(response):
+ async for chunk in response:
+ response_value = chunk
+ else:
+ response_value = response
+ if response_value is None:
+ return types.ErrorData(code=-32603, message="Sampling model returned no response")
+ if not self.active:
+ return types.ErrorData(code=-32600, message="Outer MCP invocation ended")
+ await self.validate()
+ text = "".join(block.text for block in response_value.content if isinstance(block, TextBlock))
+ return types.CreateMessageResult(role="assistant", content=types.TextContent(text=text),
+ model=str(self.run.agent.model.model), stop_reason="endTurn")
+
+ async def elicitation(self, _context, params):
+ if not self.active or not self.run.interactive:
+ return types.ElicitResult(action="decline")
+ await self.validate()
+ if params.mode == "url":
+ parsed = urlsplit(params.url)
+ if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
+ return types.ElicitResult(action="decline")
+ questions = [{"id": "external", "question": f"{self.run.server}: {params.message}\n{params.url}",
+ "options": [{"label": "已在外部页面完成"}, {"label": "拒绝"}]}]
+ else:
+ schema = params.requested_schema
+ props = schema.get("properties", {})
+ if not props or len(props) > user_questions.MAX_QUESTIONS:
+ return types.ElicitResult(action="decline")
+ questions = []
+ for key, spec in props.items():
+ if spec.get("format") in {"password", "secret"} or spec.get("writeOnly"):
+ return types.ElicitResult(action="decline")
+ values = spec.get("enum", [True, False] if spec.get("type") == "boolean" else [])
+ questions.append({"id": key, "question": f"{self.run.server}: {params.message} — {spec.get('title', key)}",
+ "options": [{"label": json.dumps(v, ensure_ascii=False)} for v in values]})
+ # Only the human wait is excluded: validation, sampling and server
+ # work before/after this block still consume the original budget.
+ with self.budget.waiting_for_user() if self.budget is not None else nullcontext():
+ outcome = await user_questions.ask(chat_id=self.run.request.chat_id, questions=questions, interactive=True)
+ if not self.active or outcome["status"] != "answered":
+ return types.ElicitResult(action="cancel")
+ await self.validate()
+ answers = outcome["answers"]
+ if params.mode == "url":
+ accepted = answers[0].get("selected_labels") == ["已在外部页面完成"]
+ return types.ElicitResult(action="accept" if accepted else "decline")
+ content = {}
+ try:
+ for answer in answers:
+ if answer.get("skipped"):
+ continue
+ key = answer["id"]
+ selected = answer.get("selected_labels") or []
+ value = answer.get("custom")
+ if value is None and selected:
+ value = json.loads(selected[0])
+ elif props[key].get("type") != "string":
+ value = json.loads(value)
+ content[key] = value
+ jsonschema.validate(content, schema)
+ except (ValueError, TypeError, jsonschema.ValidationError, jsonschema.SchemaError):
+ return types.ElicitResult(action="decline")
+ return types.ElicitResult(action="accept", content=content)
diff --git a/src/backend/core/llm/mcp/capability_guards.py b/src/backend/core/llm/mcp/capability_guards.py
deleted file mode 100644
index 682fac39..00000000
--- a/src/backend/core/llm/mcp/capability_guards.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""Per-request connector guards; pooled clients remain account-neutral."""
-
-import asyncio
-from agentscope.tool import ToolBase, ToolChunk
-from agentscope.message import TextBlock, ToolResultState
-
-
-def connector_available(run, server_id):
- return run.validate_tool_call(server_id)
-
-
-class AvailableMCPTool(ToolBase):
- def __init__(self, tool, run, server_id):
- self.tool, self.run, self.server_id = tool, run, server_id
- for name in (
- "name",
- "description",
- "input_schema",
- "is_concurrency_safe",
- "is_read_only",
- "is_external_tool",
- "is_state_injected",
- "is_mcp",
- "mcp_name",
- ):
- setattr(self, name, getattr(tool, name))
-
- def __getattr__(self, name):
- return getattr(self.tool, name)
-
- async def check_permissions(self, *args, **kwargs):
- return await self.tool.check_permissions(*args, **kwargs)
-
- async def __call__(self, **kwargs):
- available = await asyncio.to_thread(connector_available, self.run, self.server_id)
- if not available:
- return ToolChunk(
- state=ToolResultState.ERROR,
- content=[
- TextBlock(
- text="该连接器已停用或当前不可用,本次调用未执行。请说明情况并继续处理可完成的部分。"
- )
- ],
- )
- # Do not catch an uncertain write outcome or retry a side effect.
- return await self.tool(**kwargs)
-
-
-class AvailableMCPClient:
- def __init__(self, client, run):
- self.client, self.run = client, run
- self.name = client.name
-
- def __getattr__(self, name):
- return getattr(self.client, name)
-
- async def list_tools(self):
- return [
- AvailableMCPTool(tool, self.run, self.name) for tool in await self.client.list_tools()
- ]
-
- async def get_tool(self, name):
- return AvailableMCPTool(await self.client.get_tool(name), self.run, self.name)
diff --git a/src/backend/core/llm/mcp/chat_sessions.py b/src/backend/core/llm/mcp/chat_sessions.py
new file mode 100644
index 00000000..69527178
--- /dev/null
+++ b/src/backend/core/llm/mcp/chat_sessions.py
@@ -0,0 +1,181 @@
+"""Bounded pool of chat-scoped STDIO sessions shared by that chat's concurrent runs.
+
+A session is keyed by the trusted executing user, chat and capability scope plus the
+server configuration. Runs hold counted leases; the call gate inside the session
+orders writes, so a run never waits for another run to finish. Idle sessions
+expire, and are reclaimed oldest-first when capacity is needed.
+"""
+import asyncio
+import hashlib
+import json
+import time
+from dataclasses import dataclass
+
+IDLE_SECONDS = 60.0
+MAX_SESSIONS = 32
+MAX_USER_SESSIONS = 4
+_entries = {}
+
+
+@dataclass
+class Entry:
+ key: tuple
+ loop: object
+ session: object = None
+ opening: asyncio.Task | None = None
+ expiry: asyncio.Task | None = None
+ holders: int = 0
+ idle_at: float = 0.0
+ retired: bool = False
+
+ @property
+ def idle(self):
+ return self.holders == 0 and self.opening is None
+
+ def detach(self):
+ """Stop handing this session out; its holders keep it until they release."""
+ if _entries.get(self.key) is self:
+ del _entries[self.key]
+ self.retired = True
+ if self.expiry is not None:
+ self.expiry.cancel()
+ self.expiry = None
+
+
+class Lease:
+ def __init__(self, entry):
+ self.entry, self.session = entry, entry.session
+ self._released = False
+
+ async def release(self, *, abort=False):
+ if self._released:
+ return
+ self._released = True
+ entry = self.entry
+ entry.holders -= 1
+ if abort or self.session.stopped:
+ entry.detach()
+ if entry.holders:
+ return
+ if entry.retired:
+ await self.session.close()
+ else:
+ _park(entry)
+
+
+async def _expire(entry):
+ await asyncio.sleep(IDLE_SECONDS)
+ if entry.idle and _entries.get(entry.key) is entry:
+ entry.detach()
+ await entry.session.close()
+
+
+async def _reclaim(user):
+ """Make room for one more session, closing idle sessions oldest-first."""
+ while True:
+ user_entries = [e for k, e in _entries.items() if k[0] == user]
+ if len(user_entries) >= MAX_USER_SESSIONS:
+ candidates = user_entries
+ elif len(_entries) >= MAX_SESSIONS:
+ candidates = list(_entries.values())
+ else:
+ return
+ idle = [e for e in candidates if e.idle]
+ if not idle:
+ raise RuntimeError("MCP STDIO session capacity reached")
+ victim = min(idle, key=lambda e: e.idle_at)
+ victim.detach()
+ await victim.session.close()
+
+
+def _key(scope, name, cfg):
+ from mcp.client.stdio import get_default_environment
+ cfg = {**cfg, "env": get_default_environment() | (cfg.get("env") or {})}
+ cfg.pop("oauth_provider", None)
+ fingerprint = hashlib.sha256(json.dumps(cfg, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
+ return (*scope, name, fingerprint)
+
+
+def _park(entry):
+ """An unheld session waits out its idle period, or closes now if it was retired."""
+ if entry.retired:
+ return asyncio.create_task(entry.session.close())
+ entry.idle_at = time.monotonic()
+ entry.expiry = asyncio.create_task(_expire(entry))
+
+
+async def _open(entry, name, cfg):
+ from core.llm.mcp.session import Session
+ try:
+ session = await Session.open(name, cfg)
+ except BaseException:
+ if entry.opening is asyncio.current_task():
+ entry.opening = None
+ entry.detach()
+ raise
+ entry.opening = None
+ entry.session = session
+ if entry.retired:
+ await session.close()
+ raise RuntimeError("MCP chat session was revoked")
+ if entry.holders == 0:
+ _park(entry)
+ return session
+
+
+async def acquire(scope, name, cfg):
+ # Scope contains trusted executing user, chat and capability scope, not remote metadata.
+ key = _key(scope, name, cfg)
+ for old in [e for k, e in _entries.items() if k[:-1] == key[:-1] and k != key]:
+ old.detach()
+ if old.idle and old.session is not None:
+ await old.session.close()
+ entry = _entries.get(key)
+ if entry is not None and (entry.retired or entry.session is not None and entry.session.stopped):
+ entry.detach()
+ entry = None
+ if entry is None:
+ await _reclaim(scope[0])
+ entry = _entries.setdefault(key, Entry(key, asyncio.get_running_loop()))
+ if entry.expiry is not None:
+ entry.expiry.cancel()
+ entry.expiry = None
+ entry.holders += 1
+ try:
+ if entry.session is None:
+ if entry.opening is None:
+ entry.opening = asyncio.create_task(_open(entry, name, cfg), name=f"mcp-open:{name}")
+ await asyncio.shield(entry.opening)
+ if entry.retired or entry.session.stopped:
+ raise RuntimeError("MCP chat session was revoked")
+ return Lease(entry)
+ except BaseException:
+ entry.holders -= 1
+ if entry.holders == 0 and entry.session is not None and entry.expiry is None:
+ _park(entry)
+ raise
+
+
+async def close_chat(chat_id=None):
+ """Revoke every session of a chat (or all); holders' next calls fail as closed."""
+ for entry in [e for k, e in list(_entries.items()) if chat_id is None or k[1] == chat_id]:
+ entry.detach()
+ if entry.opening is not None:
+ opening, entry.opening = entry.opening, None
+ opening.cancel()
+ await asyncio.gather(opening, return_exceptions=True)
+ if entry.session is not None:
+ entry.session.failed = True
+ await entry.session.close()
+
+
+def invalidate_chat(chat_id):
+ """Deletion can run on a sync API worker; dispatch teardown to the owning loop."""
+ entries = [entry for key, entry in list(_entries.items()) if key[1] == chat_id]
+ for entry in entries:
+ # Plain flag writes take effect at once; dict and task work stays on the owning loop.
+ entry.retired = True
+ if entry.session is not None:
+ entry.session.failed = True
+ for loop in {entry.loop for entry in entries}:
+ loop.call_soon_threadsafe(lambda: asyncio.create_task(close_chat(chat_id)))
diff --git a/src/backend/core/llm/mcp/client.py b/src/backend/core/llm/mcp/client.py
new file mode 100644
index 00000000..9106c556
--- /dev/null
+++ b/src/backend/core/llm/mcp/client.py
@@ -0,0 +1,149 @@
+"""SDK-native connections; project authorization is attached at the transport boundary."""
+import asyncio
+import hashlib
+import json
+import math
+import os
+import weakref
+from contextlib import asynccontextmanager
+from typing import Any
+import httpx2
+from mcp import Client, StdioServerParameters
+from mcp.client.caching import CacheConfig, InMemoryResponseCacheStore
+from mcp.client.sse import sse_client
+from mcp.client.streamable_http import streamable_http_client
+from mcp.shared._httpx_utils import create_mcp_http_client
+
+
+HTTP_TRANSPORTS = frozenset({"streamable_http", "sse"})
+_KB_SERVER_ID = "retrieve_dataset_content"
+
+
+def _positive_timeout(value: Any, default: float) -> float:
+ try:
+ parsed = float(value)
+ return parsed if parsed > 0 else default
+ except (TypeError, ValueError):
+ return default
+
+
+def _execution_timeout(name: str, cfg: dict) -> float:
+ """Resolve a hard tool-call deadline, with a tighter KB-specific default."""
+ if cfg.get("execution_timeout") is not None:
+ return _positive_timeout(cfg["execution_timeout"], 120.0)
+ if name == _KB_SERVER_ID:
+ return _positive_timeout(os.getenv("KB_MCP_EXECUTION_TIMEOUT_SECONDS"), 75.0)
+ return _positive_timeout(os.getenv("MCP_TOOL_EXECUTION_TIMEOUT_SECONDS"), 120.0)
+
+
+def _http_transport_timeout(name: str, cfg: dict, execution_timeout: float) -> float:
+ if cfg.get("transport_timeout") is not None:
+ return _positive_timeout(cfg["transport_timeout"], 30.0)
+ if name == _KB_SERVER_ID:
+ # Leave a small margin beyond the MCP server's own structured timeout
+ # so callers receive its tool_result instead of a transport exception.
+ return execution_timeout + 5.0
+ if name == "internet_search":
+ # Search returns per-query timeouts after its 45-second batch deadline.
+ return 50.0
+ return 30.0
+
+
+def is_http_cfg(cfg: dict) -> bool:
+ return cfg.get("transport") in HTTP_TRANSPORTS
+
+
+@asynccontextmanager
+async def authorized_transport(name, cfg):
+ from core.llm.mcp.invocation import HEADER, PLUGIN_HEADER, for_url
+ provider = cfg.get("oauth_provider")
+ headers = cfg.get("headers") or {}
+ signed = any(key.lower() == HEADER.lower() for key in headers)
+
+ async def authorize(request):
+ from core.llm.mcp.callbacks import CURRENT_CALL
+ call = CURRENT_CALL.get()
+ if call is not None and call.budget is not None:
+ # This request is bounded by the invocation execution budget. HTTP
+ # read time includes human waits; retain connect/write/pool limits.
+ request.extensions["timeout"] = {**request.extensions.get("timeout", {}), "read": None}
+ if provider is not None:
+ await provider.mcp_request_hook(request)
+ if signed:
+ request.headers.update(for_url(
+ cfg["url"], request.headers.get("x-current-user-id"),
+ request.headers.get("x-chat-id"), request.headers.get(PLUGIN_HEADER, ""),
+ ))
+
+ timeout = _http_transport_timeout(name, cfg, _execution_timeout(name, cfg))
+ if cfg["transport"] == "sse":
+ def factory(headers=None, timeout=None, auth=None):
+ return httpx2.AsyncClient(headers=headers, timeout=timeout, auth=auth,
+ event_hooks={"request": [authorize]})
+ # One SSE stream serves all invocations; a shared idle deadline cannot
+ # distinguish human waits. Discovery and invocation budgets bound work.
+ async with sse_client(cfg["url"], headers=headers, timeout=timeout,
+ sse_read_timeout=math.inf, auth=provider, httpx_client_factory=factory) as streams:
+ yield streams
+ else:
+ async with create_mcp_http_client(headers=headers, timeout=httpx2.Timeout(timeout),
+ auth=provider) as http:
+ http.event_hooks["request"].append(authorize)
+ async with streamable_http_client(cfg["url"], http_client=http) as streams:
+ yield streams
+
+
+# Listings outlive a run: each run opens its own HTTP client, so discovery results
+# are shared through one store per event loop, partitioned by authorization context.
+FIRST_PARTY_LISTING_TTL_MS = 60_000
+_VOLATILE_HEADERS = frozenset({"x-chat-id", "x-conversation-id", "x-hugagent-invocation"})
+_listing_stores = weakref.WeakKeyDictionary()
+
+
+def listing_cache(cfg: dict):
+ """Cache config for an HTTP server, or None to keep the SDK's per-client default."""
+ if not is_http_cfg(cfg) or cfg.get("oauth_provider") is not None:
+ return None
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ return None
+ from core.llm.mcp.invocation import audience_for
+ headers = {str(k).lower(): str(v) for k, v in (cfg.get("headers") or {}).items()
+ if str(k).lower() not in _VOLATILE_HEADERS}
+ partition = hashlib.sha256(json.dumps(headers, sort_keys=True).encode()).hexdigest()
+ store = _listing_stores.setdefault(loop, InMemoryResponseCacheStore())
+ # Built-in servers send no freshness hints; their listings change only on deploy.
+ ttl = FIRST_PARTY_LISTING_TTL_MS if audience_for(cfg["url"]) else 0
+ return CacheConfig(store=store, partition=partition, target_id=cfg["url"], default_ttl_ms=ttl)
+
+
+class AuthorizedClient(Client):
+ """Reject schema changes within a frozen run instead of replaying an old permission."""
+ def freeze(self, descriptors):
+ self.frozen_tools = {d.name: d.model_dump(mode="json") for d in descriptors}
+
+ async def list_tools(self, **kwargs):
+ page = await super().list_tools(**kwargs)
+ frozen = getattr(self, "frozen_tools", None)
+ if frozen is not None:
+ for tool in page.tools:
+ if frozen.get(tool.name) != tool.model_dump(mode="json"):
+ raise ValueError("MCP tool schema changed; a new authorized run is required")
+ return page
+
+
+def make_client(name: str, cfg: dict, *, sampling_callback=None, elicitation_callback=None) -> Client:
+ """One native client per authorized lifetime; negotiation and caches belong to the SDK."""
+ transport = (authorized_transport(name, cfg) if is_http_cfg(cfg) else
+ StdioServerParameters(command=cfg.get("command", "python"),
+ args=cfg.get("args", []), env=cfg.get("env") or None,
+ cwd=cfg.get("cwd")))
+ cache = listing_cache(cfg)
+ # Interactive sessions also serve resources/prompts, whose SDK methods expose
+ # no per-request timeout. Session.open bounds discovery, and SessionTool
+ # bounds each invocation; never mutate a shared SDK timer during a callback.
+ sdk_timeout = math.inf if elicitation_callback is not None else _execution_timeout(name, cfg)
+ return AuthorizedClient(transport, mode="auto", read_timeout_seconds=sdk_timeout,
+ sampling_callback=sampling_callback, elicitation_callback=elicitation_callback,
+ **({"cache": cache} if cache is not None else {}))
diff --git a/src/backend/core/llm/mcp/context_tools.py b/src/backend/core/llm/mcp/context_tools.py
new file mode 100644
index 00000000..75776f26
--- /dev/null
+++ b/src/backend/core/llm/mcp/context_tools.py
@@ -0,0 +1,69 @@
+"""Expose advertised MCP resources and prompts as permission-governed tools."""
+import hashlib
+import json
+import jsonschema
+from mcp.shared.uri_template import UriTemplate
+from agentscope.message import TextBlock
+from agentscope.tool import ToolChunk
+from agentscope.permission import PermissionBehavior, PermissionDecision
+from core.llm.mcp.results import content_blocks
+from core.llm.mcp.tools import SessionTool
+
+
+class ContextTool(SessionTool):
+ is_read_only = True
+ is_concurrency_safe = True
+
+ def __init__(self, resource, kind, descriptor, timeout):
+ super().__init__(resource, timeout)
+ server = resource.name
+ self.kind, self.descriptor = kind, descriptor
+ identity = (str(descriptor.uri) if kind == "resource" else descriptor.uri_template
+ if kind == "resource_template" else descriptor.name)
+ self.name = "mcp_" + kind + "_" + hashlib.sha256(f"{server}:{identity}".encode()).hexdigest()[:16]
+ self.description = f"{kind} from {server}: {descriptor.description or identity}"
+ self.input_schema = {"type": "object", "properties": {}, "additionalProperties": False}
+ if kind == "resource_template":
+ self.template = UriTemplate.parse(descriptor.uri_template)
+ self.input_schema["properties"] = {v.name: ({"type": "array", "items": {"type": "string"}}
+ if v.explode else {"type": "string"}) for v in self.template.variables}
+ self.input_schema["required"] = [v for v in self.template.variable_names
+ if v not in self.template.query_variable_names]
+ if kind == "prompt":
+ self.input_schema["properties"] = {a.name: {"type": "string", "description": a.description or a.name}
+ for a in descriptor.arguments or []}
+ self.input_schema["required"] = [a.name for a in descriptor.arguments or [] if a.required]
+
+ async def check_permissions(self, *_args, **_kwargs):
+ return PermissionDecision(behavior=PermissionBehavior.ASK,
+ message="Read remote MCP context from the selected server")
+
+ async def call(self, **kwargs):
+ return await self._invoke(lambda: self._read(**kwargs), exclusive=False)
+
+ async def _read(self, **kwargs):
+ jsonschema.validate(kwargs, self.input_schema)
+ client = self.resource.session.client
+ if self.kind in {"resource", "resource_template"}:
+ uri = str(self.descriptor.uri) if self.kind == "resource" else self.template.expand(kwargs)
+ # An explicit read wants current content; only listings are cached.
+ result = await client.read_resource(uri, cache_mode="bypass")
+ blocks = [TextBlock(text=item.model_dump_json(by_alias=True, exclude_none=True)) for item in result.contents]
+ else:
+ result = await client.get_prompt(self.descriptor.name, arguments=kwargs)
+ blocks = []
+ for message in result.messages:
+ blocks.append(TextBlock(text=json.dumps({"role": message.role}, ensure_ascii=False)))
+ blocks.extend(content_blocks([message.content]))
+ return ToolChunk(content=blocks, metadata={"mcp": result.model_dump(mode="json", by_alias=True, exclude_none=True)})
+
+
+def context_tools(resource, timeout):
+ tools, names = [], set()
+ for kind, descriptor in resource.session.context:
+ tool = ContextTool(resource, kind, descriptor, timeout)
+ if tool.name in names:
+ raise ValueError("Duplicate MCP context identity")
+ names.add(tool.name)
+ tools.append(tool)
+ return tools
diff --git a/src/backend/core/llm/mcp/deadline.py b/src/backend/core/llm/mcp/deadline.py
new file mode 100644
index 00000000..95d119d7
--- /dev/null
+++ b/src/backend/core/llm/mcp/deadline.py
@@ -0,0 +1,40 @@
+"""Per-invocation execution time, excluding time spent waiting for a person."""
+import asyncio
+from contextlib import contextmanager
+
+
+class InvocationBudget:
+ """One task deadline; overlapping human waits pause the same clock once."""
+
+ def __init__(self, seconds: float):
+ self._timeout = asyncio.timeout(seconds)
+ self._remaining = seconds
+ self._waiters = 0
+ self._active = False
+
+ async def __aenter__(self):
+ await self._timeout.__aenter__()
+ self._active = True
+ return self
+
+ async def __aexit__(self, *exc):
+ self._active = False
+ return await self._timeout.__aexit__(*exc)
+
+ @contextmanager
+ def waiting_for_user(self):
+ # A late callback must not resurrect a completed or already expired call.
+ if not self._active or self._timeout.expired():
+ raise TimeoutError("MCP invocation is no longer active")
+ if self._waiters == 0:
+ self._remaining = self._timeout.when() - asyncio.get_running_loop().time()
+ if self._remaining <= 0:
+ raise TimeoutError("MCP execution budget exhausted")
+ self._timeout.reschedule(None)
+ self._waiters += 1
+ try:
+ yield
+ finally:
+ self._waiters -= 1
+ if self._waiters == 0 and self._active and not self._timeout.expired():
+ self._timeout.reschedule(asyncio.get_running_loop().time() + self._remaining)
diff --git a/src/backend/core/llm/mcp/invocation.py b/src/backend/core/llm/mcp/invocation.py
index 5de6a0be..85778848 100644
--- a/src/backend/core/llm/mcp/invocation.py
+++ b/src/backend/core/llm/mcp/invocation.py
@@ -26,10 +26,14 @@ def issue(audience, user_id, chat_id, plugin_id=""):
signature = hmac.new(key(), body.encode(), hashlib.sha256).hexdigest()
return {HEADER: body + "." + signature}
-def for_url(url, user_id, chat_id, plugin_id=""):
+def audience_for(url):
+ """The built-in server a URL addresses, or None for any other server."""
from core.config.mcp_config import mcp_http_url as _mcp_http_url
from extensions.mcp_servers._ports import PORTS
- audience = next((name for name in PORTS if _mcp_http_url(name).rstrip("/") == str(url).rstrip("/")), None)
+ return next((name for name in PORTS if _mcp_http_url(name).rstrip("/") == str(url).rstrip("/")), None)
+
+def for_url(url, user_id, chat_id, plugin_id=""):
+ audience = audience_for(url)
if not audience:
return {}
try:
diff --git a/src/backend/core/llm/mcp/lifetime.py b/src/backend/core/llm/mcp/lifetime.py
new file mode 100644
index 00000000..c08240c0
--- /dev/null
+++ b/src/backend/core/llm/mcp/lifetime.py
@@ -0,0 +1,68 @@
+"""A run's handle on an MCP session: its tools, its interaction authority and its release."""
+import asyncio
+from dataclasses import dataclass, field
+from core.llm.mcp.client import _execution_timeout, is_http_cfg
+
+MANIFEST_SOURCES = frozenset({"cloud_manifest", "plugin_manifest"})
+
+
+@dataclass
+class MCPResource:
+ name: str
+ tools: list = field(default_factory=list)
+ session: object = None
+ interactions: object = None
+ # Write calls whose outcome is unknown in this run; exact replays are refused.
+ unknown_outcomes: set = field(default_factory=set)
+ closed: bool = False
+ _lease: object = None
+ _closing: asyncio.Task | None = None
+
+ @property
+ def descriptors(self):
+ return self.session.descriptors if self.session is not None else []
+
+ async def close(self, *, abort=False):
+ """Close a run-owned session, or return a pooled one; abort also retires the pooled session."""
+ # Tools reject calls at once; teardown finishes even if the caller is cancelled again.
+ self.closed = True
+ if self._closing is None:
+ self._closing = asyncio.create_task(self._close(abort))
+ await asyncio.shield(self._closing)
+
+ async def _close(self, abort):
+ if self._lease is not None:
+ await self._lease.release(abort=abort)
+ elif self.session is not None:
+ await self.session.close()
+
+
+def _bind_tools(resource, cfg):
+ from core.llm.mcp.context_tools import context_tools
+ from core.llm.mcp.tools import RemoteTool
+ timeout = _execution_timeout(resource.name, cfg)
+ enabled, disabled = cfg.get("enable_tools"), set(cfg.get("disable_tools") or [])
+ tools = [RemoteTool(resource, descriptor, timeout) for descriptor in resource.session.descriptors
+ if (enabled is None or descriptor.name in enabled) and descriptor.name not in disabled]
+ return tools + context_tools(resource, timeout)
+
+
+async def discover(name, cfg, *, interactions=None, scope=None):
+ """Bind a run to a session. Chat-scoped STDIO servers share a pooled process;
+ HTTP and unscoped servers get a run-owned session."""
+ if cfg.get("schema_source") in MANIFEST_SOURCES:
+ from core.llm.tools.cloud_manifest import manifest_tools
+ return MCPResource(name, tools=manifest_tools(name, cfg))
+ if scope and not is_http_cfg(cfg):
+ from core.llm.mcp.chat_sessions import acquire
+ lease = await acquire(scope, name, cfg)
+ resource = MCPResource(name, session=lease.session, interactions=interactions, _lease=lease)
+ else:
+ from core.llm.mcp.session import Session
+ resource = MCPResource(name, session=await Session.open(name, cfg), interactions=interactions)
+ try:
+ resource.tools = _bind_tools(resource, cfg)
+ except BaseException:
+ await resource.close(abort=True)
+ raise
+ return resource
diff --git a/src/backend/core/llm/mcp/manager.py b/src/backend/core/llm/mcp/manager.py
index 24d0e825..f7492d00 100644
--- a/src/backend/core/llm/mcp/manager.py
+++ b/src/backend/core/llm/mcp/manager.py
@@ -1,246 +1,11 @@
-"""MCP client pool manager for AgentScope 2.0.
-
-Manages MCPClient instances with TTL caching.
-
-Migration notes (1.x → 2.0)
----------------------------
-- ``StdIOStatefulClient(name, command, args, env)`` →
- ``MCPClient(name=, is_stateful=True, mcp_config=StdioMCPConfig(command, args, env))``.
-- In 2.0 ``Toolkit`` is constructed **once** (``Toolkit(tools=, mcps=, ...)``); there are
- no incremental ``register_mcp_client`` / ``register_tool_function`` methods and no
- ``namesake_strategy``. Stateful clients must ``connect()`` **before** being passed
- into ``Toolkit``.
-- Therefore ``connect_mcp_clients`` only connects and returns the client list; the
- Toolkit is built once in agent_factory via
- ``Toolkit(tools=[...FunctionTool...], mcps=clients)``.
-"""
-
+"""Factory failure cleanup and run resource teardown."""
from __future__ import annotations
-
import asyncio
import logging
-import time
from contextvars import ContextVar
from functools import wraps
-from typing import Any, Dict, List
-
-import httpx
-import mcp.types
-from mcp.client.sse import sse_client
-from mcp.client.streamable_http import streamable_http_client
-from pydantic import ConfigDict, Field
-
-from agentscope.mcp import MCPClient, StdioMCPConfig
-from agentscope.permission import PermissionBehavior, PermissionDecision
-from agentscope.tool import MCPTool, ToolBase, ToolChunk
-
logger = logging.getLogger(__name__)
-
-from core.capabilities.mcp.tool_schema import has_usable_schema
-
-
-def _cyfunc_probe() -> None: # after Cython compilation its type is cython_function_or_method
- pass
-
-
-# Cython compiles methods into cython_function_or_method; pydantic v2 does not recognize
-# it as a method and treats it as an "un-annotated field", raising PydanticUserError,
-# which makes the whole module fail to import after hardened compilation and fall back to
-# plaintext. Registering that type in ignored_types makes pydantic ignore compiled
-# methods; under pure Python it is just FunctionType, so there is no side effect.
-_CYFUNCTION_TYPE = type(_cyfunc_probe)
-
-# TTL for the instance-level memoization of ``list_tools``. Within a single turn,
-# agent_factory's HTTP MCP liveness probe (``_connect_http``, parallel list_tools) and
-# the subsequent ``Toolkit.get_tool_schemas()`` (AgentScope ``_get_available_tools``
-# does a **serial** ``await list_tools()`` per client) each enumerate tools once per
-# server — stateless HTTP creates a new connection every time, and the serial
-# re-enumeration is the main cause of the ~2s agent build time (11 servers, 5 slow
-# search MCPs at ~450ms each, chained serially). Memoization cuts the second
-# enumeration to ~0ms (the probe pass stays parallel at ~465ms). Per-request HTTP
-# clients get a fresh instance every turn → cache lifetime = one turn, no header
-# leakage across turns/users; pooled stdio clients survive across turns, so the TTL
-# is the safety net (tool definitions are static anyway; config changes rebuild the
-# pool → new instances).
-_LIST_TOOLS_TTL_S = 300.0
-
-
-class BareNameMCPClient(MCPClient):
- """Restore the server-side bare name of MCP tools (``internet_search`` rather than ``mcp__internet_search__internet_search``).
-
- AgentScope 2.0's ``MCPTool`` adapter rewrites the outward-facing name to
- ``mcp____``, but this project's display-name mapping
- (core/config/display_names), citation extraction (orchestration/citation_anchor
- dispatches on bare names like ``internet_search``), catalog gating, tool
- references in system prompts and SKILL.md, and frontend icons/panels/renderers
- are all built on the 1.x bare names. ``MCPTool.__call__`` actually calls the
- server via ``self._tool.name`` (the bare name), so rewriting the adapter's
- ``.name`` only affects the LLM-visible name and the SSE event stream; the call
- path is unaffected.
- """
-
- model_config = ConfigDict(ignored_types=(_CYFUNCTION_TYPE,), arbitrary_types_allowed=True)
- oauth_provider: Any = Field(default=None, exclude=True)
-
- def _create_http_client(self):
- """Attach the SDK OAuth provider without forking AgentScope's client."""
- from core.llm.mcp.invocation import HEADER, PLUGIN_HEADER, for_url
- config = self.mcp_config
- signed = any(key.lower() == HEADER.lower() for key in (config.headers or {}))
- if self.oauth_provider is None and not signed:
- return super()._create_http_client()
- oauth_hook = getattr(self.oauth_provider, "mcp_request_hook", None)
- async def request_hook(request):
- if oauth_hook:
- await oauth_hook(request)
- if signed:
- request.headers.update(for_url(config.url, request.headers.get("x-current-user-id"), request.headers.get("x-chat-id"), request.headers.get(PLUGIN_HEADER, "")))
- event_hooks = {"request": [request_hook]}
- if config.url.endswith("/sse") or config.url.endswith("/messages/"):
- def _http_client_factory(headers=None, timeout=None, auth=None):
- return httpx.AsyncClient(
- headers=headers,
- timeout=timeout,
- auth=auth,
- event_hooks=event_hooks,
- )
-
- return sse_client(
- url=config.url,
- headers=config.headers,
- timeout=config.timeout,
- auth=self.oauth_provider,
- httpx_client_factory=_http_client_factory,
- )
- http_client = httpx.AsyncClient(
- headers=config.headers,
- timeout=config.timeout,
- auth=self.oauth_provider,
- event_hooks=event_hooks,
- )
- return streamable_http_client(url=config.url, http_client=http_client)
-
- async def get_tool(self, name: str) -> MCPTool:
- tool = await super().get_tool(name)
- tool.name = tool._tool.name
- # Use AgentScope's concurrent batch executor for every MCP call.
- # Permission checks remain independent of the execution policy.
- tool.is_concurrency_safe = True
- return tool
-
- async def list_tools(self) -> List[MCPTool]:
- """Instance-level TTL memoization to eliminate duplicate list_tools for the same server within a turn.
-
- See the ``_LIST_TOOLS_TTL_S`` comment at the top of the module: the liveness
- probe and ``get_tool_schemas`` each call once, and the second serial
- re-enumeration is the main cause of the ~2s agent build time. The cache hangs
- off the instance (``__slots__`` includes ``__dict__``, so arbitrary attributes
- can be set); a new client per turn → naturally scoped to a single turn.
- """
- cached = getattr(self, "_lt_cache", None)
- if cached is not None:
- expires_at, tools = cached
- if time.monotonic() < expires_at:
- return tools
- tools = await super().list_tools()
- self._lt_cache = (time.monotonic() + _LIST_TOOLS_TTL_S, tools)
- return tools
-
-
-from core.llm.mcp.gateway_tool import GatewayMCPTool
-
-
-class ManifestMCPClient(BareNameMCPClient):
- """Network-free MCP discovery backed only by the current cloud manifest."""
-
- manifest_tools: List[Dict[str, Any]] = Field(default_factory=list, exclude=True)
- gateway_invoke_url: str = Field(exclude=True)
- schema_hash: str = Field(exclude=True)
- gateway_transport: Any = Field(default=None, exclude=True)
- gateway_plugin: str = Field(default="", exclude=True)
- manager_cloud_available: bool = Field(default=False, exclude=True)
- read_only: bool = Field(default=False, exclude=True)
-
- def _raw_manifest_tools(self) -> List[mcp.types.Tool]:
- tools: List[mcp.types.Tool] = []
- from core.harness.registry.run_capabilities import has_run_capabilities
- from core.capabilities.plugins.management.contract import localize, declared, tools as manager_tools
- items = list(self.manifest_tools)
- from extensions.mcp_servers.registry import gateway_server
- if self.read_only and gateway_server(self.name) is not None:
- items = [item for item in items if item.get("annotations", {}).get("readOnlyHint") is True]
- if has_run_capabilities() and self.gateway_plugin == "skill-manager":
- if any(t.get("name") == "install_skill" and declared(t, self.gateway_plugin) for t in items):
- items += [t for t in manager_tools("skill-manager", "local") if t["name"] == "upload_skill_to_cloud" and not any(x.get("name") == t["name"] for x in items)]
- for item in items:
- if has_run_capabilities():
- item = localize(item, self.gateway_plugin, cloud_available=self.manager_cloud_available or bool(self.gateway_invoke_url))
- if item is None:
- continue
- try:
- tool = mcp.types.Tool.model_validate(item)
- except Exception as exc: # noqa: BLE001 - isolate one malformed tool
- logger.warning("Invalid manifest tool in MCP '%s': %s", self.name, exc)
- continue
- if self.enable_tools is not None and tool.name not in self.enable_tools:
- continue
- if self.disable_tools is not None and tool.name in self.disable_tools:
- continue
- if not has_usable_schema(item):
- # A manifest entry that never captured the server's inputSchema
- # would become a zero-parameter tool: the model then has nowhere
- # to put its arguments and retries the same empty call forever.
- # Withhold it instead of offering a tool that can never succeed.
- logger.warning(
- "Manifest tool '%s' in MCP '%s' has no captured inputSchema; withheld",
- tool.name,
- self.name,
- )
- continue
- tools.append(tool)
- return tools
-
- async def list_raw_tools(self) -> List[mcp.types.Tool]:
- return self._raw_manifest_tools()
-
- async def get_tool(self, name: str) -> GatewayMCPTool:
- for tool in self._raw_manifest_tools():
- if tool.name == name:
- from core.harness.registry.run_capabilities import has_run_capabilities
- from core.capabilities.plugins.management.contract import declared
- if has_run_capabilities() and declared(tool.model_dump(by_alias=True), self.gateway_plugin):
- from core.llm.tools.management import ManagementTool
- return ManagementTool(self.name, tool, self.gateway_plugin, self.mcp_config.headers or {}, cloud_backed=bool(self.gateway_invoke_url))
- return GatewayMCPTool(
- mcp_name=self.name,
- tool=tool,
- invoke_url=self.gateway_invoke_url,
- schema_hash=self.schema_hash,
- headers=dict(self.mcp_config.headers or {}),
- timeout=float(self.execution_timeout or 120.0),
- transport=self.gateway_transport,
- source_plugin=self.gateway_plugin,
- )
- raise ValueError(f"Tool '{name}' not found in cloud manifest MCP '{self.name}'")
-
- async def list_tools(self) -> List[GatewayMCPTool]:
- return [await self.get_tool(tool.name) for tool in self._raw_manifest_tools()]
-
-
-def make_stdio_client(server_name: str, server_cfg: dict) -> MCPClient:
- """Build a stdio MCPClient (not yet connected) from a server config in configs/mcp_config.py."""
- return BareNameMCPClient(
- name=server_name,
- is_stateful=True,
- mcp_config=StdioMCPConfig(
- command=server_cfg.get("command", "python"),
- args=server_cfg.get("args", []),
- env=server_cfg.get("env") or None,
- ),
- )
-
-
# The factory owns transient clients until it returns them to its caller.
# ContextVar values propagate into parallel HTTP probes, so partial construction
# has one cleanup owner even when cancellation interrupts asyncio.gather.
@@ -262,7 +27,7 @@ async def construct(*args, **kwargs):
try:
return await function(*args, **kwargs)
except BaseException:
- await close_clients(list(reversed(clients)))
+ await close_clients(list(reversed(clients)), abort=True)
raise
finally:
_factory_clients.reset(token)
@@ -270,31 +35,7 @@ async def construct(*args, **kwargs):
return construct
-async def connect_mcp_clients(
- mcp_servers: Dict[str, dict],
-) -> List[MCPClient]:
- """Connect to MCP servers and return the connected MCPClient list."""
- clients: List[MCPClient] = []
- for server_name, server_cfg in mcp_servers.items():
- client = make_stdio_client(server_name, server_cfg)
- try:
- await client.connect()
- clients.append(own_factory_client(client))
- logger.debug("MCP client '%s' connected", server_name)
- except asyncio.CancelledError:
- await client.close()
- raise
- except Exception as exc:
- logger.warning("Failed to connect MCP server '%s': %s", server_name, exc)
- try:
- await client.close()
- except Exception as fallback_error:
- from core.infra.failures import record_best_effort_failure
- record_best_effort_failure('core.llm.mcp.manager:connect_mcp_clients', fallback_error)
- return clients
-
-
-async def close_clients(clients: List[MCPClient]) -> None:
+async def close_clients(clients: list, *, abort=False) -> None:
"""Close every client in its owning task, deferring repeated cancellation."""
cancelled = None
task = asyncio.current_task()
@@ -302,7 +43,7 @@ async def close_clients(clients: List[MCPClient]) -> None:
while True:
before = task.cancelling() if task else 0
try:
- await client.close()
+ await client.close(abort=True) if abort else await client.close()
break
except asyncio.CancelledError as exc:
cancelled = exc
diff --git a/src/backend/core/llm/mcp/pool.py b/src/backend/core/llm/mcp/pool.py
deleted file mode 100644
index a48ed46f..00000000
--- a/src/backend/core/llm/mcp/pool.py
+++ /dev/null
@@ -1,389 +0,0 @@
-"""Global MCP connection pool (AgentScope 2.0).
-
-Keeps stable MCP servers connected across requests to eliminate the 1-7s
-subprocess spawn overhead. Per-request servers (e.g. retrieve_dataset_content
-with runtime KB env vars) are spawned fresh each time.
-
-Migration notes (1.x → 2.0)
----------------------------
-- ``StdIOStatefulClient`` / ``HttpStatefulClient`` → ``MCPClient(is_stateful=,
- mcp_config=StdioMCPConfig|HttpMCPConfig)``.
-- The 2.0 ``Toolkit`` is constructed in one shot (``Toolkit(tools=, mcps=, ...)``);
- there is **no** ``register_mcp_client`` / ``register_tool_function`` and **no**
- ``MCPClient.get_callable_function`` — so the 1.x "cache MCPToolFunction,
- register on demand" optimization cannot be ported directly. This pool now only
- handles **connection reuse** (stable clients keep their connection across
- requests, saving the subprocess spawn), returning a list of connected
- ``MCPClient`` per request, and ``agent_factory`` builds
- ``Toolkit(tools=[...FunctionTool...], mcps=clients)`` in one go.
-- HTTP per-request (KB switches headers per user): use an MCPClient with
- ``is_stateful=False``; each call opens a fresh underlying connection, no
- pooling needed (see agent_factory).
-
-Usage:
- pool = MCPConnectionPool.get_instance()
- await pool.initialize(server_configs) # once at startup
-
- # Per request (stable reuses connections, per-request spawns fresh):
- clients, transient = await pool.get_request_clients(enabled_keys, per_request_cfg)
- toolkit = Toolkit(tools=fn_tools, mcps=clients) # in agent_factory
- ...
- await pool.close_transient(transient)
-
- await pool.shutdown() # on shutdown
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import os
-from threading import Lock
-from typing import Any, Dict, List, Optional, Set, Tuple
-
-from agentscope.mcp import HttpMCPConfig, MCPClient, StdioMCPConfig
-from core.llm.mcp.manager import BareNameMCPClient, ManifestMCPClient, has_usable_schema
-
-logger = logging.getLogger(__name__)
-
-HTTP_TRANSPORTS = frozenset({"streamable_http", "sse"})
-_KB_SERVER_ID = "retrieve_dataset_content"
-
-
-def _positive_timeout(value: Any, default: float) -> float:
- try:
- parsed = float(value)
- return parsed if parsed > 0 else default
- except (TypeError, ValueError):
- return default
-
-
-def _execution_timeout(name: str, cfg: dict) -> float:
- """Resolve a hard tool-call deadline, with a tighter KB-specific default."""
- if cfg.get("execution_timeout") is not None:
- return _positive_timeout(cfg["execution_timeout"], 120.0)
- if name == _KB_SERVER_ID:
- return _positive_timeout(os.getenv("KB_MCP_EXECUTION_TIMEOUT_SECONDS"), 75.0)
- return _positive_timeout(os.getenv("MCP_TOOL_EXECUTION_TIMEOUT_SECONDS"), 120.0)
-
-
-def _http_transport_timeout(name: str, cfg: dict, execution_timeout: float) -> float:
- if cfg.get("transport_timeout") is not None:
- return _positive_timeout(cfg["transport_timeout"], 30.0)
- if name == _KB_SERVER_ID:
- # Leave a small margin beyond the MCP server's own structured timeout
- # so callers receive its tool_result instead of a transport exception.
- return execution_timeout + 5.0
- if name == "internet_search":
- # Search returns per-query timeouts after its 45-second batch deadline.
- return 50.0
- return 30.0
-
-
-def is_http_cfg(cfg: dict) -> bool:
- return cfg.get("transport") in HTTP_TRANSPORTS
-
-
-def uses_manifest_schema(cfg: dict) -> bool:
- """Return whether a config contains a usable dynamic cloud manifest.
-
- At least one tool must carry the server's real ``inputSchema``; entries known
- only by name and description are withheld when the client enumerates tools
- (see ``has_usable_schema``), so a manifest holding nothing else describes no
- callable tool at all.
- """
- tools = cfg.get("manifest_tools")
- if cfg.get("schema_source") == "plugin_manifest":
- from core.capabilities.plugins.management.contract import declared
- return bool(tools and cfg.get("origin") == "local_plugin" and all(
- declared(tool, cfg.get("gateway_plugin")) for tool in tools))
- return bool(
- cfg.get("schema_source") == "cloud_manifest"
- and str(cfg.get("gateway_invoke_url") or "").strip()
- and str(cfg.get("schema_hash") or "").strip()
- and isinstance(tools, list)
- and any(
- isinstance(tool, dict)
- and bool(str(tool.get("name") or "").strip())
- and has_usable_schema(tool)
- for tool in tools
- )
- )
-
-
-def make_client(
- name: str,
- cfg: dict,
- *,
- is_stateful: bool = True,
- client_cls: type[BareNameMCPClient] = BareNameMCPClient,
-) -> MCPClient:
- """Build an MCPClient (HTTP or stdio) from a config dict.
-
- HTTP servers may set ``is_stateful=False`` for a fresh per-request connection
- (multi-user KB). stdio must be stateful (enforced by 2.0). ``client_cls`` lets
- callers substitute a ``BareNameMCPClient`` subclass (e.g. the confirmation-gated
- client) while reusing the same URL/config construction.
- """
- execution_timeout = _execution_timeout(name, cfg)
- if is_http_cfg(cfg):
- # ⚠️ The 2.0 streamable_http MCP client does not follow 307 redirects. Many MCP
- # servers 307-redirect ``/mcp/`` (trailing slash) to ``/mcp`` → 2.0 throws
- # HTTPStatusError outright and triggers an anyio cancel-scope crash. Strip the
- # trailing slash to match the server's canonical form and avoid the redirect.
- _url = (cfg.get("url") or "").rstrip("/")
- client_kwargs = {
- "name": name,
- "is_stateful": is_stateful,
- "oauth_provider": cfg.get("oauth_provider"),
- "mcp_config": HttpMCPConfig(
- url=_url,
- headers=cfg.get("headers") or None,
- timeout=_http_transport_timeout(name, cfg, execution_timeout),
- ),
- "execution_timeout": execution_timeout,
- "enable_tools": cfg.get("enable_tools"),
- }
- if cfg.get("schema_source") in ("cloud_manifest", "plugin_manifest"):
- if not uses_manifest_schema(cfg):
- raise ValueError(f"MCP '{name}' has an invalid cloud capability manifest")
- return ManifestMCPClient(
- **client_kwargs,
- manifest_tools=list(cfg["manifest_tools"]),
- gateway_invoke_url=str(cfg["gateway_invoke_url"]),
- schema_hash=str(cfg["schema_hash"]),
- gateway_transport=cfg.get("gateway_transport"),
- gateway_plugin=str(cfg.get("gateway_plugin") or ""),
- manager_cloud_available=bool(cfg.get("manager_cloud_available")),
- read_only=bool(cfg.get("read_only")),
- )
- return client_cls(
- **client_kwargs,
- )
- return client_cls(
- name=name,
- is_stateful=True,
- mcp_config=StdioMCPConfig(
- command=cfg.get("command", "python"),
- args=cfg.get("args", []),
- env=cfg.get("env") or None,
- ),
- execution_timeout=execution_timeout,
- )
-
-
-class MCPConnectionPool:
- """Singleton MCP connection pool (connection reuse only, 2.0)."""
-
- _instance: Optional[MCPConnectionPool] = None
- _instance_lock = Lock()
-
- def __init__(self) -> None:
- self._stable_clients: Dict[str, MCPClient] = {}
- self._stable_configs: Dict[str, dict] = {}
- self._stable_server_ids: Set[str] = set()
- self._initialized = False
- self._config_version: int = 0
- self._lock = asyncio.Lock()
-
- @classmethod
- def get_instance(cls) -> MCPConnectionPool:
- if cls._instance is None:
- with cls._instance_lock:
- if cls._instance is None:
- cls._instance = cls()
- return cls._instance
-
- async def initialize(self, server_configs: Optional[Dict[str, dict]] = None) -> None:
- """Connect to all stable MCP servers."""
- async with self._lock:
- if server_configs is None:
- from core.capabilities.mcp.service import McpServerConfigService
-
- server_configs = McpServerConfigService.get_instance().get_all_servers()
-
- # ⚠️ 2.0: only stdio stable servers are pooled. HTTP MCP is **not pooled** even
- # when marked is_stable — the 2.0 stateful HTTP client is task-bound, and reusing
- # it across request tasks crashes the cancel scope; HTTP always goes per-request
- # with is_stateful=False (see agent_factory._connect_http).
- self._stable_server_ids = {
- name
- for name, cfg in server_configs.items()
- if cfg.get("is_stable", False) and not is_http_cfg(cfg)
- }
- await self._close_all_stable()
-
- stable_items = [
- (name, cfg)
- for name, cfg in server_configs.items()
- if name in self._stable_server_ids
- ]
-
- async def _connect_one(name: str, cfg: dict) -> bool:
- try:
- client = make_client(name, cfg)
- await client.connect()
- self._stable_clients[name] = client
- self._stable_configs[name] = cfg
- logger.info(
- "[mcp_pool] Connected stable server: %s (%s)",
- name,
- cfg.get("transport", "stdio"),
- )
- return True
- except Exception as exc:
- logger.warning("[mcp_pool] Failed to connect stable server '%s': %s", name, exc)
- return False
-
- results = await asyncio.gather(*(_connect_one(n, c) for n, c in stable_items))
- connected = sum(1 for ok in results if ok)
- self._initialized = True
- self._config_version += 1
- logger.info(
- "[mcp_pool] Initialized with %d/%d stable servers",
- connected,
- len(self._stable_server_ids),
- )
-
- async def reinitialize_if_config_changed(self, new_server_configs: Dict[str, dict]) -> None:
- new_stable_ids = {
- name
- for name, cfg in new_server_configs.items()
- if cfg.get("is_stable", False) and not is_http_cfg(cfg)
- }
- changed = new_stable_ids != self._stable_server_ids
- if not changed:
- for name in new_stable_ids:
- if self._stable_configs.get(name) != new_server_configs.get(name):
- changed = True
- break
- if changed:
- logger.info("[mcp_pool] Config change detected, reinitializing stable connections")
- await self.initialize(new_server_configs)
-
- @property
- def has_cached_tools(self) -> bool:
- # No tool-func cache in 2.0; property kept for caller compatibility (means stable servers are connected).
- return bool(self._stable_clients)
-
- async def get_request_clients(
- self,
- enabled_keys: List[str],
- per_request_servers_cfg: Optional[Dict[str, dict]] = None,
- ) -> Tuple[List[MCPClient], List[MCPClient]]:
- """Return the connected MCPClient list needed for this request + the transient list (for closing).
-
- - stable servers: reuse the already-connected client (no re-spawn).
- - per-request stdio servers: spawn fresh (concurrently).
- The caller (agent_factory) uses the returned clients in a single
- ``Toolkit(tools=, mcps=clients)``.
- """
- request_clients: List[MCPClient] = []
- transient_clients: List[MCPClient] = []
-
- # Phase 1: spawn per-request servers concurrently
- spawn_tasks: Dict[str, asyncio.Task] = {}
- for key in enabled_keys:
- if key not in self._stable_server_ids:
- cfg = (per_request_servers_cfg or {}).get(key)
- if cfg is not None:
- spawn_tasks[key] = asyncio.create_task(self._spawn_transient(key, cfg))
-
- # Phase 2: collect stable clients (already connected, reconnect if needed)
- for key in enabled_keys:
- if key not in self._stable_server_ids:
- continue
- client = self._stable_clients.get(key)
- if client is None:
- client = await self._reconnect_stable(key)
- if client is not None:
- request_clients.append(client)
-
- # Phase 3: await per-request spawns
- for key, task in spawn_tasks.items():
- try:
- client = await task
- if client is not None:
- request_clients.append(client)
- transient_clients.append(client)
- except Exception as exc:
- logger.warning("[mcp_pool] Failed per-request server '%s': %s", key, exc)
-
- return request_clients, transient_clients
-
- # Backward-compat alias: old callers took (toolkit, transient); now returns (clients, transient).
- async def build_toolkit_from_cache(
- self,
- enabled_keys: List[str],
- per_request_servers_cfg: Optional[Dict[str, dict]] = None,
- ) -> Tuple[List[MCPClient], List[MCPClient]]:
- return await self.get_request_clients(enabled_keys, per_request_servers_cfg)
-
- async def close_transient(self, transient_clients: List[MCPClient]) -> None:
- for client in transient_clients:
- try:
- await client.close()
- except Exception as exc:
- logger.debug("[mcp_pool] close transient failed: %s", exc)
-
- async def _spawn_transient(self, key: str, cfg: dict) -> Optional[MCPClient]:
- client = make_client(key, cfg)
- try:
- await client.connect()
- return client
- except Exception as exc:
- logger.warning("[mcp_pool] spawn_transient '%s' connect failed: %s", key, exc)
- try:
- await client.close()
- except Exception as fallback_error:
- from core.infra.failures import record_best_effort_failure
- record_best_effort_failure('core.llm.mcp.pool:_spawn_transient', fallback_error)
- raise
-
- async def _reconnect_stable(self, name: str) -> Optional[MCPClient]:
- cfg = self._stable_configs.get(name)
- if cfg is None:
- return None
- old = self._stable_clients.pop(name, None)
- if old is not None:
- try:
- await old.close()
- except Exception as fallback_error:
- from core.infra.failures import record_best_effort_failure
- record_best_effort_failure('core.llm.mcp.pool:_reconnect_stable', fallback_error)
- try:
- client = make_client(name, cfg)
- await client.connect()
- self._stable_clients[name] = client
- logger.info("[mcp_pool] Reconnected stable server: %s", name)
- return client
- except Exception as exc:
- logger.warning("[mcp_pool] Reconnection failed for '%s': %s", name, exc)
- return None
-
- async def _close_all_stable(self) -> None:
- for name, client in list(self._stable_clients.items()):
- try:
- await client.close()
- except Exception as exc:
- logger.warning("[mcp_pool] close failed for '%s': %s", name, exc)
- self._stable_clients.clear()
- self._stable_configs.clear()
-
- async def shutdown(self) -> None:
- async with self._lock:
- await self._close_all_stable()
- self._initialized = False
- logger.info("[mcp_pool] Shut down")
-
- @property
- def is_initialized(self) -> bool:
- return self._initialized
-
- @property
- def stable_client_count(self) -> int:
- return len(self._stable_clients)
-
- @property
- def stable_server_ids(self) -> frozenset[str]:
- return frozenset(self._stable_server_ids)
diff --git a/src/backend/core/llm/mcp/results.py b/src/backend/core/llm/mcp/results.py
new file mode 100644
index 00000000..5f5f39f2
--- /dev/null
+++ b/src/backend/core/llm/mcp/results.py
@@ -0,0 +1,67 @@
+"""Lossless MCP result metadata with explicit model-facing content conversion."""
+import json
+import jsonschema
+from agentscope.message import TextBlock, DataBlock, Base64Source
+from agentscope.tool import ToolChunk
+from agentscope.tool._response import ToolResultState
+from mcp import MCPError, types
+from mcp.types import CONNECTION_CLOSED, REQUEST_TIMEOUT
+
+
+def content_blocks(contents):
+ blocks = []
+ for item in contents:
+ if isinstance(item, types.TextContent):
+ blocks.append(TextBlock(text=item.text))
+ elif isinstance(item, (types.ImageContent, types.AudioContent)):
+ blocks.append(DataBlock(source=Base64Source(media_type=item.mime_type, data=item.data)))
+ else:
+ # Resource URIs are references, never automatically fetched as network URLs.
+ # Preserve links, embedded resources and their provenance for the model.
+ blocks.append(TextBlock(text=item.model_dump_json(by_alias=True, exclude_none=True)))
+ return blocks
+
+
+def _carried_by_text(structured, blocks):
+ """Whether a text block already holds the structured value (SDKs wrap non-object returns as {"result": ...})."""
+ for block in blocks:
+ if not isinstance(block, TextBlock):
+ continue
+ if structured == {"result": block.text}:
+ return True
+ try:
+ value = json.loads(block.text)
+ except ValueError:
+ continue
+ if value == structured or structured == {"result": value}:
+ return True
+ return False
+
+
+def tool_result(result, descriptor):
+ content = content_blocks(result.content)
+ metadata = {"mcp": result.model_dump(mode="json", by_alias=True, exclude_none=True),
+ "output_schema": descriptor.output_schema}
+ # The model needs the structured value once; skip it only when text already carries it.
+ if result.structured_content is not None and not _carried_by_text(result.structured_content, content):
+ content.append(TextBlock(text=json.dumps(result.structured_content, ensure_ascii=False,
+ separators=(",", ":"))))
+ return ToolChunk(content=content, metadata=metadata,
+ state=ToolResultState.ERROR if result.is_error else ToolResultState.RUNNING)
+
+
+def error_result(text, **metadata):
+ return ToolChunk(content=[TextBlock(text=text)], state=ToolResultState.ERROR,
+ metadata={"origin": "mcp", **metadata})
+
+
+def is_timeout(exc):
+ return isinstance(exc, TimeoutError) or (isinstance(exc, MCPError) and exc.code == REQUEST_TIMEOUT)
+
+
+def poisons_session(exc):
+ """A request-level failure leaves the session usable; a lost peer, a changed
+ schema or a cancellation does not."""
+ if isinstance(exc, MCPError):
+ return exc.code == CONNECTION_CLOSED
+ return not isinstance(exc, (TimeoutError, jsonschema.ValidationError))
diff --git a/src/backend/core/llm/mcp/session.py b/src/backend/core/llm/mcp/session.py
new file mode 100644
index 00000000..a2309797
--- /dev/null
+++ b/src/backend/core/llm/mcp/session.py
@@ -0,0 +1,147 @@
+"""One live SDK session: its owner task, discovered surface and call admission."""
+import asyncio
+import contextvars
+from contextlib import AsyncExitStack, asynccontextmanager
+from mcp import types
+from core.llm.mcp.callbacks import route
+from core.llm.mcp.client import make_client
+
+MAX_PAGES = 100
+
+
+class CallGate:
+ """Read-only calls share the session; writes run alone."""
+
+ def __init__(self):
+ self._readers = 0
+ self._writer = False
+ self._changed = asyncio.Condition()
+
+ @asynccontextmanager
+ async def _hold(self, exclusive):
+ async with self._changed:
+ if exclusive:
+ await self._changed.wait_for(lambda: not self._writer and not self._readers)
+ self._writer = True
+ else:
+ await self._changed.wait_for(lambda: not self._writer)
+ self._readers += 1
+ try:
+ yield
+ finally:
+ async with self._changed:
+ if exclusive:
+ self._writer = False
+ else:
+ self._readers -= 1
+ self._changed.notify_all()
+
+ def shared(self):
+ return self._hold(False)
+
+ def exclusive(self):
+ return self._hold(True)
+
+
+async def _pages(method, field):
+ items, cursor, seen = [], None, set()
+ for _ in range(MAX_PAGES):
+ page = await method(cursor=cursor)
+ items.extend(getattr(page, field))
+ cursor = page.next_cursor
+ if cursor is None:
+ return items
+ if cursor in seen:
+ raise ValueError("MCP pagination cursor repeated")
+ seen.add(cursor)
+ raise ValueError(f"MCP {field} listing exceeded {MAX_PAGES} pages")
+
+
+class Session:
+ """Owns one SDK client from a dedicated task, so any run's task may call through it."""
+
+ def __init__(self, name, cfg):
+ self.name, self.cfg = name, cfg
+ self.client = None
+ self.descriptors = []
+ self.context = [] # (kind, descriptor) for advertised resources, templates and prompts
+ self.calls = CallGate()
+ self.in_flight = set()
+ self.failed = False
+ self._stop = asyncio.Event()
+ self._owner = None
+
+ @property
+ def stopped(self):
+ return self._stop.is_set() or self.failed or (self._owner is not None and self._owner.done())
+
+ async def _sampling(self, context, params):
+ call = route(self)
+ if call is None:
+ return types.ErrorData(code=-32600, message="No active sampling scope")
+ return await call.dispatch("sampling", context, params)
+
+ async def _elicitation(self, context, params):
+ call = route(self)
+ if call is None:
+ return types.ElicitResult(action="decline")
+ return await call.dispatch("elicitation", context, params)
+
+ async def _discover(self, client):
+ capabilities = client.server_capabilities
+ if capabilities.tools is not None:
+ descriptors = await _pages(client.list_tools, "tools")
+ names = [d.name for d in descriptors]
+ if len(names) != len(set(names)):
+ raise ValueError("Duplicate MCP tool name")
+ self.descriptors = descriptors
+ client.freeze(descriptors)
+ listings = []
+ if capabilities.resources is not None:
+ listings += [("resource", client.list_resources, "resources"),
+ ("resource_template", client.list_resource_templates, "resource_templates")]
+ if capabilities.prompts is not None:
+ listings.append(("prompt", client.list_prompts, "prompts"))
+ found = await asyncio.gather(*(_pages(method, field) for _, method, field in listings))
+ self.context = [(kind, item) for (kind, _, _), items in zip(listings, found) for item in items]
+
+ async def _own(self, client, ready):
+ try:
+ async with AsyncExitStack() as stack:
+ await stack.enter_async_context(client)
+ self.client = client
+ await self._discover(client)
+ ready.set_result(None)
+ await self._stop.wait()
+ except BaseException as exc:
+ if not ready.done():
+ ready.set_exception(exc)
+ else:
+ raise
+
+ @classmethod
+ async def open(cls, name, cfg):
+ session = cls(name, cfg)
+ client = make_client(name, cfg, sampling_callback=session._sampling,
+ elicitation_callback=session._elicitation)
+ ready = asyncio.get_running_loop().create_future()
+ # A fresh context keeps run-scoped ContextVars out of the long-lived session task.
+ session._owner = asyncio.create_task(session._own(client, ready), name=f"mcp-session:{name}",
+ context=contextvars.Context())
+ try:
+ await asyncio.wait_for(asyncio.shield(ready), timeout=float(cfg.get("discovery_timeout") or 30))
+ except BaseException:
+ session._stop.set()
+ from core.infra.task_lifecycle import cancel_and_join
+ try:
+ await cancel_and_join(session._owner)
+ finally:
+ if ready.done() and not ready.cancelled():
+ ready.exception()
+ raise
+ return session
+
+ async def close(self):
+ self._stop.set()
+ if self._owner is not None:
+ await asyncio.shield(self._owner)
diff --git a/src/backend/core/llm/mcp/tools.py b/src/backend/core/llm/mcp/tools.py
new file mode 100644
index 00000000..1f42a7e6
--- /dev/null
+++ b/src/backend/core/llm/mcp/tools.py
@@ -0,0 +1,113 @@
+"""AgentScope tool boundary over a live native MCP session."""
+import hashlib
+import json
+import math
+from agentscope.tool import ToolBase
+from agentscope.permission import PermissionBehavior, PermissionDecision
+from core.llm.mcp.deadline import InvocationBudget
+from core.llm.mcp.callbacks import CURRENT_CALL
+from core.llm.mcp.results import error_result, is_timeout, poisons_session, tool_result
+
+MAX_QUEUED_PROGRESS = 32
+
+
+def _call_key(name, arguments):
+ body = json.dumps(arguments, sort_keys=True, ensure_ascii=False, default=str)
+ return hashlib.sha256(f"{name}\0{body}".encode()).hexdigest()
+
+
+def queue_progress(queue, event):
+ """Keep only the newest event per call; the stream loop drains the queue between events."""
+ for index, queued in enumerate(queue):
+ if queued["tool_id"] == event["tool_id"]:
+ queue[index] = event
+ return
+ if len(queue) < MAX_QUEUED_PROGRESS:
+ queue.append(event)
+
+
+class SessionTool(ToolBase):
+ """A run's tool over a session; the run handle decides whether it may still call."""
+ is_mcp = True
+
+ def __init__(self, resource, timeout):
+ super().__init__()
+ self.resource, self.mcp_name, self.timeout = resource, resource.name, timeout
+
+ def _require_open(self):
+ if self.resource.closed or self.resource.session.stopped:
+ raise RuntimeError("MCP run lease is closed")
+
+ async def _invoke(self, operation, *, exclusive, write_key=None):
+ """Run one request; only a lost session is fatal, other failures become results."""
+ self._require_open()
+ resource, session = self.resource, self.resource.session
+ if write_key is not None and write_key in resource.unknown_outcomes:
+ return error_result("该写操作上次以相同参数调用时结果未知,已拒绝重复执行。请先核对服务端状态。",
+ server=self.mcp_name, outcome_unknown=True)
+ async with session.calls.exclusive() if exclusive else session.calls.shared():
+ self._require_open()
+ call = resource.interactions.open_call(session) if resource.interactions else None
+ token = CURRENT_CALL.set(call)
+ if call is not None:
+ session.in_flight.add(call)
+ try:
+ async with InvocationBudget(self.timeout) as budget:
+ if call is not None:
+ call.budget = budget
+ return await operation()
+ except BaseException as exc:
+ if poisons_session(exc):
+ session.failed = True
+ raise
+ if write_key is not None and is_timeout(exc):
+ resource.unknown_outcomes.add(write_key)
+ return error_result(
+ f"MCP 写操作未在预期内完成({type(exc).__name__}),结果未知。请核对服务端状态,不要直接重试。",
+ server=self.mcp_name, outcome_unknown=True)
+ return error_result(f"MCP 调用失败:{type(exc).__name__}: {exc}"[:2000], server=self.mcp_name)
+ finally:
+ CURRENT_CALL.reset(token)
+ if call is not None:
+ session.in_flight.discard(call)
+ await call.finish()
+
+
+class RemoteTool(SessionTool):
+ def __init__(self, resource, descriptor, timeout):
+ super().__init__(resource, timeout)
+ self.name = descriptor.name
+ self.description = descriptor.description or descriptor.name
+ self.input_schema = descriptor.input_schema
+ self.is_read_only = bool(descriptor.annotations and descriptor.annotations.read_only_hint)
+ # Read-only calls have no effects to order, so the agent may run them together.
+ self.is_concurrency_safe = self.is_read_only
+ self.descriptor = descriptor
+
+ async def check_permissions(self, *_args, **_kwargs):
+ return PermissionDecision(behavior=PermissionBehavior.ALLOW if self.is_read_only else PermissionBehavior.ASK,
+ message="MCP operation is subject to the run execution policy")
+
+ async def call(self, **kwargs):
+ from core.harness.tool_call_context import CURRENT_TOOL_CALL_ID
+ tool_call_id = CURRENT_TOOL_CALL_ID.get()
+ interactions = self.resource.interactions
+
+ async def progress(value, total, message):
+ if interactions is None or interactions.agent is None:
+ return
+ queue_progress(interactions.agent.state.pending_mcp_progress, {
+ "tool_id": tool_call_id, "name": self.name, "server_id": self.mcp_name,
+ "progress": value, "total": total, "message": str(message or "")[:500]})
+
+ async def operation():
+ result = await self.resource.session.client.call_tool(
+ self.descriptor.name, kwargs,
+ # The outer pausable budget owns execution time. A second SDK
+ # wall clock would expire during legacy server-to-client elicitation.
+ read_timeout_seconds=math.inf if interactions is not None else self.timeout,
+ progress_callback=progress)
+ return tool_result(result, self.descriptor)
+
+ return await self._invoke(operation, exclusive=not self.is_read_only,
+ write_key=None if self.is_read_only else _call_key(self.name, kwargs))
diff --git a/src/backend/core/llm/middleware/__init__.py b/src/backend/core/llm/middleware/__init__.py
index c49c9954..a22094fc 100644
--- a/src/backend/core/llm/middleware/__init__.py
+++ b/src/backend/core/llm/middleware/__init__.py
@@ -1 +1 @@
-"""middleware domain package."""
+"""Record of the AgentScope middleware chain replaced by HookBus hooks."""
diff --git a/src/backend/core/llm/ports.py b/src/backend/core/llm/ports.py
index 81fcaf45..644f6364 100644
--- a/src/backend/core/llm/ports.py
+++ b/src/backend/core/llm/ports.py
@@ -1,5 +1,6 @@
"""Execution-layer services injected into the model runtime by its host."""
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any, Callable, Protocol
@@ -17,9 +18,6 @@ def binding_error(self, message: str) -> Exception: ...
def response_error(self, message: str) -> Exception: ...
-
-
-
def register_tool_effect_runtime(factory: Callable[..., ToolEffectRuntime]) -> None:
process_state().tool_effect_factory = factory
@@ -43,11 +41,6 @@ def read(self, task_id: str, user_id: str, session_id: str) -> dict: ...
def list_owned(self, user_id: str, chat_id: str) -> list[dict]: ...
-
-
-
-
-
def register_execution_services(*, commands: CommandExecution, records: CommandRecords,
parent_snapshot: Callable[..., dict]) -> None:
process_state().commands, process_state().command_records, process_state().parent_snapshot = commands, records, parent_snapshot
@@ -71,20 +64,16 @@ def snapshot_parent_runtime(request: Any, **kwargs: Any) -> dict:
return process_state().parent_snapshot(request, **kwargs)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class PortsState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
tool_effect_factory: Callable[..., ToolEffectRuntime] | None = None
commands: CommandExecution | None = None
command_records: CommandRecords | None = None
parent_snapshot: Callable[..., dict] | None = None
-@state_cache(maxsize=1)
-def process_state() -> PortsState:
- return PortsState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = PortsState()
+
+
+def process_state() -> PortsState:
+ return _STATE
diff --git a/src/backend/core/llm/prompt_files.py b/src/backend/core/llm/prompt_files.py
index d0c2a282..98a79ab5 100644
--- a/src/backend/core/llm/prompt_files.py
+++ b/src/backend/core/llm/prompt_files.py
@@ -1,12 +1,12 @@
-"""prompt files responsibilities."""
+"""Read prompt parts from markdown files on disk."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
-from core.llm import prompt_kinds as prompt_kinds_module
-from core.llm import prompt_storage as prompt_storage_module
-from core.llm import prompt_versions_state as prompt_versions_state_module
+from core.llm import prompt_kinds
+from core.llm import prompt_storage
+from core.llm import prompt_versions_state
def backend_root() -> Path:
# src/backend
@@ -14,7 +14,7 @@ def backend_root() -> Path:
def fs_dir(kind: str) -> Path:
- spec = prompt_kinds_module.KIND_SPECS.get(kind)
+ spec = prompt_kinds.KIND_SPECS.get(kind)
if spec is None:
raise ValueError(f"unknown kind: {kind}")
if spec.root is not None:
@@ -53,15 +53,15 @@ def read_fs_parts(kind: str) -> List[Dict[str, Any]]:
"""
dirp = fs_dir(kind)
sig = fs_signature(dirp)
- with prompt_versions_state_module.process_state().fs_parts_cache_lock:
- cached = prompt_versions_state_module.process_state().fs_parts_cache.get(kind)
+ with prompt_versions_state.process_state().fs_parts_cache_lock:
+ cached = prompt_versions_state.process_state().fs_parts_cache.get(kind)
if cached is not None and cached[0] == sig:
- return prompt_storage_module.clone(cached[1])
+ return prompt_storage.clone(cached[1])
parts = build_fs_parts(kind, dirp, [name for name, _, _ in sig])
- with prompt_versions_state_module.process_state().fs_parts_cache_lock:
- prompt_versions_state_module.process_state().fs_parts_cache[kind] = (sig, parts)
- return prompt_storage_module.clone(parts)
+ with prompt_versions_state.process_state().fs_parts_cache_lock:
+ prompt_versions_state.process_state().fs_parts_cache[kind] = (sig, parts)
+ return prompt_storage.clone(parts)
def prompt_part_id(filename: str) -> str:
@@ -72,7 +72,7 @@ def prompt_part_id(filename: str) -> str:
def build_fs_parts(kind: str, dirp: Path, files: List[str]) -> List[Dict[str, Any]]:
- layout = prompt_kinds_module.KIND_SPECS[kind].layout
+ layout = prompt_kinds.KIND_SPECS[kind].layout
if layout == "single":
fname = f"{kind}.system.md"
@@ -122,13 +122,13 @@ def build_fs_parts(kind: str, dirp: Path, files: List[str]) -> List[Dict[str, An
def default_version_for_kind(kind: str, version_id: Optional[str] = None) -> Dict[str, Any]:
"""Build a new version dict from filesystem for a given kind."""
- spec = prompt_kinds_module.KIND_SPECS.get(kind)
+ spec = prompt_kinds.KIND_SPECS.get(kind)
if spec is None:
raise ValueError(f"unknown kind: {kind}")
now = datetime.now(timezone.utc).isoformat()
return {
- "id": version_id or prompt_versions_state_module.DEFAULT_VERSION_ID,
+ "id": version_id or prompt_versions_state.DEFAULT_VERSION_ID,
"kind": kind,
"name": spec.name,
"description": spec.desc,
diff --git a/src/backend/core/llm/prompt_kinds.py b/src/backend/core/llm/prompt_kinds.py
index e218f2f8..96782bc6 100644
--- a/src/backend/core/llm/prompt_kinds.py
+++ b/src/backend/core/llm/prompt_kinds.py
@@ -1,4 +1,4 @@
-"""prompt kinds responsibilities."""
+"""Prompt kinds: built-in specs plus admin-defined kinds."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -6,7 +6,7 @@
from typing import Dict, List, Optional
from extensions.paths import DEFAULT_AGENTS_DIR
from sqlalchemy.orm import Session
-from core.llm import prompt_storage as prompt_storage_module
+from core.llm import prompt_storage
@dataclass(frozen=True)
class KindSpec:
@@ -45,7 +45,7 @@ def list_custom_kinds(db: Optional[Session] = None) -> List[Dict[str, str]]:
versions/active 两张表,差别只在于没有文件系统兜底、可以删。
「对话模式」就是靠这个扩展的:管理员在提示词管理新开一个 tab,模式那边就能绑它。
"""
- payload = prompt_storage_module.load_payload(db)
+ payload = prompt_storage.load_payload(db)
out: List[Dict[str, str]] = []
for item in payload.get("custom_kinds") or []:
if not isinstance(item, dict):
@@ -84,7 +84,7 @@ def create_custom_kind(
raise ValueError("kind key required")
if cleaned in KIND_SPECS:
raise ValueError(f"「{cleaned}」是内置 kind,不能重复创建")
- payload = prompt_storage_module.clone(prompt_storage_module.load_payload(db))
+ payload = prompt_storage.clone(prompt_storage.load_payload(db))
customs = payload.setdefault("custom_kinds", [])
if any(str((c or {}).get("key")) == cleaned for c in customs):
raise ValueError(f"kind「{cleaned}」已存在")
@@ -106,7 +106,7 @@ def create_custom_kind(
}
)
payload.setdefault("active", {})[cleaned] = "default"
- prompt_storage_module.save_payload(payload, db=db, updated_by=updated_by)
+ prompt_storage.save_payload(payload, db=db, updated_by=updated_by)
return {"key": cleaned, "label": (label or cleaned).strip()[:60]}
@@ -118,7 +118,7 @@ def delete_custom_kind(key: str, db: Optional[Session] = None) -> bool:
"""
if key in KIND_SPECS:
raise ValueError("内置 kind 不可删除")
- payload = prompt_storage_module.clone(prompt_storage_module.load_payload(db))
+ payload = prompt_storage.clone(prompt_storage.load_payload(db))
customs = payload.get("custom_kinds") or []
remaining = [c for c in customs if str((c or {}).get("key")) != key]
if len(remaining) == len(customs):
@@ -126,7 +126,7 @@ def delete_custom_kind(key: str, db: Optional[Session] = None) -> bool:
payload["custom_kinds"] = remaining
payload["versions"] = [v for v in (payload.get("versions") or []) if v.get("kind") != key]
(payload.get("active") or {}).pop(key, None)
- prompt_storage_module.save_payload(payload, db=db)
+ prompt_storage.save_payload(payload, db=db)
return True
diff --git a/src/backend/core/llm/prompt_rendering.py b/src/backend/core/llm/prompt_rendering.py
index 5f1643e6..61c00d0d 100644
--- a/src/backend/core/llm/prompt_rendering.py
+++ b/src/backend/core/llm/prompt_rendering.py
@@ -1,21 +1,21 @@
-"""prompt rendering responsibilities."""
+"""Render the active prompt version, or the file defaults, into text."""
from __future__ import annotations
-from core.llm import prompt_versions as prompt_versions_module_
+from core.llm import prompt_versions
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
import logging
+from core.llm import prompt_files
+from core.llm import prompt_kinds
+from core.llm import prompt_versions_state
+
logger = logging.getLogger(__name__)
-from core.llm import prompt_files as prompt_files_module
-from core.llm import prompt_kinds as prompt_kinds_module
-from core.llm import prompt_versions as prompt_versions_module
-from core.llm import prompt_versions_state as prompt_versions_state_module
def render_active_prompt(kind: str, db: Optional[Session] = None) -> Optional[str]:
"""Concatenate enabled parts of the active version into a single string.
Returns None if no active version exists in DB (caller should fall back).
"""
- v = prompt_versions_module_.get_active_version(kind, db=db)
+ v = prompt_versions.get_active_version(kind, db=db)
if not v:
return None
parts = v.get("parts") or []
@@ -44,7 +44,7 @@ def render_active_prompt_part(
receive the wrong prompt.
Returns None if no active version / no match (caller falls back to fs).
"""
- v = prompt_versions_module_.get_active_version(kind, db=db)
+ v = prompt_versions.get_active_version(kind, db=db)
if not v:
return None
parts = v.get("parts") or []
@@ -90,11 +90,11 @@ def render_kind_segment(
return rendered
except Exception:
logger.debug("render %s active prompt failed", kind, exc_info=True)
- if not fs_fallback or kind not in prompt_kinds_module.KIND_SPECS:
+ if not fs_fallback or kind not in prompt_kinds.KIND_SPECS:
return ""
chunks = [
(p.get("content") or "").strip()
- for p in prompt_files_module.read_fs_parts(kind)
+ for p in prompt_files.read_fs_parts(kind)
if p.get("is_enabled", True)
and (p.get("content") or "").strip()
and (p.get("part_id") or "") not in skip
@@ -106,7 +106,7 @@ def render_active_parts(kind: str, *, db: Optional[Session], skip: set) -> Optio
"""激活版本的正文,按 ``skip`` 剔除若干段。``skip`` 为空时等同 render_active_prompt。"""
if not skip:
return render_active_prompt(kind, db=db)
- version = prompt_versions_module_.get_active_version(kind, db=db)
+ version = prompt_versions.get_active_version(kind, db=db)
if not version:
return None
chunks = [
@@ -124,14 +124,14 @@ def render_turbo_system_prompt(db: Optional[Session] = None) -> str:
极速模式不做常规装配:智能体只带检索类工具,默认提示词的工具/流程段都不适用。
"""
- return render_kind_segment("turbo", db=db) or prompt_versions_state_module.TURBO_LAST_RESORT
+ return render_kind_segment("turbo", db=db) or prompt_versions_state.TURBO_LAST_RESORT
def effective_parts(kind: str, db: Optional[Session] = None) -> List[Dict[str, Any]]:
"""Read the active version, with whole-version filesystem fallback only."""
try:
- version = prompt_versions_module_.get_active_version(kind, db=db)
+ version = prompt_versions.get_active_version(kind, db=db)
except Exception:
logger.warning("Prompt version unavailable for %s; using filesystem", kind, exc_info=True)
version = None
- return version.get("parts", []) if version is not None else prompt_files_module.read_fs_parts(kind)
+ return version.get("parts", []) if version is not None else prompt_files.read_fs_parts(kind)
diff --git a/src/backend/core/llm/prompt_storage.py b/src/backend/core/llm/prompt_storage.py
index 4e2c30a6..c646de50 100644
--- a/src/backend/core/llm/prompt_storage.py
+++ b/src/backend/core/llm/prompt_storage.py
@@ -1,12 +1,12 @@
-"""prompt storage responsibilities."""
+"""Load and cache the stored prompt pool for this worker."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, Optional
-from core.files.content.content_blocks_state import DEFAULT_PROMPT_VERSIONS, PROMPT_VERSIONS_BLOCK_ID
+from core.files.content.content_blocks_constants import DEFAULT_PROMPT_VERSIONS, PROMPT_VERSIONS_BLOCK_ID
from core.db.engine import SessionLocal
from core.db.models import ContentBlock
from sqlalchemy.orm import Session
-from core.llm import prompt_versions_state as prompt_versions_state_module
+from core.llm import prompt_versions_state
def load_payload(db: Optional[Session] = None) -> Dict[str, Any]:
"""Read the committed pool revision before reusing a worker-local payload.
@@ -27,9 +27,9 @@ def load_payload(db: Optional[Session] = None) -> Dict[str, Any]:
.first()
)
cache_key = (db.get_bind(), tuple(revision) if revision is not None else None)
- with prompt_versions_state_module.process_state().payload_cache_lock:
- if prompt_versions_state_module.process_state().payload_cache is not None and prompt_versions_state_module.process_state().payload_cache_key == cache_key:
- return clone(prompt_versions_state_module.process_state().payload_cache)
+ with prompt_versions_state.process_state().payload_cache_lock:
+ if prompt_versions_state.process_state().payload_cache is not None and prompt_versions_state.process_state().payload_cache_key == cache_key:
+ return clone(prompt_versions_state.process_state().payload_cache)
row = (
db.query(ContentBlock.updated_at, ContentBlock.payload)
@@ -44,9 +44,9 @@ def load_payload(db: Optional[Session] = None) -> Dict[str, Any]:
# A write may have committed between the two SELECTs. Cache the revision
# returned with the payload, never the earlier revision probe.
cache_key = (db.get_bind(), (row.updated_at,) if row is not None else None)
- with prompt_versions_state_module.process_state().payload_cache_lock:
- prompt_versions_state_module.process_state().payload_cache = payload
- prompt_versions_state_module.process_state().payload_cache_key = cache_key
+ with prompt_versions_state.process_state().payload_cache_lock:
+ prompt_versions_state.process_state().payload_cache = payload
+ prompt_versions_state.process_state().payload_cache_key = cache_key
return clone(payload)
finally:
if own_session:
@@ -88,9 +88,9 @@ def save_payload(
def invalidate_cache() -> None:
"""Drop this worker's cache; other workers detect the DB revision on read."""
pass # State is owned by its responsibility module.
- with prompt_versions_state_module.process_state().payload_cache_lock:
- prompt_versions_state_module.process_state().payload_cache = None
- prompt_versions_state_module.process_state().payload_cache_key = None
+ with prompt_versions_state.process_state().payload_cache_lock:
+ prompt_versions_state.process_state().payload_cache = None
+ prompt_versions_state.process_state().payload_cache_key = None
def clone(obj: Any) -> Any:
diff --git a/src/backend/core/llm/prompt_versions.py b/src/backend/core/llm/prompt_versions.py
index f8eaffee..86242df3 100644
--- a/src/backend/core/llm/prompt_versions.py
+++ b/src/backend/core/llm/prompt_versions.py
@@ -1,17 +1,18 @@
-"""prompt versions responsibilities."""
+"""Create, activate and seed prompt versions."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
import logging
+from core.llm import prompt_files
+from core.llm import prompt_kinds
+from core.llm import prompt_storage
+from core.llm import prompt_versions_state
+
logger = logging.getLogger(__name__)
-from core.llm import prompt_files as prompt_files_module
-from core.llm import prompt_kinds as prompt_kinds_module
-from core.llm import prompt_storage as prompt_storage_module
-from core.llm import prompt_versions_state as prompt_versions_state_module
def list_versions(kind: Optional[str] = None, db: Optional[Session] = None) -> List[Dict[str, Any]]:
- payload = prompt_storage_module.load_payload(db)
+ payload = prompt_storage.load_payload(db)
versions = payload.get("versions") or []
active = payload.get("active") or {}
items = []
@@ -36,13 +37,13 @@ def list_versions(kind: Optional[str] = None, db: Optional[Session] = None) -> L
def get_version(
kind: str, version_id: str, db: Optional[Session] = None
) -> Optional[Dict[str, Any]]:
- if not prompt_kinds_module.is_valid_kind(kind, db):
+ if not prompt_kinds.is_valid_kind(kind, db):
return None
- payload = prompt_storage_module.load_payload(db)
+ payload = prompt_storage.load_payload(db)
active = payload.get("active") or {}
for v in payload.get("versions") or []:
if v.get("kind") == kind and v.get("id") == version_id:
- data = prompt_storage_module.clone(v)
+ data = prompt_storage.clone(v)
data["is_active"] = active.get(kind) == version_id
return data
return None
@@ -50,15 +51,15 @@ def get_version(
def get_active_version(kind: str, db: Optional[Session] = None) -> Optional[Dict[str, Any]]:
"""Return the active version for a kind, or None if not found / DB empty."""
- if not prompt_kinds_module.is_valid_kind(kind, db):
+ if not prompt_kinds.is_valid_kind(kind, db):
return None
- payload = prompt_storage_module.load_payload(db)
+ payload = prompt_storage.load_payload(db)
active_id = (payload.get("active") or {}).get(kind)
if not active_id:
return None
for v in payload.get("versions") or []:
if v.get("kind") == kind and v.get("id") == active_id:
- return prompt_storage_module.clone(v)
+ return prompt_storage.clone(v)
return None
@@ -73,12 +74,12 @@ def upsert_version(
db: Optional[Session] = None,
updated_by: str = "admin",
) -> Dict[str, Any]:
- if not prompt_kinds_module.is_valid_kind(kind, db):
+ if not prompt_kinds.is_valid_kind(kind, db):
raise ValueError(f"invalid kind: {kind}")
if not version_id:
raise ValueError("version_id required")
- payload = prompt_storage_module.clone(prompt_storage_module.load_payload(db))
+ payload = prompt_storage.clone(prompt_storage.load_payload(db))
versions: List[Dict[str, Any]] = payload.setdefault("versions", [])
now = datetime.now(timezone.utc).isoformat()
@@ -91,7 +92,7 @@ def upsert_version(
if from_id:
src = next((v for v in versions if v.get("kind") == kind and v.get("id") == from_id), None)
if src:
- source_parts = prompt_storage_module.clone(src.get("parts") or [])
+ source_parts = prompt_storage.clone(src.get("parts") or [])
if name is None:
name = f"{src.get('name') or from_id} 副本"
if description is None:
@@ -127,14 +128,14 @@ def upsert_version(
}
versions.append(saved)
- prompt_storage_module.save_payload(payload, db=db, updated_by=updated_by)
+ prompt_storage.save_payload(payload, db=db, updated_by=updated_by)
return saved
def delete_version(kind: str, version_id: str, db: Optional[Session] = None) -> None:
- if not prompt_kinds_module.is_valid_kind(kind, db):
+ if not prompt_kinds.is_valid_kind(kind, db):
raise ValueError(f"invalid kind: {kind}")
- payload = prompt_storage_module.clone(prompt_storage_module.load_payload(db))
+ payload = prompt_storage.clone(prompt_storage.load_payload(db))
active = payload.get("active") or {}
if active.get(kind) == version_id:
raise ValueError("cannot delete the currently active version; activate another first")
@@ -144,18 +145,18 @@ def delete_version(kind: str, version_id: str, db: Optional[Session] = None) ->
if len(new_list) == len(versions):
raise KeyError(f"version not found: {kind}/{version_id}")
payload["versions"] = new_list
- prompt_storage_module.save_payload(payload, db=db)
+ prompt_storage.save_payload(payload, db=db)
def activate_version(kind: str, version_id: str, db: Optional[Session] = None) -> None:
- if not prompt_kinds_module.is_valid_kind(kind, db):
+ if not prompt_kinds.is_valid_kind(kind, db):
raise ValueError(f"invalid kind: {kind}")
- payload = prompt_storage_module.clone(prompt_storage_module.load_payload(db))
+ payload = prompt_storage.clone(prompt_storage.load_payload(db))
versions: List[Dict[str, Any]] = payload.get("versions") or []
if not any(v.get("kind") == kind and v.get("id") == version_id for v in versions):
raise KeyError(f"version not found: {kind}/{version_id}")
payload.setdefault("active", {})[kind] = version_id
- prompt_storage_module.save_payload(payload, db=db)
+ prompt_storage.save_payload(payload, db=db)
# Downstream prompt builders cache by (active_id, updated_at); poke their cache too.
try:
from prompts.prompt_runtime import invalidate_prompt_cache
@@ -193,7 +194,7 @@ def seed_from_filesystem(
db: Optional[Session] = None,
) -> Dict[str, Any]:
"""Create default versions from filesystem if not already present."""
- payload = prompt_storage_module.clone(prompt_storage_module.load_payload(db))
+ payload = prompt_storage.clone(prompt_storage.load_payload(db))
versions: List[Dict[str, Any]] = payload.setdefault("versions", [])
active: Dict[str, str] = payload.setdefault("active", {})
changed = False
@@ -266,30 +267,30 @@ def exists(kind: str, vid: str) -> bool:
# 每个内置 kind 的默认版本:没有就按文件系统建;已有就把**新增**的 md 文件补进去。
# 补这一步是因为默认版本只在首次冷启动时从文件系统建一次,之后新加的 *.system.md
# 再也进不了已建库的部署。已有 part 的正文原样保留,管理员改过的不会被覆盖。
- for kind in list(prompt_kinds_module.KIND_SPECS):
- if not exists(kind, prompt_versions_state_module.DEFAULT_VERSION_ID):
- default_row = prompt_files_module.default_version_for_kind(kind, prompt_versions_state_module.DEFAULT_VERSION_ID)
+ for kind in list(prompt_kinds.KIND_SPECS):
+ if not exists(kind, prompt_versions_state.DEFAULT_VERSION_ID):
+ default_row = prompt_files.default_version_for_kind(kind, prompt_versions_state.DEFAULT_VERSION_ID)
versions.append(default_row)
- added.append(f"{kind}/{prompt_versions_state_module.DEFAULT_VERSION_ID}")
+ added.append(f"{kind}/{prompt_versions_state.DEFAULT_VERSION_ID}")
changed = True
else:
default_row = next(
- v for v in versions if v.get("kind") == kind and v.get("id") == prompt_versions_state_module.DEFAULT_VERSION_ID
+ v for v in versions if v.get("kind") == kind and v.get("id") == prompt_versions_state.DEFAULT_VERSION_ID
)
existing_pids = {
(p.get("part_id") or "").strip() for p in default_row.get("parts") or []
}
- for fs_part in prompt_files_module.read_fs_parts(kind):
+ for fs_part in prompt_files.read_fs_parts(kind):
if fs_part["part_id"] not in existing_pids:
default_row.setdefault("parts", []).append(fs_part)
- added.append(f"{kind}/{prompt_versions_state_module.DEFAULT_VERSION_ID}:{fs_part['part_id']}")
+ added.append(f"{kind}/{prompt_versions_state.DEFAULT_VERSION_ID}:{fs_part['part_id']}")
changed = True
if kind not in active:
- active[kind] = prompt_versions_state_module.DEFAULT_VERSION_ID
+ active[kind] = prompt_versions_state.DEFAULT_VERSION_ID
changed = True
if added or changed:
- prompt_storage_module.save_payload(payload, db=db, updated_by="system_seed")
+ prompt_storage.save_payload(payload, db=db, updated_by="system_seed")
if added:
logger.info("[prompt_version_service] seeded: %s", ", ".join(added))
return {"added": added, "active": active}
diff --git a/src/backend/core/llm/prompt_versions_state.py b/src/backend/core/llm/prompt_versions_state.py
index 263814c8..3a529ffa 100644
--- a/src/backend/core/llm/prompt_versions_state.py
+++ b/src/backend/core/llm/prompt_versions_state.py
@@ -1,26 +1,12 @@
-"""prompt versions state responsibilities."""
+"""Default version id and caches for prompt versions."""
from __future__ import annotations
+from dataclasses import dataclass, field
from threading import Lock
from typing import Any, Dict, Optional
DEFAULT_VERSION_ID = "default"
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
TURBO_LAST_RESORT = (
"你是极速查询助手。收到问题后,最多发起 1-2 轮工具调用(同一轮内可并行"
"调用多个检索工具),拿到结果立即用中文简洁作答,并标注信息来源。"
@@ -28,21 +14,17 @@
)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
-class PromptVersionsStateState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
+@dataclass
+class PromptVersionsState:
payload_cache: Optional[Dict[str, Any]] = None
payload_cache_key: Optional[tuple] = None
- payload_cache_lock: object = state_field(default_factory=lambda: Lock())
- fs_parts_cache: Dict[str, tuple] = state_field(default_factory=lambda: {})
- fs_parts_cache_lock: object = state_field(default_factory=lambda: Lock())
+ payload_cache_lock: object = field(default_factory=lambda: Lock())
+ fs_parts_cache: Dict[str, tuple] = field(default_factory=lambda: {})
+ fs_parts_cache_lock: object = field(default_factory=lambda: Lock())
+
+
+_STATE = PromptVersionsState()
-@state_cache(maxsize=1)
-def process_state() -> PromptVersionsStateState:
- return PromptVersionsStateState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+def process_state() -> PromptVersionsState:
+ return _STATE
diff --git a/src/backend/core/llm/runtime/__init__.py b/src/backend/core/llm/runtime/__init__.py
index 8f3f4567..f2f8736a 100644
--- a/src/backend/core/llm/runtime/__init__.py
+++ b/src/backend/core/llm/runtime/__init__.py
@@ -1 +1 @@
-"""runtime domain package."""
+"""The AgentScope execution boundary: streaming, hooks, state and tool adapters."""
diff --git a/src/backend/core/llm/runtime/agentscope_hook_adapter.py b/src/backend/core/llm/runtime/agentscope_hook_adapter.py
index f945d065..25576bac 100644
--- a/src/backend/core/llm/runtime/agentscope_hook_adapter.py
+++ b/src/backend/core/llm/runtime/agentscope_hook_adapter.py
@@ -189,7 +189,7 @@ async def _observe_event(self, event: Any, run_id: str) -> None:
invocation = adapt_agentscope_event(event, run_id=run_id)
if invocation is None:
return
- await self.bus.event_sink.append(
+ await self.bus.event_sink.publish(
Event.create(
run_id=run_id,
event_type="framework_invocation",
diff --git a/src/backend/core/llm/runtime/context_hook_adapter.py b/src/backend/core/llm/runtime/context_hook_adapter.py
index bd682fec..9989a268 100644
--- a/src/backend/core/llm/runtime/context_hook_adapter.py
+++ b/src/backend/core/llm/runtime/context_hook_adapter.py
@@ -1,7 +1,7 @@
"""Translate neutral context additions at the framework boundary."""
from core.harness.events import thaw_value
from core.llm.context.items import ContextItem
-from core.llm.context.ir_state import POLICY_HEAD_TAIL
+from core.llm.context.ir_constants import POLICY_HEAD_TAIL
from core.llm.context.rendering import append_state_context_item, next_context_sequence
from core.llm.runtime.turn_state import reset_artifact_read_state, reset_pin_hint_state
diff --git a/src/backend/core/llm/runtime/delta_coalescer.py b/src/backend/core/llm/runtime/delta_coalescer.py
new file mode 100644
index 00000000..ed6365b7
--- /dev/null
+++ b/src/backend/core/llm/runtime/delta_coalescer.py
@@ -0,0 +1,106 @@
+"""Merge consecutive streaming deltas so every event downstream is worth a write.
+
+A model streams answer text, thinking and tool arguments a few characters at a
+time, and each event the stream yields becomes one durable run-event append
+(several Redis round trips). :class:`DeltaCoalescer` sits between the event
+mapper and the consumer and merges a run of same-stream deltas into one event:
+
+* a delta goes out at once when nothing was flushed for
+ ``DELTA_FLUSH_INTERVAL_S`` (so the first token is never delayed), otherwise
+ it waits in the buffer until that interval has passed or
+ ``DELTA_FLUSH_CHARS`` have accumulated (see :mod:`streaming_constants`);
+* only one stream is buffered at a time — a delta for a different stream, or
+ any other event (tool start/end, results, errors, the end of the stream),
+ flushes the buffer first, so the merged stream is the original stream in the
+ original order with adjacent same-stream deltas concatenated;
+* :meth:`deadline` tells a consumer that waits on its source with a timeout
+ when the buffered tail is due, so text never sits behind a silent model.
+"""
+
+from __future__ import annotations
+
+import time
+from typing import Any, Hashable, List, Optional, Tuple
+
+from core.llm.runtime import streaming_constants
+
+Event = Tuple[str, Any]
+
+
+def _stream_key(kind: str, payload: Any) -> Optional[Hashable]:
+ """Which delta stream an event belongs to, or None when it is not a delta."""
+ if kind in ("text_delta", "thinking_delta"):
+ return kind
+ if kind == "tool_call_delta":
+ return (kind, payload.get("id"))
+ return None
+
+
+def _merge(kind: str, first: Any, text: str) -> Any:
+ if kind == "tool_call_delta":
+ return {**first, "delta": text}
+ return text
+
+
+def _text(kind: str, payload: Any) -> str:
+ return (payload.get("delta") if kind == "tool_call_delta" else payload) or ""
+
+
+class DeltaCoalescer:
+ def __init__(self) -> None:
+ self._key: Optional[Hashable] = None
+ self._kind = ""
+ self._first: Any = None
+ self._parts: List[str] = []
+ self._size = 0
+ self._last_flush = float("-inf")
+
+ def feed(self, event: Event, now: Optional[float] = None) -> List[Event]:
+ """Take one mapped event; return the events to deliver now, in order."""
+ kind, payload = event
+ key = _stream_key(kind, payload)
+ if key is None:
+ return [*self.drain(now), event]
+ text = _text(kind, payload)
+ if not text:
+ return []
+ now = time.monotonic() if now is None else now
+ out = self.drain(now) if key != self._key else []
+ if self._key is None:
+ self._key, self._kind, self._first = key, kind, payload
+ self._parts.append(text)
+ self._size += len(text)
+ if (
+ self._size >= streaming_constants.DELTA_FLUSH_CHARS
+ or now - self._last_flush >= streaming_constants.DELTA_FLUSH_INTERVAL_S
+ ):
+ out.extend(self.drain(now))
+ return out
+
+ def drain(self, now: Optional[float] = None) -> List[Event]:
+ """Flush whatever is buffered."""
+ if self._key is None:
+ return []
+ event = (self._kind, _merge(self._kind, self._first, "".join(self._parts)))
+ self._key, self._kind, self._first = None, "", None
+ self._parts, self._size = [], 0
+ self._last_flush = time.monotonic() if now is None else now
+ return [event]
+
+ def deadline(self) -> Optional[float]:
+ """Monotonic time the buffered delta is due, or None when nothing waits."""
+ if self._key is None:
+ return None
+ return self._last_flush + streaming_constants.DELTA_FLUSH_INTERVAL_S
+
+ def flush_due(self, now: Optional[float] = None) -> List[Event]:
+ """Flush the buffer if its deadline has passed."""
+ now = time.monotonic() if now is None else now
+ due = self.deadline()
+ return self.drain(now) if due is not None and now >= due else []
+
+ def reset(self) -> None:
+ """Discard the buffer (the stream was abandoned) and forget the clock."""
+ self._key, self._kind, self._first = None, "", None
+ self._parts, self._size = [], 0
+ self._last_flush = float("-inf")
diff --git a/src/backend/core/llm/runtime/file_context.py b/src/backend/core/llm/runtime/file_context.py
index 44d66cf4..8f0c26fe 100644
--- a/src/backend/core/llm/runtime/file_context.py
+++ b/src/backend/core/llm/runtime/file_context.py
@@ -1,4 +1,4 @@
-"""file context responsibilities."""
+"""Turn file attachments into context: text, images and spreadsheet previews."""
from __future__ import annotations
from core.files import artifact_bytes
import base64
diff --git a/src/backend/core/llm/runtime/hooks_state.py b/src/backend/core/llm/runtime/hooks_constants.py
similarity index 88%
rename from src/backend/core/llm/runtime/hooks_state.py
rename to src/backend/core/llm/runtime/hooks_constants.py
index 3f7f4a2e..3b78f011 100644
--- a/src/backend/core/llm/runtime/hooks_state.py
+++ b/src/backend/core/llm/runtime/hooks_constants.py
@@ -1,4 +1,4 @@
-"""hooks state responsibilities."""
+"""Limits for file content injected by context hooks."""
from __future__ import annotations
import re
diff --git a/src/backend/core/llm/runtime/manifest_agent.py b/src/backend/core/llm/runtime/manifest_agent.py
index 5a4637b6..aea1c7bb 100644
--- a/src/backend/core/llm/runtime/manifest_agent.py
+++ b/src/backend/core/llm/runtime/manifest_agent.py
@@ -6,9 +6,9 @@
import logging
from typing import Any, Callable, Optional
-from agentscope.agent import Agent
+from agentscope.agent import Agent, InjectionConfig
from core.llm.context.adapter import AgentScopeContextAdapter
-from core.llm.context.ir_state import VISIBILITY_MANIFEST_ONLY
+from core.llm.context.ir_constants import VISIBILITY_MANIFEST_ONLY
from core.llm.context.ir import ContextAssembler
from core.llm.context.content import estimate_context_tokens
from core.models.context_window import FALLBACK_CONTEXT_WINDOW, usable_context_window
@@ -23,6 +23,7 @@ class ManifestBoundAgent(Agent):
def __init__(
self, *args, context_adapter_factory=None, **kwargs
) -> None: # noqa: ANN002,ANN003
+ kwargs.setdefault("injection_config", InjectionConfig(inject_runtime_state=False))
super().__init__(*args, **kwargs)
self._context_adapter_factory = context_adapter_factory or AgentScopeContextAdapter
self._tool_reserve_memo: Optional[tuple[int, int]] = None
@@ -31,7 +32,7 @@ def __init__(
def context_adapter_factory(self): # noqa: ANN201
return self._context_adapter_factory
- async def compress_context(self, context_config=None) -> None: # noqa: ANN001
+ async def compress_context(self, context_config=None, instructions=None) -> None: # noqa: ANN001
"""Disable AgentScope's second, opaque context selector.
Durable pre-turn compaction may replace history with an explicit
@@ -40,7 +41,7 @@ async def compress_context(self, context_config=None) -> None: # noqa: ANN001
candidate decision. Running AgentScope compression here would delete
candidates before the final manifest and make exclusions unauditable.
"""
- del context_config
+ del context_config, instructions
@property
def request_context_manifest(self) -> dict[str, Any]:
diff --git a/src/backend/core/llm/runtime/model_cache.py b/src/backend/core/llm/runtime/model_cache.py
index 43594ed8..aa66cbd3 100644
--- a/src/backend/core/llm/runtime/model_cache.py
+++ b/src/backend/core/llm/runtime/model_cache.py
@@ -1,6 +1,7 @@
-"""model cache responsibilities."""
+"""Cache model instances per chat mode and invalidate them on config changes."""
from __future__ import annotations
import threading
+from dataclasses import dataclass, field
from functools import wraps
from typing import Any
@@ -118,28 +119,15 @@ def resolve_chat_mode(ctx) -> str:
return "medium" if getattr(ctx, "enable_thinking", True) else "fast"
+@dataclass
+class ModelCacheState:
+ model_cache: dict[tuple[Any, ...], Any] = field(default_factory=lambda: {})
+ cached_version: int = field(default_factory=lambda: -1)
+ model_cache_lock: object = field(default_factory=lambda: threading.RLock())
+_STATE = ModelCacheState()
-
-
-
-
-
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
-class ModelCacheState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- model_cache: dict[tuple[Any, ...], Any] = state_field(default_factory=lambda: {})
- cached_version: int = state_field(default_factory=lambda: -1)
- model_cache_lock: object = state_field(default_factory=lambda: threading.RLock())
-
-@state_cache(maxsize=1)
def process_state() -> ModelCacheState:
- return ModelCacheState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+ return _STATE
diff --git a/src/backend/core/llm/runtime/model_steps.py b/src/backend/core/llm/runtime/model_steps.py
index f220198e..3d0d8a9b 100644
--- a/src/backend/core/llm/runtime/model_steps.py
+++ b/src/backend/core/llm/runtime/model_steps.py
@@ -142,6 +142,7 @@ def record_tool_result_step(block: Any) -> Dict[str, Any]:
"id": str(data.get("id") or ""),
"name": str(data.get("name") or ""),
"output": _persistable_output(data.get("output")),
+ **({"metadata": _plain(data["metadata"])} if "mcp" in (data.get("metadata") or {}) else {}),
"state": str(data.get("state") or "success"),
}
],
diff --git a/src/backend/core/llm/runtime/project_environment.py b/src/backend/core/llm/runtime/project_environment.py
index ad69899d..30c9d24a 100644
--- a/src/backend/core/llm/runtime/project_environment.py
+++ b/src/backend/core/llm/runtime/project_environment.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from threading import Lock
from core.projects.environment.config import FIELDS
@@ -13,7 +13,6 @@
# Compare-and-store only; resource lookups never run under this lock.
-
def _snapshot(context, key, value):
with process_state().snapshot_lock:
if context.get(key) is None:
@@ -171,17 +170,13 @@ def validate_project_environment(request):
raise HTTPException(403, "项目资源权限已变更,本轮已暂停,请重新发起对话")
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class ProjectEnvironmentState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- snapshot_lock: object = state_field(default_factory=lambda: Lock())
+ snapshot_lock: object = field(default_factory=lambda: Lock())
-@state_cache(maxsize=1)
-def process_state() -> ProjectEnvironmentState:
- return ProjectEnvironmentState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = ProjectEnvironmentState()
+
+
+def process_state() -> ProjectEnvironmentState:
+ return _STATE
diff --git a/src/backend/core/llm/runtime/state.py b/src/backend/core/llm/runtime/state.py
index 479280be..bb26cf26 100644
--- a/src/backend/core/llm/runtime/state.py
+++ b/src/backend/core/llm/runtime/state.py
@@ -44,6 +44,7 @@ class AgentRuntimeState(AgentState):
# drained by the streaming adapter into ``model_step`` events. Each entry
# is one ``core.llm.runtime.model_steps`` assistant record.
pending_model_steps: List[dict] = Field(default_factory=list)
+ pending_mcp_progress: List[dict] = Field(default_factory=list)
def apply_request_context(self, context: dict, user_message_text: str) -> None:
"""Populate per-request runtime fields from the request ``context`` dict (replaces the 1.x agent._jx_context).
diff --git a/src/backend/core/llm/runtime/stream_events.py b/src/backend/core/llm/runtime/stream_events.py
index e295a582..123e0865 100644
--- a/src/backend/core/llm/runtime/stream_events.py
+++ b/src/backend/core/llm/runtime/stream_events.py
@@ -1,12 +1,10 @@
-"""stream events operations."""
+"""Map AgentScope reply events to SSE events."""
from __future__ import annotations
-from core.llm.runtime import streaming_state
from core.llm.runtime.stream_text import looks_like_tool_error as _looks_like_tool_error, strip_thinking_answer as _strip_thinking_answer
from core.harness import log_writer
import time
from datetime import datetime, timezone
from typing import Any, AsyncIterator, Dict, Optional, Tuple
-from core.harness import log_writer as log_writer
import logging
logger = logging.getLogger(__name__)
@@ -56,8 +54,6 @@ async def map_event(
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,
@@ -73,37 +69,16 @@ async def map_event(
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) >= streaming_state.TOOL_CALL_DELTA_FLUSH_CHARS
- or now - last_emit >= streaming_state.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,
- },
- )
+ yield (
+ "tool_call_delta",
+ {"name": self._tool_name_buf.get(tid, "unknown"), "id": tid, "delta": 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:
@@ -190,6 +165,7 @@ async def map_event(
"id": tid,
"content": content,
"status": state,
+ "mcp": (result_step["blocks"][0].get("metadata") or {}).get("mcp"),
},
)
return
diff --git a/src/backend/core/llm/runtime/stream_loop.py b/src/backend/core/llm/runtime/stream_loop.py
index 3ac49d0b..528e943f 100644
--- a/src/backend/core/llm/runtime/stream_loop.py
+++ b/src/backend/core/llm/runtime/stream_loop.py
@@ -1,6 +1,6 @@
-"""stream loop operations."""
+"""Drive one agent reply and yield its SSE events."""
from __future__ import annotations
-from core.llm.runtime import streaming_state
+from core.llm.runtime import streaming_constants
from core.harness import log_writer
import asyncio
import time
@@ -8,10 +8,9 @@
from agentscope.message import Msg
from core.infra.logging import LogContext
from core.llm.context.rendering import next_request_sequence, render_context_item, render_session_input
-from core.llm.context.ir_state import KIND_USER_INPUT, POLICY_NEVER, SESSION_CONTEXT_META_KEY
+from core.llm.context.ir_constants import KIND_USER_INPUT, POLICY_NEVER, SESSION_CONTEXT_META_KEY
from core.llm.context.items import ContextItem
from core.llm.runtime.message_compat import session_to_msgs
-from core.harness import log_writer as log_writer
import logging
logger = logging.getLogger(__name__)
@@ -189,26 +188,46 @@ async def _produce():
_stream_start = time.monotonic()
_first_event_logged = False
- _poll_interval = streaming_state.QUEUE_POLL_INTERVAL_S
+ _poll_interval = streaming_constants.QUEUE_POLL_INTERVAL_S
# Last model_progress emission — throttles the liveness signal emitted
# when upstream events map to nothing. Only the swallowed-event branch
# reads/updates the clock, keeping the mapped hot path free of it.
_last_progress_ts = _stream_start
+ # Every event leaves through the coalescer, so a buffered delta is always
+ # flushed before whatever follows it and the stream keeps its order.
+ deltas = self._deltas
try:
while True:
+ for progress in st.pending_mcp_progress:
+ if progress.get("tool_id") in self._pending_tool_calls:
+ for out in deltas.feed(("tool_progress", progress)):
+ yield out
+ st.pending_mcp_progress = []
for _human_event in _drain_human_interaction_events():
- yield _human_event
+ for out in deltas.feed(_human_event):
+ yield out
+ _timeout = _poll_interval
+ _due = deltas.deadline()
+ if _due is not None:
+ _timeout = min(_timeout, max(0.0, _due - time.monotonic()))
try:
- kind, payload = await asyncio.wait_for(event_q.get(), timeout=_poll_interval)
+ kind, payload = await asyncio.wait_for(event_q.get(), timeout=_timeout)
except asyncio.TimeoutError:
+ _flushed = deltas.flush_due()
+ if _flushed:
+ for out in _flushed:
+ yield out
+ continue
+ if _due is not None:
+ continue
if self._model_call_inflight:
# Model call in flight with a silent wire — count as
# liveness (throttled) so the inactivity watchdog waits
# for the model client's own timeout/retry outcome
# instead of misjudging the run as hung.
_now = time.monotonic()
- if _now - _last_progress_ts >= streaming_state.MODEL_PROGRESS_MIN_INTERVAL_S:
+ if _now - _last_progress_ts >= streaming_constants.MODEL_PROGRESS_MIN_INTERVAL_S:
_last_progress_ts = _now
yield ("model_progress", None)
continue
@@ -220,7 +239,10 @@ async def _produce():
# finishing reply cannot strand the frontend in submitted
# state waiting for user_question_resolved.
for _human_event in _drain_human_interaction_events():
- yield _human_event
+ for out in deltas.feed(_human_event):
+ yield out
+ for out in deltas.drain():
+ yield out
if kind == "err":
yield ("error", payload)
break
@@ -228,7 +250,8 @@ async def _produce():
# Subagent bypass events — passed straight through, never into _map_event.
if not _first_event_logged:
_first_event_logged = True
- yield ("subagent_event", payload)
+ for out in deltas.feed(("subagent_event", payload)):
+ yield out
continue
# kind == "ev"
payload, result_step = payload
@@ -239,10 +262,12 @@ async def _produce():
# before mapping ModelCallStart so the queued-card UI can
# switch from "waiting" to "applied" deterministically.
agent.state.steer_delivery = None
- yield ("steer_applied", dict(steer_delivery))
+ for out in deltas.feed(("steer_applied", dict(steer_delivery))):
+ yield out
reasoning_protocol = self._take_reasoning_protocol()
if reasoning_protocol is not None:
- yield ("reasoning_protocol", reasoning_protocol)
+ for out in deltas.feed(("reasoning_protocol", reasoning_protocol)):
+ yield out
_mapped_any = False
async for out in self._map_event(payload, result_step=result_step):
if not _first_event_logged:
@@ -251,17 +276,19 @@ async def _produce():
_first_event_logged = True
_mapped_any = True
yield out
- if not _mapped_any:
+ if not _mapped_any and deltas.deadline() is None:
# The model is alive (an upstream event just arrived) but
# nothing was forwarded — typical of long tool-call-arg /
# suppressed-thinking streaming. Emit a throttled liveness
# signal so the inactivity watchdog doesn't misjudge a
# healthy run as hung.
_now = time.monotonic()
- if _now - _last_progress_ts >= streaming_state.MODEL_PROGRESS_MIN_INTERVAL_S:
+ if _now - _last_progress_ts >= streaming_constants.MODEL_PROGRESS_MIN_INTERVAL_S:
_last_progress_ts = _now
yield ("model_progress", None)
except Exception as e: # noqa: BLE001
+ for out in deltas.drain():
+ yield out
yield ("error", e)
finally:
await self.stop_producer()
@@ -297,8 +324,7 @@ 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()
+ deltas.reset()
try:
_log_ctx.__exit__(None, None, None)
except Exception as fallback_error:
diff --git a/src/backend/core/llm/runtime/stream_text.py b/src/backend/core/llm/runtime/stream_text.py
index 1c00a9df..bebd8ece 100644
--- a/src/backend/core/llm/runtime/stream_text.py
+++ b/src/backend/core/llm/runtime/stream_text.py
@@ -1,8 +1,8 @@
-"""stream text responsibilities."""
+"""Split visible answer text from thinking and detect tool error text."""
from __future__ import annotations
from typing import Tuple
from core.infra.model_output import THINK_BLOCK_RE
-from core.llm.runtime import streaming_state as streaming_state_module
+from core.llm.runtime import streaming_constants
def looks_like_tool_error(content: str) -> bool:
if not content:
@@ -21,7 +21,7 @@ def looks_like_tool_error(content: str) -> bool:
return True
if "不存在或已删除" in s or "无权访问" in head:
return True
- if streaming_state_module.HTTP_ERR_RE.search(s):
+ if streaming_constants.HTTP_ERR_RE.search(s):
return True
return False
diff --git a/src/backend/core/llm/runtime/streaming.py b/src/backend/core/llm/runtime/streaming.py
index ebd8fa06..d6e0a124 100644
--- a/src/backend/core/llm/runtime/streaming.py
+++ b/src/backend/core/llm/runtime/streaming.py
@@ -1,12 +1,13 @@
-"""streaming responsibilities."""
+"""Wrap an AgentScope agent so it streams SSE events."""
from __future__ import annotations
from contextlib import aclosing
from core.llm.runtime import stream_loop
from core.llm.runtime import stream_events
+from core.llm.runtime.delta_coalescer import DeltaCoalescer
import asyncio
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
from agentscope.agent import Agent
-from agentscope.mcp import MCPClient
+from core.llm.mcp.lifetime import MCPResource
import logging
logger = logging.getLogger(__name__)
@@ -16,7 +17,7 @@ class StreamingAgent:
def __init__(
self,
agent: Agent,
- mcp_clients: List[MCPClient],
+ mcp_clients: List[MCPResource],
):
self._producer_task = None
self.agent = agent
@@ -30,9 +31,8 @@ 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] = {}
+ # Merges text / thinking / tool-argument deltas before they leave _map_event.
+ self._deltas = DeltaCoalescer()
# tool_id → accumulated result text (ToolResultTextDelta)
self._tool_result_buf: Dict[str, str] = {}
# Accumulated answer text (for dedup in the -suppression scenario)
@@ -253,9 +253,16 @@ async def stream(
async def _map_event(
self, ev: Any, *, result_step: Optional[Dict[str, Any]] = None
) -> AsyncIterator[Tuple[str, Any]]:
+ """Map one AgentScope event, with deltas merged by ``self._deltas``.
+
+ A merged delta may still be buffered when this returns; whoever drives
+ the stream flushes it through ``self._deltas`` (on its deadline, before
+ its own events, and at the end).
+ """
async with aclosing(stream_events.map_event(self, ev, result_step=result_step)) as events:
async for event in events:
- yield event
+ for out in self._deltas.feed(event):
+ yield out
async def stop_producer(self):
"""Stop actual model/tool work before closing the stream or its clients."""
@@ -271,9 +278,10 @@ async def shutdown(self):
"""Close transient (per-request) MCP clients."""
from core.llm.mcp.manager import close_clients
- await self.stop_producer()
try:
- await close_clients(self.mcp_clients)
- except Exception as exc:
- logger.debug("StreamingAgent.shutdown: close_clients error: %s", exc)
- self.mcp_clients = []
+ await self.stop_producer()
+ finally:
+ try:
+ await close_clients(self.mcp_clients)
+ finally:
+ self.mcp_clients = []
diff --git a/src/backend/core/llm/runtime/streaming_constants.py b/src/backend/core/llm/runtime/streaming_constants.py
new file mode 100644
index 00000000..d60a3272
--- /dev/null
+++ b/src/backend/core/llm/runtime/streaming_constants.py
@@ -0,0 +1,20 @@
+"""Intervals and patterns used while streaming."""
+from __future__ import annotations
+import re
+
+HTTP_ERR_RE = re.compile(r"HTTP\s*[45]\d\d")
+
+
+MODEL_PROGRESS_MIN_INTERVAL_S = 5.0
+
+
+QUEUE_POLL_INTERVAL_S = 3.0
+
+
+# Streaming deltas (answer text, thinking, tool arguments) are merged by
+# ``delta_coalescer`` into at most one event per interval, or sooner once this
+# many characters wait.
+DELTA_FLUSH_INTERVAL_S = 0.05
+
+
+DELTA_FLUSH_CHARS = 256
diff --git a/src/backend/core/llm/runtime/streaming_state.py b/src/backend/core/llm/runtime/streaming_state.py
deleted file mode 100644
index 850c3165..00000000
--- a/src/backend/core/llm/runtime/streaming_state.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""streaming state responsibilities."""
-from __future__ import annotations
-import re
-
-HTTP_ERR_RE = re.compile(r"HTTP\s*[45]\d\d")
-
-
-MODEL_PROGRESS_MIN_INTERVAL_S = 5.0
-
-
-QUEUE_POLL_INTERVAL_S = 3.0
-
-
-TOOL_CALL_DELTA_FLUSH_INTERVAL_S = 0.05
-
-
-TOOL_CALL_DELTA_FLUSH_CHARS = 256
diff --git a/src/backend/core/llm/runtime/tool_collector.py b/src/backend/core/llm/runtime/tool_collector.py
deleted file mode 100644
index 36a99610..00000000
--- a/src/backend/core/llm/runtime/tool_collector.py
+++ /dev/null
@@ -1,268 +0,0 @@
-"""ToolCollector — adapts 1.x's incremental ``toolkit.register_*`` to 2.0's one-shot Toolkit.
-
-AgentScope 2.0's ``Toolkit`` is injected once at construction time (``Toolkit(tools=,
-mcps=, skills_or_loaders=)``); it has no incremental ``register_tool_function`` /
-``register_mcp_client`` / ``register_agent_skill`` methods.
-
-To avoid rewriting the ~15 ``register_*`` in-house tool functions (they all call
-``toolkit.register_tool_function(fn, namesake_strategy=...)``), this module provides a
-**duck-typed compatible** collector: it exposes methods of the same names but internally
-just collects tools into an ``AllowedFunctionTool`` list / skill directory list.
-``agent_factory`` passes the collector to every ``register_*``, then constructs once via
-``Toolkit(tools=collector.function_tools, mcps=clients,
-skills_or_loaders=collector.skill_loaders)``.
-
-Also resolves two behavioral differences in 2.0:
- * ``FunctionTool``'s default ``check_permissions`` returns ASK -> every in-house tool
- would pop HITL. The ``AllowedFunctionTool`` subclass flips the default decision back
- to ALLOW (built-in tools like Bash keep their own dangerous-command checks, since we
- do not touch their check_permissions).
- * Tool functions return ``ToolChunk`` (tool.py already aliases ToolResponse to ToolChunk).
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import os
-from dataclasses import replace
-from functools import lru_cache
-from pathlib import Path
-
-import frontmatter
-from typing import Any, Callable, List
-
-from agentscope.permission import PermissionBehavior, PermissionDecision
-from agentscope.tool import FunctionTool
-from agentscope.skill import LocalSkillLoader, SkillLoaderBase, Skill
-from core.sandbox.permissions.dispatch_models import ToolPermissionSpec
-from core.sandbox.permissions.tool_specs import builtin_tool_permission
-
-from core.capabilities.skills.config import model_facing_skill_dir
-
-logger = logging.getLogger(__name__)
-
-
-class AllowedFunctionTool(FunctionTool):
- """In-house Python tool: allowed by default (overrides 2.0 FunctionTool's default ASK)."""
-
- async def check_permissions(self, *args: Any, **kwargs: Any) -> PermissionDecision:
- return PermissionDecision(
- behavior=PermissionBehavior.ALLOW,
- message="HugAgentOS self-developed tool (auto-allowed).",
- )
-
-
-def _parse_runtime_skill(text):
- content = frontmatter.loads(text)
- name, description = content.get("name"), content.get("description")
- if not name or not description:
- return None
- return str(name), str(description), content.content
-
-
-_cached_runtime_skill = lru_cache(maxsize=256)(_parse_runtime_skill)
-
-
-class RuntimeNamedSkillLoader(SkillLoaderBase):
- """Read frozen files and expose a deployment-appropriate directory."""
-
- def __init__(self, directory: str, runtime_name: str, capability_run=None) -> None:
- self.directory = directory
- self.runtime_name = runtime_name
- self.capability_run = capability_run
- self._physical_loader = LocalSkillLoader(directory)
-
- def _list_desktop_skill(self):
- from core.harness.registry.capability_runtime import validate
- from core.harness.capability_errors import CapabilityError
-
- # Keep authorization outside the parse-error handling: it must fail the
- # whole enumeration, never quietly omit a revoked or modified skill.
- try:
- validate(self.capability_run, only_skill=self.runtime_name)
- except (OSError, CapabilityError):
- # 被撤销或改动过的单个技能只从枚举里消失,不牵连这一轮其它工具。
- return []
- try:
- entry = Path(self.directory) / "SKILL.md"
- updated_at = entry.stat().st_mtime
- text = entry.read_text(encoding="utf-8")
- parsed = (
- _cached_runtime_skill(text) if len(text) <= 262144 else _parse_runtime_skill(text)
- )
- if parsed is None:
- return []
- _, description, markdown = parsed
- return [
- Skill(
- name=self.runtime_name,
- description=description,
- dir=model_facing_skill_dir(self.runtime_name, self.directory),
- markdown=markdown,
- updated_at=updated_at,
- )
- ]
- except Exception as exc:
- logger.warning("Failed to load skill metadata: %s", type(exc).__name__)
- return []
-
- async def list_skills(self):
- # AgentScope's general filesystem scanner crosses the executor boundary
- # eight times for one known file. Batch that I/O in one worker while
- # preserving its parser, fields and sandbox alias.
- if os.name == "nt" and self.capability_run is not None:
- return await asyncio.to_thread(self._list_desktop_skill)
- from core.harness.registry.capability_runtime import validate
-
- if self.capability_run is not None:
- # Re-check only this loader's own skill (authorization + fresh hash),
- # not the whole run. N loaders each re-hashing all N skills — twice
- # per assembly — was O(N²) filesystem work and the dominant cost of
- # desktop agent setup; scoping to one skill makes it O(N).
- from core.harness.capability_errors import CapabilityError
-
- try:
- await asyncio.to_thread(validate, self.capability_run, only_skill=self.runtime_name)
- except (CapabilityError, OSError):
- return []
- physical = await self._physical_loader.list_skills()
- return [
- replace(skill, name=self.runtime_name, dir=model_facing_skill_dir(self.runtime_name, self.directory))
- for skill in physical
- ]
-
-
-class _ParallelRuntimeSkillLoader(SkillLoaderBase):
- """Enumerate independent desktop loaders with bounded I/O concurrency."""
-
- def __init__(self, loaders):
- self._loaders = tuple(loaders)
-
- async def list_skills(self):
- limit = asyncio.Semaphore(4)
-
- async def load(loader):
- async with limit:
- return await loader.list_skills()
-
- results = await asyncio.gather(
- *(load(loader) for loader in self._loaders), return_exceptions=True
- )
- skills = []
- for result in results:
- if isinstance(result, BaseException):
- raise result
- skills.extend(result)
- return skills
-
-
-class ToolCollector:
- """Duck-type compatible with the 1.x Toolkit registration interface; actually only collects tools/skills for the 2.0 Toolkit."""
-
- def __init__(self) -> None:
- # name -> AllowedFunctionTool (deduped by name, supports override/skip)
- self._tools: dict[str, AllowedFunctionTool] = {}
- self._permission_specs: dict[str, ToolPermissionSpec] = {}
- self._tool_order: List[str] = []
- self._skill_loaders: List[Any] = []
-
- # ── 1.x compatibility interface ─────────────────────────────────────
- def register_tool_function(
- self,
- func: Callable[..., Any],
- *,
- func_description: str | None = None,
- namesake_strategy: str = "override",
- permission: ToolPermissionSpec | None = None,
- **_ignored: Any,
- ) -> None:
- """Collect a tool function as an AllowedFunctionTool.
-
- namesake_strategy:
- - "override" (default): same name replaces the existing one
- - "skip": same name keeps the existing one and discards the new one
- """
- name = getattr(func, "__name__", None) or "tool"
- if name in self._tools:
- if namesake_strategy == "skip":
- return
- # override: replace, but keep the original ordering position
- permission_spec = permission or builtin_tool_permission(name)
- try:
- ft = AllowedFunctionTool(
- func,
- name=name,
- description=func_description,
- )
- except Exception as exc: # noqa: BLE001
- logger.warning("[tool_collector] 构造 FunctionTool '%s' 失败: %s", name, exc)
- return
- if name not in self._tools:
- self._tool_order.append(name)
- self._tools[name] = ft
- if permission_spec is not None:
- self._permission_specs[name] = permission_spec
- else:
- self._permission_specs.pop(name, None)
-
- def register_tool_permission(self, name: str, spec: ToolPermissionSpec) -> None:
- """Attach developer-owned MCP policy to the same dispatch registry."""
- previous = self._permission_specs.get(name)
- if previous is not None and previous.key != spec.key:
- raise ValueError(f"Conflicting permission declaration for {name!r}")
- self._permission_specs[name] = spec
-
- def register_agent_skill(
- self, skill_dir: Any, *, runtime_name: str | None = None, capability_run=None
- ) -> None:
- """Keep physical reads separate from a desktop skill's runtime identity."""
- if not skill_dir:
- return
- if runtime_name is not None:
- if not any(
- isinstance(item, RuntimeNamedSkillLoader)
- and item.directory == skill_dir
- and item.runtime_name == runtime_name
- for item in self._skill_loaders
- ):
- self._skill_loaders.append(
- RuntimeNamedSkillLoader(skill_dir, runtime_name, capability_run)
- )
- else:
- self.register_agent_skill(skill_dir, runtime_name=Path(skill_dir).name)
-
- def register_mcp_client(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover
- # 2.0 goes through Toolkit(mcps=); this should never be called — kept as an empty fallback against legacy call paths.
- logger.warning(
- "[tool_collector] register_mcp_client 被调用但已忽略(2.0 经 Toolkit(mcps=))。"
- )
-
- # ── Result accessors for agent_factory ───────────────────────────────
- def get_tool(self, name: str) -> AllowedFunctionTool | None:
- """Get a collected AllowedFunctionTool by name (for tests/introspection; `._func`
- is the original callable, `.input_schema` is the JSON schema)."""
- return self._tools.get(name)
-
- @property
- def function_tools(self) -> List[AllowedFunctionTool]:
- return [self._tools[n] for n in self._tool_order if n in self._tools]
-
- @property
- def permission_specs(self) -> dict[str, ToolPermissionSpec]:
- """Detached declaration map consumed by the run-scoped gateway."""
- return dict(self._permission_specs)
-
- @property
- def skill_loaders(self) -> List[Any]:
- loaders = list(self._skill_loaders)
- if (
- os.name == "nt"
- and len(loaders) >= 8
- and all(isinstance(loader, RuntimeNamedSkillLoader) for loader in loaders)
- ):
- return [_ParallelRuntimeSkillLoader(loaders)]
- return loaders
-
-
-__all__ = ["AllowedFunctionTool", "ToolCollector"]
diff --git a/src/backend/core/llm/runtime/tool_media_store.py b/src/backend/core/llm/runtime/tool_media_store.py
index e44e6b7e..a1f12fc3 100644
--- a/src/backend/core/llm/runtime/tool_media_store.py
+++ b/src/backend/core/llm/runtime/tool_media_store.py
@@ -36,6 +36,7 @@
import hashlib
import logging
from collections import OrderedDict
+from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from core.db.engine import SessionLocal
@@ -56,7 +57,6 @@
_cache: "OrderedDict[str, str]" = OrderedDict()
-
def _cache_get(digest: str) -> Optional[str]:
data = _cache.get(digest)
if data is not None:
@@ -239,17 +239,13 @@ def reset_cache() -> None:
process_state().cache_bytes = 0
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class ToolMediaStoreState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
cache_bytes: object = 0
-@state_cache(maxsize=1)
-def process_state() -> ToolMediaStoreState:
- return ToolMediaStoreState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = ToolMediaStoreState()
+
+
+def process_state() -> ToolMediaStoreState:
+ return _STATE
diff --git a/src/backend/core/llm/runtime/turn_state.py b/src/backend/core/llm/runtime/turn_state.py
index 5855e825..f3b69d78 100644
--- a/src/backend/core/llm/runtime/turn_state.py
+++ b/src/backend/core/llm/runtime/turn_state.py
@@ -1,4 +1,4 @@
-"""turn state responsibilities."""
+"""Per-turn trackers for artifact reads and pin hints."""
from __future__ import annotations
from contextvars import ContextVar
from typing import Any, Dict, Optional
diff --git a/src/backend/core/llm/skill_selection.py b/src/backend/core/llm/skill_selection.py
index 29ad5594..1a75dba0 100644
--- a/src/backend/core/llm/skill_selection.py
+++ b/src/backend/core/llm/skill_selection.py
@@ -13,7 +13,7 @@
from core.capabilities.skills.loader_factory import get_skill_loader
from core.capabilities.skills.registry import AgentSkillMetadata
from core.llm.context.adapter import AgentScopeContextAdapter
-from core.llm.context.ir_state import KIND_SYSTEM_RULE, KIND_USER_INPUT, POLICY_HEAD_TAIL, POLICY_NEVER
+from core.llm.context.ir_constants import KIND_SYSTEM_RULE, KIND_USER_INPUT, POLICY_HEAD_TAIL, POLICY_NEVER
from core.llm.context.ir import ContextAssembler
from core.llm.context.items import make_text_context_item
from core.models.context_window import FALLBACK_CONTEXT_WINDOW, usable_context_window
diff --git a/src/backend/core/llm/subagents/__init__.py b/src/backend/core/llm/subagents/__init__.py
index e1127259..015345e9 100644
--- a/src/backend/core/llm/subagents/__init__.py
+++ b/src/backend/core/llm/subagents/__init__.py
@@ -1 +1 @@
-"""subagents domain package."""
+"""Sub-agents: the call tool, their sessions, prompts and streamed execution."""
diff --git a/src/backend/core/llm/subagents/execution.py b/src/backend/core/llm/subagents/execution.py
index 639f0789..628270ca 100644
--- a/src/backend/core/llm/subagents/execution.py
+++ b/src/backend/core/llm/subagents/execution.py
@@ -10,22 +10,15 @@
import logging
import threading
import time
-import uuid
-from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, List, Optional, Tuple
-from agentscope.message import TextBlock
-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 core.infra.task_lifecycle import settle_task
-from core.llm.subagents import sessions as subagent_sessions
from core.llm.context.rendering import next_request_sequence, render_context_item
-from core.llm.context.ir_state import KIND_USER_INPUT, POLICY_NEVER
+from core.llm.context.ir_constants import KIND_USER_INPUT, POLICY_NEVER
from core.llm.context.items import make_text_context_item
from core.llm.tools._common import resolve_sandbox_session
-from core.harness import log_writer as log_writer
+from core.db.threads import run_in_session
logger = logging.getLogger(__name__)
@@ -210,7 +203,7 @@ def _run_subagent_in_thread(
or they never reach the user-visible assistant message.
When ``emit`` is provided, the sub-agent is consumed **in streaming mode**
- (``agent._reply``, event by event) and every thinking/tool_call/tool_result/content
+ (``agent.reply_stream``, event by event) and every thinking/tool_call/tool_result/content
sub-step is bypass-forwarded back to the main SSE stream via ``emit``; with ``emit``
None the behavior matches the old implementation (final text only). The returned text
is always taken from the reply's **final Msg** — fully equivalent to the old
@@ -219,7 +212,6 @@ def _run_subagent_in_thread(
async def _inner() -> str:
from agentscope.message import Msg
- from core.db.engine import SessionLocal
from core.capabilities.agents.builtin import build_builtin_runtime_profile, get_builtin_subagent
from core.llm.factory import create_agent_executor
from core.llm.mcp.manager import close_clients
@@ -231,7 +223,7 @@ async def _inner() -> str:
if builtin_spec is not None:
user_agent = build_builtin_runtime_profile(builtin_spec, parent_runtime)
else:
- with SessionLocal() as db:
+ def _load_user_agent(db):
svc = UserAgentService(db)
user_agent = svc.get_raw_by_id(agent_id, user_id=current_user_id)
_ = user_agent.mcp_server_ids, user_agent.skill_ids, user_agent.kb_ids
@@ -242,6 +234,8 @@ async def _inner() -> str:
user_agent.max_tokens,
user_agent.timeout,
)
+ return user_agent
+ user_agent = await run_in_session(_load_user_agent)
# A conversation is the only sandbox boundary. Every built-in and user-defined
# child agent inherits the parent's sandbox session and therefore sees the same
@@ -324,15 +318,13 @@ async def _inner() -> str:
extract_messages_from_context(agent.state.context),
)
- # Streaming path: consume _reply directly (reply_stream would discard the final
- # Msg, which we need as the authoritative return text). Non-Msg events are mapped
- # to sub-steps and bypass-forwarded via emit.
+ # Public stream includes the final message for child result extraction.
final_msg = None
mapper = _SubMapper()
stream_mapper = StreamingAgent(agent, mcp_clients)
stream_mapper._enable_thinking = True
_last_progress = time.monotonic()
- async for chunk in agent._reply(inputs=user_msg):
+ async for chunk in agent.reply_stream(inputs=user_msg, yield_final_msg=True):
if isinstance(chunk, Msg):
final_msg = chunk
continue
@@ -351,6 +343,9 @@ async def _inner() -> str:
Exception
): # noqa: BLE001 — bypass mapping must never take down the sub-agent
logger.debug("subagent event map failed (ignored)", exc_info=True)
+ for kind, payload in stream_mapper._deltas.drain():
+ for sub in mapper.feed(kind, payload):
+ emit(sub)
response_text = (final_msg.get_text_content() if final_msg else "") or ""
return (
True,
diff --git a/src/backend/core/llm/subagents/stream.py b/src/backend/core/llm/subagents/stream.py
index 89ab4f91..c21dd513 100644
--- a/src/backend/core/llm/subagents/stream.py
+++ b/src/backend/core/llm/subagents/stream.py
@@ -29,7 +29,7 @@
import asyncio
import logging
import threading
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
@@ -46,10 +46,6 @@ class _Bus:
queue: asyncio.Queue
-
-
-
-
def _key(chat_id: Optional[str]) -> str:
return str(chat_id) if chat_id else _NO_CHAT
@@ -96,18 +92,14 @@ def is_active(chat_id: Optional[str]) -> bool:
return _key(chat_id) in process_state().BUSES
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class StreamState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- LOCK: object = state_field(default_factory=lambda: threading.RLock())
- BUSES: Dict[str, _Bus] = state_field(default_factory=lambda: {})
+ LOCK: object = field(default_factory=lambda: threading.RLock())
+ BUSES: Dict[str, _Bus] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> StreamState:
- return StreamState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = StreamState()
+
+
+def process_state() -> StreamState:
+ return _STATE
diff --git a/src/backend/core/llm/subagents/tool.py b/src/backend/core/llm/subagents/tool.py
index a4427f07..2a14c825 100644
--- a/src/backend/core/llm/subagents/tool.py
+++ b/src/backend/core/llm/subagents/tool.py
@@ -5,33 +5,29 @@
"""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
from core.harness import log_writer
import asyncio
import logging
-import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
-from typing import Any, Callable, Dict, List, Optional, Tuple
+from typing import Any, Callable, Dict, List, Optional
from agentscope.message import TextBlock
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
+# AgentScope 2.0: tool functions must return ToolChunk (call_tool rejects ToolChunk).
+from agentscope.tool._response import ToolChunk
from core.infra.task_lifecycle import settle_task
from core.llm.subagents import sessions as subagent_sessions
-from core.llm.context.rendering import next_request_sequence, render_context_item
-from core.llm.context.ir_state import KIND_USER_INPUT, POLICY_NEVER
-from core.llm.context.items import make_text_context_item
-from core.llm.tools._common import resolve_sandbox_session
-from core.harness import log_writer as log_writer
-logger = logging.getLogger(__name__)
from core.llm.subagents.execution import _ChildExecution, _map_subagent_event, _run_subagent_in_thread, _shared_ontology_runtime, _SubMapper
-from core.llm.subagents.prompts import _collapse_ids, _get_tools_desc as get_tools_desc, build_explicit_subagent_command_hint, build_subagent_mention_hint, build_subagent_prompt_section
+from core.llm.subagents.prompts import _get_tools_desc as get_tools_desc, build_explicit_subagent_command_hint, build_subagent_mention_hint, build_subagent_prompt_section
+
+logger = logging.getLogger(__name__)
_subagent_pool = ThreadPoolExecutor(max_workers=16, thread_name_prefix="subagent")
@@ -79,7 +75,7 @@ async def call_subagent(
task: str,
context_summary: str = "",
resume_session_id: str = "",
- ) -> ToolResponse:
+ ) -> ToolChunk:
"""调用子智能体执行专业任务。子智能体拥有独立的工具和专业知识。
需要并行调用多个子智能体时,在同一轮回复中生成多个 call_subagent 调用,
@@ -99,12 +95,12 @@ async def call_subagent(
完整上下文(读过的文件、跑过的命令、犯过的错)上继续;留空则全新启动。
Returns:
- `ToolResponse`:
+ `ToolChunk`:
子智能体的执行结果,末尾附带本次的续跑句柄。
结果对用户不可见,你需要汇总后呈现给用户。
"""
if agent_id not in agent_map:
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -126,7 +122,7 @@ async def call_subagent(
user_id=current_user_id,
)
if resume_messages is None:
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -254,7 +250,7 @@ async def record_terminal():
child_runtime = _child_capability_runtime(parent_runtime, agent_id)
except IntegrityFailed:
await _finish("failed", error="missing durable capability scope")
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text", text="子智能体缺少可恢复的持久工具调用身份,已拒绝执行。"
@@ -302,7 +298,7 @@ async def record_terminal():
if not ok:
await _finish("failed", output=text, error=text)
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -335,7 +331,7 @@ async def record_terminal():
if saved
else ""
)
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -370,7 +366,7 @@ async def finish_cancelled():
if _emit is not None:
_emit({"sub_type": "error", "error": str(e)[:200]})
await _finish("failed", error=str(e)[:200])
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -379,4 +375,4 @@ async def finish_cancelled():
]
)
- toolkit.register_tool_function(call_subagent, namesake_strategy="skip")
+ toolkit.append(function_tool(call_subagent))
diff --git a/src/backend/core/llm/tools/__init__.py b/src/backend/core/llm/tools/__init__.py
index efbd7fd9..9adabae7 100644
--- a/src/backend/core/llm/tools/__init__.py
+++ b/src/backend/core/llm/tools/__init__.py
@@ -17,9 +17,8 @@
from .read_tool import register_read
from .write_tool import register_write
-# Sandbox / skill / myspace / artifact tools (relocated from the former
-# singular ``core.llm.tool`` module). Surfaced here so callers import the whole
-# tool family from one place: ``from core.llm.tools import register_bash, ...``.
+# Sandbox / skill / myspace / artifact tools, surfaced here so callers import
+# the whole tool family from one place: ``from core.llm.tools import register_bash, ...``.
from .sandbox_tool import (
register_bash,
register_sandbox_get_artifact,
diff --git a/src/backend/core/llm/tools/_common.py b/src/backend/core/llm/tools/_common.py
index edb68753..54bf1036 100644
--- a/src/backend/core/llm/tools/_common.py
+++ b/src/backend/core/llm/tools/_common.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from core.files.artifacts.refs import require_artifact_storage_key
import asyncio
import base64
@@ -14,8 +13,8 @@
from agentscope.message import Base64Source, DataBlock, TextBlock
-# AgentScope 2.0: tool functions must return ToolChunk; aliased (its fields are a superset of ToolResponse).
-from agentscope.tool._response import ToolChunk as ToolResponse
+# AgentScope 2.0: tool functions must return ToolChunk; aliased (its fields are a superset of ToolChunk).
+from agentscope.tool._response import ToolChunk
from core.projects.edition import ProjectScope
logger = logging.getLogger(__name__)
@@ -23,7 +22,7 @@
async def resp_image(
image_bytes: bytes, mime_type: str, *, name: str, meta: Optional[dict[str, Any]] = None
-) -> ToolResponse:
+) -> ToolChunk:
"""图片原样交给多模态主模型:一段 JSON 元信息 + 一个图片数据块。
AgentScope 会把工具结果里的 DataBlock 提升成下一轮的 image 消息;端点不收图时
@@ -32,7 +31,7 @@ async def resp_image(
"""
payload = {"type": "image", "mime_type": mime_type, "size": len(image_bytes), **(meta or {})}
data = await asyncio.to_thread(lambda: base64.b64encode(image_bytes).decode("ascii"))
- return ToolResponse(
+ return ToolChunk(
content=[
*resp_json(payload).content,
DataBlock(
@@ -44,13 +43,13 @@ async def resp_image(
)
-def resp_json(payload: dict[str, Any]) -> ToolResponse:
- """Wrap a JSON dict as a single-text-block ToolResponse.
+def resp_json(payload: dict[str, Any]) -> ToolChunk:
+ """Wrap a JSON dict as a single-text-block ToolChunk.
Mirrors ``core.llm.tool._resp_json``. Re-implemented here so this package
has zero cross-imports back to the large monolithic ``tool.py``.
"""
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -60,7 +59,7 @@ def resp_json(payload: dict[str, Any]) -> ToolResponse:
)
-def response_metadata(response: ToolResponse, metadata: dict[str, Any]) -> ToolResponse:
+def response_metadata(response: ToolChunk, metadata: dict[str, Any]) -> ToolChunk:
"""Merge file metadata into the existing JSON block, preserving image layout."""
block = response.content[0]
block.text = json.dumps({**json.loads(block.text), **metadata}, ensure_ascii=False)
diff --git a/src/backend/core/llm/tools/_tool_helpers.py b/src/backend/core/llm/tools/_tool_helpers.py
index 09865298..390ac7d8 100644
--- a/src/backend/core/llm/tools/_tool_helpers.py
+++ b/src/backend/core/llm/tools/_tool_helpers.py
@@ -6,11 +6,10 @@
import json
import logging
-from pathlib import Path
-from typing import Any, Optional
+from typing import Any
from agentscope.message import TextBlock
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
from core.config.settings import settings
logger = logging.getLogger(__name__)
@@ -27,9 +26,9 @@
-def resp_json(payload: dict[str, Any]) -> ToolResponse:
- """Helper: wrap a JSON-serializable dict as a single-text-block ToolResponse."""
- return ToolResponse(
+def resp_json(payload: dict[str, Any]) -> ToolChunk:
+ """Helper: wrap a JSON-serializable dict as a single-text-block ToolChunk."""
+ return ToolChunk(
content=[
TextBlock(
type="text",
diff --git a/src/backend/core/llm/tools/ask_user_question_tool.py b/src/backend/core/llm/tools/ask_user_question_tool.py
index 2052a622..097bec1d 100644
--- a/src/backend/core/llm/tools/ask_user_question_tool.py
+++ b/src/backend/core/llm/tools/ask_user_question_tool.py
@@ -1,11 +1,12 @@
"""Model-facing ``ask_user_question`` tool over the suspended question registry."""
+from core.llm.factory.tools.definitions import function_tool
import logging
from typing import Annotated, List, Optional
from agentscope.message import TextBlock
from agentscope.tool import Toolkit
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
from agentscope.tool._response import ToolResultState
from core.llm.tools import user_questions
from core.llm.tools._common import resp_json
@@ -78,7 +79,7 @@ class AskUserQuestionItem(TypedDict):
]
-def _error_response(status: str) -> ToolResponse:
+def _error_response(status: str) -> ToolChunk:
"""Return DSH-style tool errors while retaining local timeout semantics."""
errors = {
@@ -97,7 +98,7 @@ def _error_response(status: str) -> ToolResponse:
status,
("ASK_ABORTED", "ask_user_question was aborted before the user answered"),
)
- return ToolResponse(
+ return ToolChunk(
content=[TextBlock(type="text", text=f"Error: {message}")],
state=ToolResultState.ERROR,
metadata={
@@ -132,7 +133,7 @@ async def ask_user_question(
List[AskUserQuestionItem],
Field(description="Questions to ask the user before continuing."),
],
- ) -> ToolResponse:
+ ) -> ToolChunk:
"""Ask the user one or more questions and wait for the human answer."""
result = await user_questions.ask(
@@ -160,7 +161,7 @@ async def ask_user_question(
# to DSH's exact two-sentence contract instead of leaking local policy or
# transport details into the fixed tool-schema prefix.
ask_user_question.__doc__ = _TOOL_DESCRIPTION
- toolkit.register_tool_function(ask_user_question, namesake_strategy="override")
+ toolkit.append(function_tool(ask_user_question))
logger.info("[factory] Registered ask_user_question tool (interactive top-level chat)")
diff --git a/src/backend/core/llm/tools/chat_history_tool.py b/src/backend/core/llm/tools/chat_history_tool.py
index 2daa0f25..02c6bd91 100644
--- a/src/backend/core/llm/tools/chat_history_tool.py
+++ b/src/backend/core/llm/tools/chat_history_tool.py
@@ -9,6 +9,7 @@
"""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import logging
@@ -89,5 +90,5 @@ async def read_chat(
list_related_chats.__doc__ = (list_related_chats.__doc__ or "").format(scope=scope_hint)
- toolkit.register_tool_function(list_related_chats)
- toolkit.register_tool_function(read_chat)
+ toolkit.append(function_tool(list_related_chats))
+ toolkit.append(function_tool(read_chat))
diff --git a/src/backend/core/llm/mcp/gateway_tool.py b/src/backend/core/llm/tools/cloud_gateway.py
similarity index 87%
rename from src/backend/core/llm/mcp/gateway_tool.py
rename to src/backend/core/llm/tools/cloud_gateway.py
index 6f373de2..685a1fac 100644
--- a/src/backend/core/llm/mcp/gateway_tool.py
+++ b/src/backend/core/llm/tools/cloud_gateway.py
@@ -1,25 +1,43 @@
"""Authorized cloud gateway invocation, separate from manifest discovery."""
from core.harness.registry import execution_identity as bridge_identity
import asyncio
+import weakref
+from contextlib import asynccontextmanager
from typing import Any, Dict
import httpx
-import mcp.types
from agentscope.permission import PermissionBehavior, PermissionDecision
from agentscope.tool import ToolBase, ToolChunk
+_shared_clients = weakref.WeakKeyDictionary()
+
+
+@asynccontextmanager
+async def _http(transport):
+ """Reuse one pooled client per event loop; an injected transport gets its own client."""
+ if transport is not None:
+ async with httpx.AsyncClient(transport=transport) as client:
+ yield client
+ return
+ loop = asyncio.get_running_loop()
+ client = _shared_clients.get(loop)
+ if client is None or client.is_closed:
+ client = _shared_clients[loop] = httpx.AsyncClient()
+ yield client
+
+
class GatewayMCPTool(ToolBase):
"""MCP-shaped tool built from a cloud-owned runtime manifest snapshot."""
is_mcp = True
is_state_injected = False
is_external_tool = False
- is_concurrency_safe = True
+ is_concurrency_safe = False
def __init__(
self,
*,
mcp_name: str,
- tool: mcp.types.Tool,
+ tool: dict,
invoke_url: str,
schema_hash: str,
headers: Dict[str, str],
@@ -27,17 +45,18 @@ def __init__(
transport: Any = None,
source_plugin: str = "",
) -> None:
+ super().__init__()
self._source_plugin = source_plugin
self.mcp_name = mcp_name
- self.name = tool.name
- self.description = tool.description or ""
- schema = dict(tool.inputSchema or {})
+ self.name = tool["name"]
+ self.description = tool.get("description") or ""
+ schema = dict(tool.get("inputSchema") or {})
schema.setdefault("type", "object")
schema.setdefault("properties", {})
schema.setdefault("required", [])
self.input_schema = schema
self.is_read_only = bool(
- tool.annotations and getattr(tool.annotations, "readOnlyHint", False)
+ tool.get("annotations", {}).get("readOnlyHint")
)
self._invoke_url = invoke_url
self._schema_hash = schema_hash
@@ -66,7 +85,7 @@ def _unknown_outcome(self) -> ToolChunk:
"gateway_outcome_unknown": True},
)
- async def __call__(self, **kwargs: Any) -> ToolChunk:
+ async def call(self, **kwargs: Any) -> ToolChunk:
from extensions.mcp_servers.registry import permission_specs
from core.sandbox.permissions.dispatch_models import CURRENT_PERMISSION_TICKET, PermissionEnforcementError, _json_hash
spec = permission_specs(self.mcp_name).get(self.name)
@@ -89,13 +108,10 @@ async def __call__(self, **kwargs: Any) -> ToolChunk:
if captured is not None:
from core.harness.registry.tool_operations import local_tool_result
async def authorize_local():
- options = {"timeout": 15.0}
- if self._transport is not None:
- options["transport"] = self._transport
- async with httpx.AsyncClient(**options) as client:
+ async with _http(self._transport) as client:
reply = await client.post(
self._invoke_url.rsplit("/", 1)[0] + "/authorize",
- headers=headers,
+ headers=headers, timeout=15.0,
json={"tool_name": self.name, "schema_hash": self._schema_hash,
"operation_id": kwargs.get("tool_effect_id") or "authorization"},
)
@@ -116,16 +132,12 @@ async def authorize_local():
if local_result is not None and not local_result.metadata.get("merge_cloud"):
return local_result
headers["accept-encoding"] = "identity"
- client_kwargs: Dict[str, Any] = {
- "timeout": httpx.Timeout(
- connect=min(10.0, self._timeout),
- read=self._timeout,
- write=min(60.0, self._timeout),
- pool=min(10.0, self._timeout),
- )
- }
- if self._transport is not None:
- client_kwargs["transport"] = self._transport
+ timeout = httpx.Timeout(
+ connect=min(10.0, self._timeout),
+ read=self._timeout,
+ write=min(60.0, self._timeout),
+ pool=min(10.0, self._timeout),
+ )
channel = None
if captured is not None:
from core.harness.registry.transfer_channels import UPLOAD_OPTIONS_HEADER, UPLOAD_SCHEMA_HEADER, upload_channel
@@ -142,7 +154,7 @@ async def authorize_local():
await asyncio.to_thread(operations.remember, effect, self.mcp_name,
self._invoke_url, self._schema_hash)
try:
- async with httpx.AsyncClient(**client_kwargs) as client:
+ async with _http(self._transport) as client:
if channel is not None:
headers.update(
{
@@ -155,6 +167,7 @@ async def authorize_local():
f"{self._invoke_url.rsplit('/', 1)[0]}/{channel.endpoint}",
headers=headers,
content=body,
+ timeout=timeout,
)
else:
response = await client.post(
@@ -165,6 +178,7 @@ async def authorize_local():
"arguments": kwargs,
"schema_hash": self._schema_hash,
},
+ timeout=timeout,
)
if captured is not None:
bridge_identity.require_current_account(captured)
diff --git a/src/backend/core/llm/tools/cloud_manifest.py b/src/backend/core/llm/tools/cloud_manifest.py
new file mode 100644
index 00000000..5a80d1f5
--- /dev/null
+++ b/src/backend/core/llm/tools/cloud_manifest.py
@@ -0,0 +1,55 @@
+"""Build native tools from an authorized cloud or local management manifest."""
+from core.capabilities.mcp.tool_schema import has_usable_schema
+from core.capabilities.plugins.management.contract import declared, localize, tools as manager_tools
+from core.harness.registry.run_capabilities import has_run_capabilities
+from core.llm.tools.cloud_gateway import GatewayMCPTool
+from core.llm.tools.management import ManagementTool
+
+
+def manifest_tools(name, cfg):
+ plugin = str(cfg.get("gateway_plugin") or "")
+ url = str(cfg.get("gateway_invoke_url") or "")
+ schema_hash = str(cfg.get("schema_hash") or "")
+ items = cfg.get("manifest_tools")
+ if not isinstance(items, list):
+ raise ValueError("Missing authorized tool manifest")
+ if cfg.get("schema_source") == "cloud_manifest" and not (url and schema_hash):
+ raise ValueError("Cloud manifest requires an invocation URL and schema hash")
+ if cfg.get("schema_source") == "plugin_manifest" and not (
+ cfg.get("origin") == "local_plugin" and all(declared(t, plugin) for t in items)
+ ):
+ raise ValueError("Invalid local management manifest")
+ if not all(isinstance(t, dict) for t in items):
+ raise ValueError("Invalid manifest tool entry")
+ items = list(items)
+ from extensions.mcp_servers.registry import gateway_server
+ if cfg.get("read_only") and gateway_server(name) is not None:
+ items = [t for t in items if t.get("annotations", {}).get("readOnlyHint") is True]
+ local = has_run_capabilities()
+ if local and plugin == "skill-manager" and any(t.get("name") == "install_skill" and declared(t, plugin) for t in items):
+ items += [t for t in manager_tools(plugin, "local") if t["name"] == "upload_skill_to_cloud" and not any(x.get("name") == t["name"] for x in items)]
+ result = []
+ names = set()
+ for item in items:
+ if local:
+ item = localize(item, plugin, cloud_available=bool(cfg.get("manager_cloud_available") or url))
+ if item is None or not has_usable_schema(item):
+ continue
+ tool_name = item.get("name")
+ if not isinstance(tool_name, str) or not tool_name or tool_name in names:
+ raise ValueError("Invalid or duplicate manifest tool name")
+ names.add(tool_name)
+ if cfg.get("enable_tools") is not None and tool_name not in cfg["enable_tools"]:
+ continue
+ if tool_name in (cfg.get("disable_tools") or []):
+ continue
+ import jsonschema
+ jsonschema.Draft202012Validator.check_schema(item["inputSchema"])
+ headers = dict(cfg.get("headers") or {})
+ if local and declared(item, plugin):
+ result.append(ManagementTool(name, item, plugin, headers, cloud_backed=bool(url)))
+ else:
+ result.append(GatewayMCPTool(mcp_name=name, tool=item, invoke_url=url,
+ schema_hash=schema_hash, headers=headers, timeout=cfg.get("execution_timeout") or 120,
+ transport=cfg.get("gateway_transport"), source_plugin=plugin))
+ return result
diff --git a/src/backend/core/llm/tools/confirmation/gate.py b/src/backend/core/llm/tools/confirmation/gate.py
index 9f33c78d..afeda91e 100644
--- a/src/backend/core/llm/tools/confirmation/gate.py
+++ b/src/backend/core/llm/tools/confirmation/gate.py
@@ -7,10 +7,11 @@
from typing import Any, Dict, Optional
from . import state
-logger = logging.getLogger(__name__)
from .shared import _load_session_grants, _save_session_grant
from .pending import _register_pending, _wait_pending
+logger = logging.getLogger(__name__)
+
async def gate(
*,
diff --git a/src/backend/core/llm/tools/confirmation/pending.py b/src/backend/core/llm/tools/confirmation/pending.py
index 8ac53ae7..3069093f 100644
--- a/src/backend/core/llm/tools/confirmation/pending.py
+++ b/src/backend/core/llm/tools/confirmation/pending.py
@@ -9,10 +9,11 @@
from core.llm.runtime import interaction_store
from . import state
-logger = logging.getLogger(__name__)
from .shared import _load_session_grants, _save_session_grant
from core.llm.tool_permissions.pending import approve_current_policy
+logger = logging.getLogger(__name__)
+
async def _register_pending(
cid_key: str, key: Tuple[str, ...], *, make_pending, timeout, precheck=None
diff --git a/src/backend/core/llm/tools/confirmation/state.py b/src/backend/core/llm/tools/confirmation/state.py
index 01a6366e..99fd18ba 100644
--- a/src/backend/core/llm/tools/confirmation/state.py
+++ b/src/backend/core/llm/tools/confirmation/state.py
@@ -108,10 +108,6 @@ def _scope_covers(kind: str, granted: str, logical_path: str) -> bool:
return True
-
-
-
-
def _gc(now: float) -> None:
"""Lazily clean up expired chat state (call while holding the lock). Chats with pending items / suspended waiters are not cleaned."""
dead = [c for c, st in process_state().CHATS.items() if now - st.last_ts > _TTL_S and (not st.pending)]
@@ -368,18 +364,14 @@ def _intercept_message(kind: str, phase: str, op: str, logical_path: str, summar
}[phase]
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class State:
+ LOCK: object = field(default_factory=lambda: threading.RLock())
+ CHATS: Dict[str, _ChatConfirm] = field(default_factory=lambda: {})
+
-@state_dataclass
-class StateState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- LOCK: object = state_field(default_factory=lambda: threading.RLock())
- CHATS: Dict[str, _ChatConfirm] = state_field(default_factory=lambda: {})
+_STATE = State()
-@state_cache(maxsize=1)
-def process_state() -> StateState:
- return StateState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+def process_state() -> State:
+ return _STATE
diff --git a/src/backend/core/llm/tools/edit_tool.py b/src/backend/core/llm/tools/edit_tool.py
index 85acebfb..90bc64c5 100644
--- a/src/backend/core/llm/tools/edit_tool.py
+++ b/src/backend/core/llm/tools/edit_tool.py
@@ -1,6 +1,7 @@
"""Read-checked mutations of files in the active sandbox."""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import difflib
import hashlib
@@ -14,7 +15,7 @@
resolve_sandbox_session,
resp_json,
)
-from core.sandbox.workspace.resolution import is_myspace_physical, space_metadata, to_physical_path, validate_project_scope_path, validate_workspace_path
+from core.sandbox.workspace.resolution import space_metadata, to_physical_path, validate_project_scope_path, validate_workspace_path
from ._state import ReadEntry, ReadStateTracker
logger = logging.getLogger(__name__)
@@ -269,5 +270,5 @@ async def Edit(
" ``{error: '...'}`` 失败。\n"
)
- toolkit.register_tool_function(Edit, namesake_strategy="override")
+ toolkit.append(function_tool(Edit))
logger.info("[factory] Registered Edit tool (chat_id=%s)", chat_id)
diff --git a/src/backend/core/llm/tools/glob_tool.py b/src/backend/core/llm/tools/glob_tool.py
index 49c35bea..34f5c9f4 100644
--- a/src/backend/core/llm/tools/glob_tool.py
+++ b/src/backend/core/llm/tools/glob_tool.py
@@ -1,5 +1,6 @@
"""Authorized sandbox file search with stateless continuation."""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import logging
import json
@@ -95,5 +96,5 @@ async def Glob(
"limit 默认 100;0 或负数返回全部。page 仅兼容第一页。"
)
- toolkit.register_tool_function(Glob, namesake_strategy="override")
+ toolkit.append(function_tool(Glob))
logger.info("[factory] Registered Glob tool (chat_id=%s)", chat_id)
diff --git a/src/backend/core/llm/tools/grep_tool.py b/src/backend/core/llm/tools/grep_tool.py
index cd2b4107..0541b8c8 100644
--- a/src/backend/core/llm/tools/grep_tool.py
+++ b/src/backend/core/llm/tools/grep_tool.py
@@ -7,7 +7,7 @@
"""
from __future__ import annotations
-from core.files.edition import is_shared_scope
+from core.llm.factory.tools.definitions import function_tool
import logging
from typing import Optional
@@ -177,5 +177,5 @@ async def Grep(
" num_matches, truncated}``。\n"
)
- toolkit.register_tool_function(Grep, namesake_strategy="override")
+ toolkit.append(function_tool(Grep))
logger.info("[factory] Registered Grep tool (chat_id=%s)", chat_id)
diff --git a/src/backend/core/llm/tools/job_tool.py b/src/backend/core/llm/tools/job_tool.py
index 87a89ac0..e528a97a 100644
--- a/src/backend/core/llm/tools/job_tool.py
+++ b/src/backend/core/llm/tools/job_tool.py
@@ -1,9 +1,10 @@
"""SDK binding for the user-scoped batch job action service."""
+from core.llm.factory.tools.definitions import function_tool
import json
from typing import Any, Dict, List, Optional
from agentscope.message import TextBlock
from agentscope.tool import Toolkit
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
from core.harness.registry.job_operations import job_operations
def register_run_job(
@@ -44,10 +45,10 @@ async def run_job(
status: str = "",
progress_wake_sec: int = 300,
on_conflict: str = "block",
- ) -> ToolResponse:
+ ) -> ToolChunk:
payload = await execute(action=action, script_path=script_path, name=name, job_id=job_id, wait=wait, interpreter=interpreter, max_calls=max_calls, max_seconds=max_seconds, concurrency=concurrency, dest_path=dest_path, status=status, progress_wake_sec=progress_wake_sec, on_conflict=on_conflict)
- return ToolResponse(content=[TextBlock(type="text", text=json.dumps(payload, ensure_ascii=False))])
+ return ToolChunk(content=[TextBlock(type="text", text=json.dumps(payload, ensure_ascii=False))])
from .job_description import RUN_JOB_DESCRIPTION
run_job.__doc__ = RUN_JOB_DESCRIPTION
- toolkit.register_tool_function(run_job)
+ toolkit.append(function_tool(run_job))
diff --git a/src/backend/core/llm/tools/management.py b/src/backend/core/llm/tools/management.py
index f0d6e738..7e4fce90 100644
--- a/src/backend/core/llm/tools/management.py
+++ b/src/backend/core/llm/tools/management.py
@@ -1,13 +1,9 @@
"""Local execution of an explicitly declared, installed manager plugin contract."""
-from core.harness.registry import execution_catalog as bridge_gateway
-from core.harness.registry import execution_identity as bridge_identity
-from core.harness.registry import execution_account as skill_account
import asyncio
import json
from agentscope.tool import ToolBase, ToolChunk
from agentscope.message import TextBlock, ToolResultState
from agentscope.permission import PermissionBehavior, PermissionDecision
-from core.capabilities.plugins.management.contract import declared, MANAGERS
from core.capabilities.plugins.management.errors import management_error
@@ -15,11 +11,12 @@ class ManagementTool(ToolBase):
is_mcp = True
is_state_injected = False
is_external_tool = False
- is_concurrency_safe = True
+ is_concurrency_safe = False
def __init__(self, server_id, tool, manager, headers, *, cloud_backed):
- self.mcp_name, self.name = server_id, tool.name
- self.description, self.input_schema = tool.description, tool.inputSchema
+ super().__init__()
+ self.mcp_name, self.name = server_id, tool["name"]
+ self.description, self.input_schema = tool.get("description", ""), tool["inputSchema"]
self.manager, self.headers = manager, dict(headers)
self.cloud_backed = cloud_backed
self.is_read_only = self.name.startswith(("get_", "list_"))
@@ -36,7 +33,7 @@ def _execute(self, arguments):
input_schema=self.input_schema, arguments=arguments,
)
- async def __call__(self, **kwargs):
+ async def call(self, **kwargs):
try:
result = await asyncio.to_thread(self._execute, kwargs)
return ToolChunk(content=[TextBlock(text=json.dumps(result, ensure_ascii=False))],
diff --git a/src/backend/core/llm/tools/ontology_toolkit.py b/src/backend/core/llm/tools/ontology_toolkit.py
index 3548b7be..5a8a54cc 100644
--- a/src/backend/core/llm/tools/ontology_toolkit.py
+++ b/src/backend/core/llm/tools/ontology_toolkit.py
@@ -48,6 +48,7 @@ class OntologyFilteredToolkit(Toolkit):
def __init__(self, *args, hidden_tools: set[str] | None = None, **kwargs):
super().__init__(*args, **kwargs)
+ self.capability_guard = None
self._ontology_hidden_tools = set(hidden_tools or ())
self._execution_surface: Optional[ExecutionSurfaceSnapshot] = None
self._execution_surface_generation = 0
@@ -69,6 +70,17 @@ def set_tool_permission_service(
self._tool_permission_service = service
async def call_tool(self, tool_call, state): # noqa: ANN001
+ if self.capability_guard is not None:
+ tools = await self._get_available_tools(state.tool_context.activated_groups)
+ registered = tools.get(tool_call.name)
+ tool = registered.tool if registered is not None else None
+ if tool is not None and tool.is_mcp:
+ allowed = await asyncio.to_thread(self.capability_guard, tool.mcp_name)
+ if not allowed:
+ chunk = ToolChunk(content=[TextBlock(text="该连接器已停用或当前不可用,本次调用未执行。")], state=ToolResultState.ERROR)
+ yield chunk
+ yield ToolResponse(id=tool_call.id).append_chunk(chunk)
+ return
service = self._tool_permission_service
if service is not None and service.registry.contains(tool_call.name):
ticket = CURRENT_PERMISSION_TICKET.get()
@@ -113,11 +125,9 @@ async def get_tool_schemas(self, groups=None): # noqa: ANN001
snapshot = await self.freeze_execution_surface(groups)
return snapshot.tool_schemas
- async def get_skill_instructions(self) -> Optional[str]:
- """Return the skill prompt from the same frozen execution surface."""
- snapshot = self._execution_surface
- if snapshot is None:
- snapshot = await self.freeze_execution_surface()
+ async def get_skill_instructions(self, activated_groups=None) -> Optional[str]:
+ """Use the same group-scoped snapshot as the model's tool schemas."""
+ snapshot = await self.freeze_execution_surface(activated_groups)
return snapshot.skill_instructions
@staticmethod
@@ -151,8 +161,6 @@ async def freeze_execution_surface(
self._capture_surface_skills = False
skills = self._captured_surface_skills
if skills is None:
- # Compatibility fallback if a future AgentScope version stops
- # consulting skills while constructing the tool surface.
skills = await self._get_available_skills(groups)
skill_instructions = None
if skills:
diff --git a/src/backend/core/llm/tools/pin_tool.py b/src/backend/core/llm/tools/pin_tool.py
index cb0cebf3..6935e286 100644
--- a/src/backend/core/llm/tools/pin_tool.py
+++ b/src/backend/core/llm/tools/pin_tool.py
@@ -3,6 +3,7 @@
Extracted from the oversized ``core/llm/tool.py``. Heavy deps imported lazily
inside the function; ``core.llm.tool`` re-exports ``register_pin_to_workspace``.
"""
+from core.llm.factory.tools.definitions import function_tool
import logging
from typing import Any, Dict, Optional, TYPE_CHECKING
@@ -12,7 +13,7 @@
from agentscope.message import TextBlock
from agentscope.tool import Toolkit
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
logger = logging.getLogger(__name__)
@@ -43,7 +44,7 @@ def register_pin_to_workspace(
async def pin_to_workspace(
file_ids: list[str] = [], file_paths: list[str] = []
- ) -> ToolResponse:
+ ) -> ToolChunk:
"""把文件交付到对话区——**唯一**让用户看到文件的方式。
凡用户要求生成/导出文件(文档、图片、PPT、Excel、PDF、CSV、压缩包、音视频、
@@ -89,7 +90,7 @@ async def pin_to_workspace(
elif isinstance(file_ids, list):
raw_ids = file_ids
else:
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -105,7 +106,7 @@ async def pin_to_workspace(
)
if not raw_ids and not file_paths:
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -217,14 +218,12 @@ async def pin_to_workspace(
_uid = user_id_var.get() or ""
_cid = chat_id_var.get() or ""
if _uid and to_persist:
- from core.db.engine import SessionLocal
+ from core.db.threads import run_in_session
from core.files.artifacts.service import persist_artifacts
- _db = SessionLocal()
- try:
- persist_artifacts(_db, _uid, _cid or None, to_persist, scope=scope, strict=True)
- finally:
- _db.close()
+ await run_in_session(
+ persist_artifacts, _uid, _cid or None, to_persist, scope=scope, strict=True
+ )
except Exception as exc: # noqa: BLE001
from fastapi import HTTPException
@@ -251,7 +250,7 @@ async def pin_to_workspace(
}
if failed_results:
result["failed"] = failed_results
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -285,11 +284,7 @@ def resolve_pin_paths(args, runtime):
)
return intents
- toolkit.register_tool_function(
- pin_to_workspace,
- namesake_strategy="override",
- permission=ToolPermissionSpec("local-project-pin-paths", resolve_pin_paths),
- )
+ toolkit.append(function_tool(pin_to_workspace, dispatch_spec=ToolPermissionSpec("local-project-pin-paths", resolve_pin_paths)))
logger.info("[factory] Registered pin_to_workspace tool")
diff --git a/src/backend/core/llm/tools/plan_update.py b/src/backend/core/llm/tools/plan_update.py
index 8e3d362d..6feaaa8b 100644
--- a/src/backend/core/llm/tools/plan_update.py
+++ b/src/backend/core/llm/tools/plan_update.py
@@ -5,15 +5,16 @@
"""
from __future__ import annotations
-from core.llm import prompt_rendering as prompt_rendering_module
+from core.llm.factory.tools.definitions import function_tool
+from core.llm import prompt_rendering
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
+# AgentScope 2.0: tool functions must return ToolChunk (call_tool rejects ToolChunk).
+from agentscope.tool._response import ToolChunk
from agentscope.message import TextBlock
logger = logging.getLogger(__name__)
@@ -64,7 +65,7 @@ def register_plan_update_tool(toolkit: Toolkit) -> None:
不更新(commit 7578469f)。
"""
- async def update_plan(steps: list, title: str = "", explanation: str = "") -> ToolResponse:
+ async def update_plan(steps: list, title: str = "", explanation: str = "") -> ToolChunk:
"""更新任务计划。传入一份计划项列表,每项含步骤与状态,以及可选的说明。
至多一个步骤可以处于 in_progress。调用本工具**不会打断**执行。
@@ -83,12 +84,12 @@ async def update_plan(steps: list, title: str = "", explanation: str = "") -> To
(可选)本次改动的理由。中途增删步骤、重写计划结构时必须填写。
Returns:
- `ToolResponse`:
+ `ToolChunk`:
当前清单的各状态计数。
"""
parsed = parse_plan_update_args({"title": title, "steps": steps})
if not parsed:
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text",
text=(
"错误:steps 必须是非空列表,每个元素为 "
@@ -115,9 +116,9 @@ async def update_plan(steps: list, title: str = "", explanation: str = "") -> To
"\n全部步骤已完成,计划终结:不要再调用 update_plan,也不要原样重复"
"任何已成功的工具调用,请直接输出面向用户的最终文字回复。"
)
- return ToolResponse(content=[TextBlock(type="text", text=text)])
+ return ToolChunk(content=[TextBlock(type="text", text=text)])
- toolkit.register_tool_function(update_plan, namesake_strategy="skip")
+ toolkit.append(function_tool(update_plan))
def build_plan_update_prompt_section() -> str:
@@ -126,6 +127,5 @@ def build_plan_update_prompt_section() -> str:
正文的真源是 ``prompts/prompt_text/plan_tool/plan_tool.system.md``,运行时取
``plan_tool`` 版本池的激活版本,可在 Config 管理台「任务计划清单」里编辑。
"""
- from core.llm import prompt_versions as pvs
- return prompt_rendering_module.render_kind_segment("plan_tool")
+ return prompt_rendering.render_kind_segment("plan_tool")
diff --git a/src/backend/core/llm/tools/plugin_activation.py b/src/backend/core/llm/tools/plugin_activation.py
index e208806b..73764e9f 100644
--- a/src/backend/core/llm/tools/plugin_activation.py
+++ b/src/backend/core/llm/tools/plugin_activation.py
@@ -1,19 +1,21 @@
"""Progressive plugin runtime: activation."""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Any, Dict, List, Set
-logger = logging.getLogger(__name__)
from core.capabilities.plugins.runtime import activation_history as plugin_activation_history
from core.capabilities.plugins.runtime import models as plugin_runtime_models
from core.capabilities.plugins.runtime import resolution as plugin_resolution
+logger = logging.getLogger(__name__)
+
class _ActivationStage:
"""Collect one activation's clients and loaders, then commit or drop them together.
@@ -21,9 +23,7 @@ class _ActivationStage:
A device capability run must never expose half a plugin: if the authorized
closure stops holding — or an MCP turns out to be unreachable — after some
clients already connected, the run has to end up exactly as it started, so
- everything lands in a buffer that is published only on success. The legacy
- cloud path has no such checkpoint and keeps appending straight to the live
- group.
+ everything lands in a buffer that is published only on success.
"""
def __init__(self, live_group: Any, runtime: Dict[str, Any], prepared: Any) -> None:
@@ -33,10 +33,9 @@ def __init__(self, live_group: Any, runtime: Dict[str, Any], prepared: Any) -> N
self._runtime = runtime
self._prepared = prepared
self._staged_clients: List[Any] = []
- self.staged = prepared is not None
- self.group = SimpleNamespace(mcps=[], skills_or_loaders=[]) if self.staged else live_group
+ self.group = SimpleNamespace(tools=[], skills_or_loaders=[])
current = runtime.setdefault("connected_keys", set())
- self.connected: Set[str] = set(current) if self.staged else current
+ self.connected: Set[str] = set(current)
async def checkpoint(self) -> None:
"""Re-assert the authorized closure between steps of the activation."""
@@ -47,27 +46,23 @@ async def checkpoint(self) -> None:
await asyncio.to_thread(validate, self._prepared)
def add_client(self, key: str, client: Any) -> None:
- self.group.mcps.append(client)
+ self.group.tools.extend(client.tools)
self.connected.add(key)
- if self.staged:
- self._staged_clients.append(client)
- return
- close_list = self._runtime.get("close_list")
- if isinstance(close_list, list):
- close_list.append(client)
+ self._staged_clients.append(client)
async def discard(self) -> None:
- for client in self._staged_clients:
- try:
- await client.close()
- except Exception as exc: # noqa: BLE001 — one bad close must not leak the rest
- logger.warning("[plugin-loader] staged MCP close failed: %s", exc)
- self._staged_clients.clear()
+ from core.llm.mcp.manager import close_clients
+
+ try:
+ await close_clients(self._staged_clients)
+ finally:
+ self._staged_clients.clear()
def commit(self) -> None:
- if not self.staged:
- return
- self._live.mcps.extend(self.group.mcps)
+ names = [t.name for t in [*self._live.tools, *self.group.tools]]
+ if len(names) != len(set(names)):
+ raise ValueError("Plugin tool name collision")
+ self._live.tools.extend(self.group.tools)
self._live.skills_or_loaders.extend(self.group.skills_or_loaders)
self._runtime["connected_keys"] = self.connected
close_list = self._runtime.get("close_list")
@@ -84,7 +79,11 @@ async def _activation_stage(live_group: Any, runtime: Dict[str, Any], prepared:
except BaseException:
await stage.discard()
raise
- stage.commit()
+ try:
+ stage.commit()
+ except BaseException:
+ await stage.discard()
+ raise
def register_load_plugin(
@@ -117,10 +116,10 @@ def register_load_plugin(
Deferred clients inherit the current run's read-only setting.
"""
from agentscope.message import TextBlock
- from agentscope.tool._response import ToolChunk as ToolResponse
+ from agentscope.tool._response import ToolChunk
def _text(msg: str) -> Any:
- return ToolResponse(content=[TextBlock(type="text", text=msg)])
+ return ToolChunk(content=[TextBlock(type="text", text=msg)])
async def load_plugin(plugin: str) -> Any:
"""加载插件目录中列出的插件,激活其包含的全部工具与技能。
@@ -201,7 +200,7 @@ async def load_plugin(plugin: str) -> Any:
]
if mcp_ids:
from core.llm.factory.tools.mcp_config import inject_runtime_headers as _inject_runtime_headers
- from core.llm.mcp.pool import make_client
+ from core.llm.mcp.lifetime import discover
from core.capabilities.mcp.service import McpServerConfigService
if prepared is not None:
@@ -229,35 +228,36 @@ async def load_plugin(plugin: str) -> Any:
)
async def _connect(key: str, cfg: dict) -> None:
- if not stage.staged and not plugin_resolution._http_transport(cfg):
- failed_servers.append(key)
- return
client = None
try:
from core.harness.registry.local_publication import ensure_native_servers
await stage.checkpoint()
await ensure_native_servers({key: cfg})
- client = make_client(
+ from core.llm.mcp.callbacks import Interactions
+ # Runtimes bound before agent construction have no request yet;
+ # their sessions stay run-owned and decline server interactions.
+ request = runtime.get("request")
+ interactions = (Interactions(request, key, bool(runtime.get("approval_available")),
+ agent=runtime.get("agent"))
+ if request is not None else None)
+ scope = ((request.current_user_id, request.chat_id, request.capability_scope)
+ if request is not None and request.current_user_id and request.chat_id
+ and not request.isolated else None)
+ client = await discover(
key, {**cfg, "read_only": bool(runtime.get("read_only"))},
- is_stateful=False,
+ interactions=interactions, scope=scope,
)
- if stage.staged and not plugin_resolution._http_transport(cfg):
- await client.connect()
- tools = await client.list_tools()
+ tools = client.tools
await stage.checkpoint()
except (
BaseException
) as exc: # noqa: BLE001 — SSE cleanup may raise CancelledError
if client is not None:
- await client.close()
+ from core.llm.mcp.manager import close_clients
+ await close_clients([client])
if isinstance(exc, asyncio.CancelledError):
- current = asyncio.current_task()
- if (
- current is not None
- and getattr(current, "cancelling", lambda: 0)() > 0
- ):
- raise
+ raise
logger.warning("[plugin-loader] MCP '%s' connect failed: %s", key, exc)
failed_servers.append(key)
return
@@ -271,7 +271,7 @@ async def _connect(key: str, cfg: dict) -> None:
failed_servers.append(key)
await stage.checkpoint()
- if stage.staged and failed_servers:
+ if failed_servers:
raise RuntimeError("插件 MCP 服务不可用:" + "、".join(failed_servers))
# ── Skills: materialize and append loaders in place ──
@@ -290,13 +290,13 @@ async def _connect(key: str, cfg: dict) -> None:
if not d:
continue
if prepared is not None:
- from core.llm.runtime.tool_collector import RuntimeNamedSkillLoader
+ from core.capabilities.skills.runtime_loader import RuntimeNamedSkillLoader
stage.group.skills_or_loaders.append(
RuntimeNamedSkillLoader(d, sid, prepared)
)
else:
- from core.llm.runtime.tool_collector import RuntimeNamedSkillLoader
+ from core.capabilities.skills.runtime_loader import RuntimeNamedSkillLoader
stage.group.skills_or_loaders.append(RuntimeNamedSkillLoader(d, sid))
item = meta.get(sid)
@@ -370,4 +370,4 @@ async def _connect(key: str, cfg: dict) -> None:
parts.append("该插件本轮没有可加载的组件(可能均已被禁用)。")
return _text("\n".join(parts))
- toolkit.register_tool_function(load_plugin, namesake_strategy="override")
+ toolkit.append(function_tool(load_plugin))
diff --git a/src/backend/core/llm/tools/project_instructions_tool.py b/src/backend/core/llm/tools/project_instructions_tool.py
index b9875416..f5e6d8a0 100644
--- a/src/backend/core/llm/tools/project_instructions_tool.py
+++ b/src/backend/core/llm/tools/project_instructions_tool.py
@@ -1,5 +1,6 @@
"""Canonical instruction reads for project chats; writes only during initialization."""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import asyncio
@@ -82,16 +83,10 @@ async def save_project_instructions(content: str, expected_revision: str):
except HTTPException as exc:
return resp_json({"ok": False, "error": exc.detail, "status": exc.status_code})
- toolkit.register_tool_function(
- read_project_instructions,
- permission=local_path_tool("file_path", READ, tool_name="read_project_instructions", default_path=target),
- )
+ toolkit.append(function_tool(read_project_instructions, dispatch_spec=local_path_tool("file_path", READ, tool_name="read_project_instructions", default_path=target)))
if not allow_write:
return
- toolkit.register_tool_function(
- save_project_instructions,
- permission=local_path_tool(
+ toolkit.append(function_tool(save_project_instructions, dispatch_spec=local_path_tool(
"file_path", WRITE, tool_name="save_project_instructions",
default_path=target, myspace_op=OP_EDIT,
- ),
- )
+ )))
diff --git a/src/backend/core/llm/tools/read_artifact_tool.py b/src/backend/core/llm/tools/read_artifact_tool.py
index dae6e7a6..9ea79e9d 100644
--- a/src/backend/core/llm/tools/read_artifact_tool.py
+++ b/src/backend/core/llm/tools/read_artifact_tool.py
@@ -4,6 +4,7 @@
deps (content parsers, hooks) are imported lazily inside the functions.
``core.llm.tool`` re-exports the public names for backward compatibility.
"""
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import logging
@@ -11,7 +12,7 @@
from agentscope.message import TextBlock
from agentscope.tool import Toolkit
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
logger = logging.getLogger(__name__)
@@ -124,7 +125,7 @@ async def read_artifact(
limit: int = _READ_ARTIFACT_DEFAULT_LIMIT,
sheet_name: str | None = None,
slide_index: int | None = None,
- ) -> ToolResponse:
+ ) -> ToolChunk:
"""读取已上传文件的完整解析文本(按字符分页)。
⚠️ 每轮对话每个 file_id **累计最多读 50000 字符**,超出即被拒绝。所以要精准
@@ -159,18 +160,18 @@ async def read_artifact(
import json as _json
from core.files.artifacts.reader import fetch_parsed_text, load_artifact_meta
- from core.llm.runtime.hooks_state import MAX_FILE_CONTENT_CHARS
+ from core.llm.runtime.hooks_constants import MAX_FILE_CONTENT_CHARS
from core.llm.runtime.turn_state import get_artifact_read_state
fid = (file_id or "").strip()
if not fid:
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text",
text=_json.dumps({"error": "file_id 不能为空"}, ensure_ascii=False),
)])
if sheet_name is not None and slide_index is not None:
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text",
text=_json.dumps(
{"error": "sheet_name 与 slide_index 互斥(分别用于 xlsx / pptx)"},
@@ -185,7 +186,7 @@ async def read_artifact(
# Blocking it mid-answer stalls streaming for every chat on the instance.
meta = await asyncio.to_thread(load_artifact_meta, fid, user_id=user_id)
if meta is None:
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text",
text=_json.dumps(
{"error": f"文件 {fid} 不存在、已删除,或无权访问"},
@@ -227,7 +228,7 @@ async def read_artifact(
except RuntimeError as e:
err = {"error": str(e), "file_id": fid, "filename": meta.get("filename")}
err.update(extras)
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text", text=_json.dumps(err, ensure_ascii=False),
)])
@@ -235,7 +236,7 @@ async def read_artifact(
error = meta.get("parse_error") or "文件暂无可读文本内容"
err = {"error": error, "filename": meta.get("filename"), "file_id": fid}
err.update(extras)
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text", text=_json.dumps(err, ensure_ascii=False),
)])
@@ -272,7 +273,7 @@ async def read_artifact(
)
else:
hint += " 如需更多内容,请缩小范围(精准 offset/limit)后再试。"
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text",
text=_json.dumps(
{
@@ -317,10 +318,10 @@ async def read_artifact(
"budget_remaining": budget_remaining_after,
}
result.update(extras)
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text",
text=_json.dumps(result, ensure_ascii=False),
)])
- toolkit.register_tool_function(read_artifact, namesake_strategy="override")
+ toolkit.append(function_tool(read_artifact))
logger.info("[factory] Registered read_artifact tool (user=%s)", user_id)
diff --git a/src/backend/core/llm/tools/read_image_tool.py b/src/backend/core/llm/tools/read_image_tool.py
index ceeeb7b4..2ff2d21b 100644
--- a/src/backend/core/llm/tools/read_image_tool.py
+++ b/src/backend/core/llm/tools/read_image_tool.py
@@ -11,6 +11,7 @@
"""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import logging
@@ -20,7 +21,7 @@
from core.projects.edition import ProjectScope
from core.models.vision import VisionMode
-from ._common import ToolResponse, resolve_sandbox_session, resp_image, resp_json
+from ._common import ToolChunk, resolve_sandbox_session, resp_image, resp_json
from core.sandbox.workspace.resolution import to_physical_path, validate_project_scope_path, validate_workspace_path
logger = logging.getLogger(__name__)
@@ -140,7 +141,7 @@ def register_read_image(
return
_sess = resolve_sandbox_session(sandbox_session_id, chat_id)
- async def _run(file_path: str, file_id: str, focus: str) -> ToolResponse:
+ async def _run(file_path: str, file_id: str, focus: str) -> ToolChunk:
try:
image_bytes, mime, label = await load_image(
file_path,
@@ -182,7 +183,7 @@ async def _run(file_path: str, file_id: str, focus: str) -> ToolResponse:
# and only the bridge variant can act on ``focus``.
if vision_mode == "native":
- async def read_image(file_path: str = "", file_id: str = "") -> ToolResponse:
+ async def read_image(file_path: str = "", file_id: str = "") -> ToolChunk:
return await _run(file_path, file_id, "")
read_image.__doc__ = _NATIVE_DOC
@@ -190,12 +191,12 @@ async def read_image(file_path: str = "", file_id: str = "") -> ToolResponse:
async def read_image(
file_path: str = "", file_id: str = "", focus: str = ""
- ) -> ToolResponse:
+ ) -> ToolChunk:
return await _run(file_path, file_id, focus)
read_image.__doc__ = _BRIDGE_DOC
- toolkit.register_tool_function(read_image)
+ toolkit.append(function_tool(read_image))
logger.info("[factory] Registered read_image tool (%s, chat_id=%s)", vision_mode, chat_id)
diff --git a/src/backend/core/llm/tools/read_tool.py b/src/backend/core/llm/tools/read_tool.py
index cbfba91f..8c852030 100644
--- a/src/backend/core/llm/tools/read_tool.py
+++ b/src/backend/core/llm/tools/read_tool.py
@@ -1,6 +1,7 @@
"""Read files from the active sandbox."""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import hashlib
@@ -283,5 +284,5 @@ async def Read(
" ...}`` / ``{type: 'too_large', ...}`` / ``{error: '...'}``。\n"
)
- toolkit.register_tool_function(Read, namesake_strategy="override")
+ toolkit.append(function_tool(Read))
logger.info("[factory] Registered Read tool (chat_id=%s)", chat_id)
diff --git a/src/backend/core/llm/tools/sandbox_artifact_tools.py b/src/backend/core/llm/tools/sandbox_artifact_tools.py
index 83438294..8010eac2 100644
--- a/src/backend/core/llm/tools/sandbox_artifact_tools.py
+++ b/src/backend/core/llm/tools/sandbox_artifact_tools.py
@@ -10,6 +10,7 @@
"""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import base64
@@ -17,15 +18,15 @@
import os
import tempfile
from pathlib import Path
-from typing import Any, Optional, TYPE_CHECKING
+from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from core.projects.edition import ProjectScope
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
+# AgentScope 2.0: tool functions must return ToolChunk (call_tool rejects ToolChunk).
+from agentscope.tool._response import ToolChunk
from core.llm.tools._common import resolve_sandbox_session
from core.files.artifacts.generated import resolve_artifact_files as _resolve_artifact_files, store_generated_file_path as _store_generated_file_path
from core.llm.tools._tool_helpers import resp_json as _resp_json, validate_workspace_path as _validate_workspace_path
@@ -45,7 +46,7 @@ def register_sandbox_put_artifact(
_sess = resolve_sandbox_session(sandbox_session_id, chat_id)
- async def sandbox_put_artifact(artifact_id: str, dest_path: str) -> ToolResponse:
+ async def sandbox_put_artifact(artifact_id: str, dest_path: str) -> ToolChunk:
from core.sandbox import SandboxConnectError as _SandboxConnectError
from core.sandbox import SandboxError as _SandboxError
from core.sandbox import get_sandbox_provider as _get_provider
@@ -101,7 +102,7 @@ async def sandbox_put_artifact(artifact_id: str, dest_path: str) -> ToolResponse
"限制:单个 artifact 最大 10 MB。\n"
)
- toolkit.register_tool_function(sandbox_put_artifact, namesake_strategy="override")
+ toolkit.append(function_tool(sandbox_put_artifact))
logger.info("[factory] Registered sandbox_put_artifact tool (chat_id=%s)", chat_id)
@@ -122,7 +123,7 @@ def register_sandbox_get_artifact(
max_bytes = _settings.sandbox.artifact_max_bytes
- async def sandbox_get_artifact(src_path: str, name: str = "") -> ToolResponse:
+ async def sandbox_get_artifact(src_path: str, name: str = "") -> ToolChunk:
import mimetypes as _mt
from core.sandbox import SandboxConnectError as _SandboxConnectError
@@ -242,5 +243,5 @@ async def sandbox_get_artifact(src_path: str, name: str = "") -> ToolResponse:
"PDF 应按页拆分,其他格式应拆包或降低体积后再逐个登记。\n"
)
- toolkit.register_tool_function(sandbox_get_artifact, namesake_strategy="override")
+ toolkit.append(function_tool(sandbox_get_artifact))
logger.info("[factory] Registered sandbox_get_artifact tool (chat_id=%s)", chat_id)
diff --git a/src/backend/core/llm/tools/sandbox_tool.py b/src/backend/core/llm/tools/sandbox_tool.py
index 5ebe532b..97f6a403 100644
--- a/src/backend/core/llm/tools/sandbox_tool.py
+++ b/src/backend/core/llm/tools/sandbox_tool.py
@@ -10,6 +10,7 @@
"""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import logging
@@ -18,15 +19,16 @@
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
+# AgentScope 2.0: tool functions must return ToolChunk (call_tool rejects ToolChunk).
+from agentscope.tool._response import ToolChunk
from core.llm.tools._common import resolve_sandbox_session
from core.llm.tools._tool_helpers import resp_json as _resp_json
-logger = logging.getLogger(__name__)
import re as _re
+logger = logging.getLogger(__name__)
+
# dws exit code 4 = PAT authorization interception; stderr/stdout carries a line
# ``PAT_AUTHORIZATION_URL=`` (a copy-safe link dws prints separately for
# OpenClaw-style hosts, see dws CHANGELOG #242).
@@ -55,7 +57,6 @@ def register_bash(
toolkit: Toolkit,
*,
loader: Any,
- loaded_skill_ids: set[str],
chat_id: Optional[str] = None,
sandbox_session_id: Optional[str] = None,
user_id: Optional[str] = None,
@@ -69,8 +70,8 @@ def register_bash(
``opensandbox_provider._make_skills_volumes`` + ``config.get_sandbox_skills_dir``):
what is bound there is the **caller's own skill view**, holding the shared
skills plus that user's private ones, so no one sees another user's skill
- files. This registration just sets up the Bash tool itself; ``loader`` /
- ``loaded_skill_ids`` are kept for backward compat with existing callers.
+ files. This registration just sets up the Bash tool itself; ``loader`` only
+ supplies the capability run that scopes it.
The sandbox session is bound to ``chat_id`` so OpenSandbox keeps a single
persistent container per conversation (variables, pip packages, /workspace
@@ -88,7 +89,7 @@ def register_bash(
async def Bash(
command: str, timeout: int | None = None, yield_time_ms: int = 60000,
run_in_background: bool = False,
- ) -> ToolResponse:
+ ) -> ToolChunk:
from core.sandbox import ProcessRequest as _ProcessRequest
from core.sandbox import SandboxConnectError as _SandboxConnectError
from core.sandbox import SandboxError as _SandboxError
@@ -194,7 +195,7 @@ async def Bash(
return _resp_json(payload)
return await finish(payload)
- async def write_stdin(session_id: str, chars: str = "", yield_time_ms: int = 0) -> ToolResponse:
+ async def write_stdin(session_id: str, chars: str = "", yield_time_ms: int = 0) -> ToolChunk:
from core.llm.ports import command_execution
from core.llm.ports import command_records
tid = await asyncio.to_thread(command_records().resolve_id, session_id, user_id, _sess)
@@ -207,7 +208,7 @@ async def write_stdin(session_id: str, chars: str = "", yield_time_ms: int = 0)
payload = await asyncio.to_thread(command_records().read, tid, user_id, _sess)
return await finish(payload)
- async def background_process(session_id: str) -> ToolResponse:
+ async def background_process(session_id: str) -> ToolChunk:
"""Adopt an existing Bash process without executing the command again."""
from core.sandbox import get_sandbox_provider
from core.llm.ports import command_execution
@@ -215,12 +216,12 @@ async def background_process(session_id: str) -> ToolResponse:
user_id=user_id, session_id=_sess, chat_id=chat_id, source_run_id=source_run_id)
return _resp_json(payload)
- async def list_processes() -> ToolResponse:
+ async def list_processes() -> ToolChunk:
"""List this user's command tasks in the current chat."""
from core.llm.ports import command_records
return _resp_json({"tasks": await asyncio.to_thread(command_records().list_owned, user_id, chat_id)})
- async def finish(payload: dict) -> ToolResponse:
+ async def finish(payload: dict) -> ToolChunk:
# dws (DingTalk CLI) PAT per-scope authorization interception: exit code 4
# + a PAT_AUTHORIZATION_URL= line on stderr. Surface the link to the
# model in structured form so it hands it verbatim to the user, who
@@ -273,10 +274,10 @@ async def finish(payload: dict) -> ToolResponse:
" JSON: {stdout, stderr, exit_code, execution_time_ms, status, session_id};输出仅含新增内容。\n"
" running 表示继续运行;exited 时检查 exit_code 和 error。\n"
)
- toolkit.register_tool_function(background_process, namesake_strategy="override")
- toolkit.register_tool_function(list_processes, namesake_strategy="override")
- toolkit.register_tool_function(write_stdin, namesake_strategy="override")
- toolkit.register_tool_function(Bash, namesake_strategy="override")
+ toolkit.append(function_tool(background_process))
+ toolkit.append(function_tool(list_processes))
+ toolkit.append(function_tool(write_stdin))
+ toolkit.append(function_tool(Bash))
logger.info("[factory] Registered Bash tool (chat_id=%s)", chat_id)
diff --git a/src/backend/core/llm/tools/skill_tool.py b/src/backend/core/llm/tools/skill_tool.py
index 0c0f8293..0f737102 100644
--- a/src/backend/core/llm/tools/skill_tool.py
+++ b/src/backend/core/llm/tools/skill_tool.py
@@ -10,6 +10,7 @@
"""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
from core.harness import log_writer
import json
@@ -21,8 +22,8 @@
from agentscope.message import TextBlock
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
+# AgentScope 2.0: tool functions must return ToolChunk (call_tool rejects ToolChunk).
+from agentscope.tool._response import ToolChunk
from core.capabilities.skills.loader_factory import get_skill_loader
@@ -114,7 +115,7 @@ def register_sandboxed_view_text_file(
async def view_text_file(
file_path: str,
ranges: list[int] | None = None,
- ) -> ToolResponse:
+ ) -> ToolChunk:
"""View file content within allowed skill directories."""
prepared = getattr(loader, "capability_run", None)
if prepared is not None:
@@ -141,7 +142,7 @@ async def view_text_file(
if requested_name is not None:
await asyncio.to_thread(validate, prepared, only_skill=requested_name)
except (CapabilityError, OSError):
- return ToolResponse(
+ return ToolChunk(
content=[
TextBlock(
type="text",
@@ -161,7 +162,7 @@ async def view_text_file(
real = _os.path.realpath(resolved)
if not any(real.startswith(d + _os.sep) or real == d for d in resolved_dirs):
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text",
text=f"Error: Access denied. Only files inside skill directories can be read.\nRequested: {file_path}",
)])
@@ -181,10 +182,10 @@ async def view_text_file(
from core.harness.registry.skill_content import adapt_skill_content
_text = adapt_skill_content(_text, user_id)
except Exception as _e: # noqa: BLE001
- return ToolResponse(content=[TextBlock(
+ return ToolChunk(content=[TextBlock(
type="text", text=f"Error reading file: {_e}",
)])
- resp = ToolResponse(content=[TextBlock(type="text", text=_text)])
+ resp = ToolChunk(content=[TextBlock(type="text", text=_text)])
if _os.path.basename(real) == "SKILL.md":
from core.config.local_mode import local_mode_enabled
@@ -236,4 +237,4 @@ async def view_text_file(
return resp
- toolkit.register_tool_function(view_text_file, namesake_strategy="override")
+ toolkit.append(function_tool(view_text_file))
diff --git a/src/backend/core/llm/tools/user_questions.py b/src/backend/core/llm/tools/user_questions.py
index 71dee22b..e7d7c714 100644
--- a/src/backend/core/llm/tools/user_questions.py
+++ b/src/backend/core/llm/tools/user_questions.py
@@ -64,10 +64,6 @@ class _ChatQuestions:
last_ts: float = field(default_factory=time.monotonic)
-
-
-
-
def _text(
value: Any,
*,
@@ -483,18 +479,14 @@ async def cancel_shared(chat_id, request_id):
]
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class UserQuestionsState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- LOCK: object = state_field(default_factory=lambda: threading.RLock())
- CHATS: Dict[str, _ChatQuestions] = state_field(default_factory=lambda: {})
+ LOCK: object = field(default_factory=lambda: threading.RLock())
+ CHATS: Dict[str, _ChatQuestions] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> UserQuestionsState:
- return UserQuestionsState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = UserQuestionsState()
+
+
+def process_state() -> UserQuestionsState:
+ return _STATE
diff --git a/src/backend/core/llm/tools/write_tool.py b/src/backend/core/llm/tools/write_tool.py
index b8fb92a0..e7f29c47 100644
--- a/src/backend/core/llm/tools/write_tool.py
+++ b/src/backend/core/llm/tools/write_tool.py
@@ -1,6 +1,7 @@
"""Read-checked mutations of files in the active sandbox."""
from __future__ import annotations
+from core.llm.factory.tools.definitions import function_tool
import difflib
import hashlib
@@ -335,5 +336,5 @@ async def Write(
" ``persistent=true`` 表示文件保存于个人或团队空间;``space_type`` 为 personal/team/temporary。\n"
)
- toolkit.register_tool_function(Write, namesake_strategy="override")
+ toolkit.append(function_tool(Write))
logger.info("[factory] Registered Write tool (chat_id=%s)", chat_id)
diff --git a/src/backend/core/memory/access.py b/src/backend/core/memory/access.py
index 5fb4a587..ebbaca5e 100644
--- a/src/backend/core/memory/access.py
+++ b/src/backend/core/memory/access.py
@@ -4,10 +4,31 @@
import asyncio
from typing import Optional
+from core.db.threads import run_in_session
from core.memory.context import MemoryContext
from core.memory import backend
+def _project_memory_owner(db, project_id: str, actor: str) -> Optional[str]:
+ """The memory scope ``actor`` may edit in a project, or None when it may not."""
+ from core.auth.edition import resolve_project_access
+ from core.db.models import Project
+ from core.projects.edition import project_memory_policy
+
+ project = db.query(Project).filter_by(project_id=project_id, deleted_at=None).first()
+ if project is None:
+ return None
+ access = resolve_project_access(db, actor, project)
+ if access.level not in ("edit", "admin"):
+ return None
+ policy = project_memory_policy(db, project.project_id, actor)
+ if policy is not None:
+ # Read availability does not revoke explicit edits of existing memory.
+ return policy[1]
+ # CE has no policy row for local projects; actor access is checked above.
+ return actor if project.kind == "local" else None
+
+
async def authorized_memory_context(memory_id: str, actor: str) -> Optional[MemoryContext]:
"""Resolve trusted actor access from the stored scope, never a client scope."""
if not memory_id or not actor or not backend.settings.memory.enabled:
@@ -27,30 +48,9 @@ async def authorized_memory_context(memory_id: str, actor: str) -> Optional[Memo
scope = item.get("user_id") or metadata.get("user_id")
workspace = str(metadata.get("workspace_id") or "default")
if workspace.startswith("project:"):
- from core.auth.edition import resolve_project_access
- from core.db.engine import SessionLocal
- from core.db.models import Project
- from core.projects.edition import project_memory_policy
-
- with SessionLocal() as db:
- project = db.query(Project).filter_by(
- project_id=workspace[len("project:"):], deleted_at=None).first()
- if project is None:
- return None
- access = resolve_project_access(db, actor, project)
- if access.level not in ("edit", "admin"):
- return None
- policy = project_memory_policy(db, project.project_id, actor)
- if policy is not None:
- # Read availability does not revoke explicit edits of existing memory.
- expected = policy[1]
- elif project.kind == "local":
- # CE has no policy row for local projects; actor access is checked above.
- expected = actor
- else:
- return None
- if scope != expected:
- return None
+ expected = await run_in_session(_project_memory_owner, workspace[len("project:"):], actor)
+ if expected is None or scope != expected:
+ return None
elif scope != actor:
return None
return MemoryContext(user_id=actor, workspace_id=workspace,
diff --git a/src/backend/core/memory/backend.py b/src/backend/core/memory/backend.py
index 599ea962..e2d787d7 100644
--- a/src/backend/core/memory/backend.py
+++ b/src/backend/core/memory/backend.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import threading
+from dataclasses import dataclass, field
from typing import Optional
from core.config.settings import settings
@@ -15,9 +16,6 @@
# One lock and instance per process; account epoch changes invalidate the cache.
-
-
-
_MIGRATION_MAX_ROWS = 10_000
@@ -475,21 +473,17 @@ def _is_auth_error(exc: Exception) -> bool:
)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class BackendState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
memory_instance: object = None
memory_epoch: Optional[str] = None
- memory_lock: object = state_field(default_factory=lambda: threading.Lock())
+ memory_lock: object = field(default_factory=lambda: threading.Lock())
memory_init_failed: object = False
embedding_patched: object = False
-@state_cache(maxsize=1)
-def process_state() -> BackendState:
- return BackendState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = BackendState()
+
+
+def process_state() -> BackendState:
+ return _STATE
diff --git a/src/backend/core/memory/context.py b/src/backend/core/memory/context.py
index a4654c08..8059cda2 100644
--- a/src/backend/core/memory/context.py
+++ b/src/backend/core/memory/context.py
@@ -7,7 +7,7 @@
from __future__ import annotations
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from typing import Literal, Optional
Confidentiality = Literal["public", "internal", "sensitive"]
diff --git a/src/backend/core/memory/effect_lane.py b/src/backend/core/memory/effect_lane.py
index 3b045aa4..bf8c2f85 100644
--- a/src/backend/core/memory/effect_lane.py
+++ b/src/backend/core/memory/effect_lane.py
@@ -6,6 +6,7 @@
import hashlib
import threading
from contextlib import asynccontextmanager
+from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from core.db.engine import SessionLocal
@@ -14,9 +15,6 @@
from sqlalchemy import text
-
-
-
class EffectLaneDeferred(RuntimeError):
def __init__(self, retry_at: datetime) -> None:
super().__init__("an older L2 effect must finish first")
@@ -54,6 +52,39 @@ def _oldest_nonterminal(db, scope_key: str):
return oldest
+class _Lane:
+ """The lane's session, touched only from worker threads.
+
+ The PostgreSQL advisory lock is transaction-scoped, so the lane must keep
+ one connection for the whole effect; async code holds this object, never
+ the session, and every method runs through ``run_effect``.
+ """
+
+ def __init__(self) -> None:
+ self.db = SessionLocal()
+
+ def dialect(self) -> str:
+ return self.db.bind.dialect.name if self.db.bind is not None else ""
+
+ def lock(self, scope_key: str) -> None:
+ self.db.execute(
+ text("SELECT pg_advisory_xact_lock(:lane_key)"),
+ {"lane_key": _advisory_key(scope_key)},
+ )
+
+ def oldest(self, scope_key: str):
+ return _oldest_nonterminal(self.db, scope_key)
+
+ def rollback(self) -> None:
+ self.db.rollback()
+
+ def commit(self) -> None:
+ self.db.commit()
+
+ def close(self) -> None:
+ self.db.close()
+
+
@asynccontextmanager
async def ordered_l2_effect(scope_key: str, job_id: str):
"""Wait for this candidate's turn, then hold one lane through its effect.
@@ -65,22 +96,17 @@ async def ordered_l2_effect(scope_key: str, job_id: str):
its external receipt before recovery checks it.
"""
- db = SessionLocal()
- dialect = db.bind.dialect.name if db.bind is not None else ""
- local = _local_lock(scope_key) if dialect != "postgresql" else None
+ lane = await run_effect(_Lane)
+ local = _local_lock(scope_key) if await run_effect(lane.dialect) != "postgresql" else None
acquired = False
try:
if local is not None:
while not local.acquire(blocking=False):
await asyncio.sleep(0.05)
else:
- await run_effect(
- db.execute,
- text("SELECT pg_advisory_xact_lock(:lane_key)"),
- {"lane_key": _advisory_key(scope_key)},
- )
+ await run_effect(lane.lock, scope_key)
acquired = True
- oldest = _oldest_nonterminal(db, scope_key)
+ oldest = await run_effect(lane.oldest, scope_key)
if oldest is None or str(oldest[0]) != job_id:
retry_at = (oldest[1] or oldest[2]) if oldest is not None else None
if retry_at is None:
@@ -89,32 +115,28 @@ async def ordered_l2_effect(scope_key: str, job_id: str):
try:
yield
except BaseException:
- await run_effect(db.rollback)
+ await run_effect(lane.rollback)
raise
else:
- await run_effect(db.commit)
+ await run_effect(lane.commit)
except asyncio.CancelledError:
if acquired:
- await run_effect(db.rollback)
+ await run_effect(lane.rollback)
raise
finally:
- await run_effect(db.close)
+ await run_effect(lane.close)
if local is not None and acquired:
local.release()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class EffectLaneState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- local_locks: dict[str, threading.Lock] = state_field(default_factory=lambda: {})
- local_locks_guard: object = state_field(default_factory=lambda: threading.Lock())
+ local_locks: dict[str, threading.Lock] = field(default_factory=lambda: {})
+ local_locks_guard: object = field(default_factory=lambda: threading.Lock())
-@state_cache(maxsize=1)
-def process_state() -> EffectLaneState:
- return EffectLaneState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = EffectLaneState()
+
+
+def process_state() -> EffectLaneState:
+ return _STATE
diff --git a/src/backend/core/memory/extractors/_base.py b/src/backend/core/memory/extractors/_base.py
index 38d38bf5..78314153 100644
--- a/src/backend/core/memory/extractors/_base.py
+++ b/src/backend/core/memory/extractors/_base.py
@@ -11,6 +11,7 @@
import json
import logging
import re
+from dataclasses import dataclass, field
from datetime import date
from typing import Any, Optional
@@ -22,9 +23,6 @@
# Lazily-loaded OpenAI client + resolved model name (avoids errors at import time)
-
-
-
def _resolve_memory_model_config() -> tuple[str, str, str]:
"""Returns (base_url, api_key, model_name).
@@ -55,9 +53,6 @@ def _client_identity(base_url: str, api_key: str) -> tuple:
return (base_url, api_key, _state_fingerprint(bind(api_key, base_url)))
-
-
-
async def _get_client() -> tuple[object, str]:
"""A dedicated memory LLM client, not shared with the main conversation. Returns (client, model_name)."""
base_url, api_key, model_name = _resolve_memory_model_config()
@@ -287,20 +282,16 @@ def fill_prompt(
return template
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class BaseState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
client: object = None
model_name: Optional[str] = None
- client_lock: object = state_field(default_factory=lambda: asyncio.Lock())
+ client_lock: object = field(default_factory=lambda: asyncio.Lock())
client_identity_key: tuple | None = None
-@state_cache(maxsize=1)
-def process_state() -> BaseState:
- return BaseState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = BaseState()
+
+
+def process_state() -> BaseState:
+ return _STATE
diff --git a/src/backend/core/memory/graph.py b/src/backend/core/memory/graph.py
index a67eccc8..35c49c3e 100644
--- a/src/backend/core/memory/graph.py
+++ b/src/backend/core/memory/graph.py
@@ -1,4 +1,4 @@
-"""graph responsibilities."""
+"""Read and write L3 graph relations in Neo4j."""
from __future__ import annotations
import asyncio
import hashlib
@@ -7,19 +7,20 @@
from core.memory.context import MemoryContext
from core.memory.executor import run_effect
import logging
+from core.memory import graph_contract
+from core.memory import graph_state
+
logger = logging.getLogger(__name__)
-from core.memory import graph_contract as graph_contract_module
-from core.memory import graph_state as graph_state_module
def get_driver():
"""Lazily create the thread-safe Neo4j driver; failures are retryable."""
if not (settings.memory.enabled and settings.memory.graph_enabled):
return None
- if graph_state_module.process_state().driver is not None:
- return graph_state_module.process_state().driver
- with graph_state_module.process_state().driver_lock:
- if graph_state_module.process_state().driver is not None:
- return graph_state_module.process_state().driver
+ if graph_state.process_state().driver is not None:
+ return graph_state.process_state().driver
+ with graph_state.process_state().driver_lock:
+ if graph_state.process_state().driver is not None:
+ return graph_state.process_state().driver
try:
from neo4j import GraphDatabase
@@ -29,24 +30,24 @@ def get_driver():
connection_timeout=3.0,
)
driver.verify_connectivity()
- graph_state_module.process_state().driver = driver
- if not graph_state_module.process_state().schema_ready:
+ graph_state.process_state().driver = driver
+ if not graph_state.process_state().schema_ready:
ensure_schema(driver)
- graph_state_module.process_state().schema_ready = True
+ graph_state.process_state().schema_ready = True
logger.info("[GraphMemory] Neo4j L3 initialized")
- return graph_state_module.process_state().driver
+ return graph_state.process_state().driver
except Exception as exc: # noqa: BLE001 - optional memory layer
logger.warning(
"[GraphMemory] initialization failed; L3 disabled for this attempt: %s", exc
)
- graph_state_module.process_state().driver = None
+ graph_state.process_state().driver = None
return None
def reset_driver() -> None:
- with graph_state_module.process_state().driver_lock:
- current, graph_state_module.process_state().driver = graph_state_module.process_state().driver, None
- graph_state_module.process_state().schema_ready = False
+ with graph_state.process_state().driver_lock:
+ current, graph_state.process_state().driver = graph_state.process_state().driver, None
+ graph_state.process_state().schema_ready = False
if current is not None:
try:
current.close()
@@ -70,7 +71,7 @@ def ensure_schema(driver) -> None:
logger.warning("[GraphMemory] schema setup skipped: %s", exc)
-def write_sync(ctx: MemoryContext, relations: Sequence[graph_contract_module.GraphRelation]) -> list[Dict[str, Any]]:
+def write_sync(ctx: MemoryContext, relations: Sequence[graph_contract.GraphRelation]) -> list[Dict[str, Any]]:
driver = get_driver()
if driver is None:
return []
@@ -81,19 +82,19 @@ def write_sync(ctx: MemoryContext, relations: Sequence[graph_contract_module.Gra
try:
with driver.session() as session:
for relation in relations:
- relation_id = graph_contract_module.relation_id_for(
+ relation_id = graph_contract.relation_id_for(
relation,
scope_user_id=ctx.effective_scope_user_id,
workspace_id=ctx.workspace_id,
)
record = session.run(
- graph_state_module.UPSERT_CYPHER,
+ graph_state.UPSERT_CYPHER,
scope_user_id=ctx.effective_scope_user_id,
workspace_id=ctx.workspace_id or "default",
- source_key=graph_contract_module.entity_key(relation.source, relation.source_type),
+ source_key=graph_contract.entity_key(relation.source, relation.source_type),
source=relation.source,
source_type=relation.source_type,
- target_key=graph_contract_module.entity_key(relation.target, relation.target_type),
+ target_key=graph_contract.entity_key(relation.target, relation.target_type),
target=relation.target,
target_type=relation.target_type,
relation_id=relation_id,
@@ -124,7 +125,7 @@ def write_sync(ctx: MemoryContext, relations: Sequence[graph_contract_module.Gra
async def write_graph_relations(
- ctx: MemoryContext, relations: Sequence[graph_contract_module.GraphRelation]
+ ctx: MemoryContext, relations: Sequence[graph_contract.GraphRelation]
) -> list[Dict[str, Any]]:
"""Persist validated relations without blocking the event loop."""
if not relations:
@@ -138,7 +139,7 @@ def read_sync(
driver = get_driver()
if driver is None:
return []
- statement = graph_state_module.SEARCH_CYPHER if query.strip() else graph_state_module.LIST_CYPHER
+ statement = graph_state.SEARCH_CYPHER if query.strip() else graph_state.LIST_CYPHER
try:
with driver.session() as session:
result = session.run(
diff --git a/src/backend/core/memory/graph_contract.py b/src/backend/core/memory/graph_contract.py
index 18a148e5..3a8d4a23 100644
--- a/src/backend/core/memory/graph_contract.py
+++ b/src/backend/core/memory/graph_contract.py
@@ -1,10 +1,10 @@
-"""graph contract responsibilities."""
+"""Validate and normalise extracted graph relations."""
from __future__ import annotations
import hashlib
import unicodedata
from dataclasses import dataclass
from typing import Any, Dict, Optional, Sequence
-from core.memory import graph_state as graph_state_module
+from core.memory import graph_state
@dataclass(frozen=True)
class GraphRelation:
@@ -19,7 +19,7 @@ class GraphRelation:
@property
def relationship(self) -> str:
- return graph_state_module.PREDICATE_LABELS[self.predicate]
+ return graph_state.PREDICATE_LABELS[self.predicate]
@property
def text(self) -> str:
@@ -44,21 +44,21 @@ def parse_relation(raw: Any) -> Optional[GraphRelation]:
source = clean_name(raw.get("source"))
target = clean_name(raw.get("target"))
predicate = str(raw.get("predicate") or "").strip().lower()
- if not source or not target or source == target or predicate not in graph_state_module.PREDICATE_LABELS:
+ if not source or not target or source == target or predicate not in graph_state.PREDICATE_LABELS:
return None
source_type = str(raw.get("source_type") or "other").strip().lower()
target_type = str(raw.get("target_type") or "other").strip().lower()
- if source_type not in graph_state_module.ALLOWED_ENTITY_TYPES:
+ if source_type not in graph_state.ALLOWED_ENTITY_TYPES:
source_type = "other"
- if target_type not in graph_state_module.ALLOWED_ENTITY_TYPES:
+ if target_type not in graph_state.ALLOWED_ENTITY_TYPES:
target_type = "other"
try:
confidence = float(raw.get("confidence", 0.8))
except (TypeError, ValueError):
confidence = 0.8
confidence = max(0.0, min(1.0, confidence))
- if confidence < graph_state_module.MIN_CONFIDENCE:
+ if confidence < graph_state.MIN_CONFIDENCE:
return None
return GraphRelation(
source=source,
@@ -87,21 +87,21 @@ def parse_relations(raw_items: Sequence[Any]) -> list[GraphRelation]:
continue
seen.add(key)
relations.append(relation)
- if len(relations) >= graph_state_module.MAX_RELATIONS_PER_TURN:
+ if len(relations) >= graph_state.MAX_RELATIONS_PER_TURN:
break
return relations
def clean_name(value: Any) -> str:
name = unicodedata.normalize("NFKC", str(value or ""))
- name = graph_state_module.SAFE_NAME.sub(" ", name).strip(" \t\r\n,,。;;::")
- if len(name) < 2 or len(name) > graph_state_module.MAX_ENTITY_CHARS:
+ name = graph_state.SAFE_NAME.sub(" ", name).strip(" \t\r\n,,。;;::")
+ if len(name) < 2 or len(name) > graph_state.MAX_ENTITY_CHARS:
return ""
return name
def normalise_name(value: str) -> str:
- return graph_state_module.SAFE_NAME.sub(" ", unicodedata.normalize("NFKC", value).lower()).strip()
+ return graph_state.SAFE_NAME.sub(" ", unicodedata.normalize("NFKC", value).lower()).strip()
def entity_key(name: str, entity_type: str) -> str:
diff --git a/src/backend/core/memory/graph_state.py b/src/backend/core/memory/graph_state.py
index 4fadd720..29b61713 100644
--- a/src/backend/core/memory/graph_state.py
+++ b/src/backend/core/memory/graph_state.py
@@ -1,10 +1,9 @@
-"""graph state responsibilities."""
+"""Graph vocabulary, limits, Cypher queries and the shared driver."""
from __future__ import annotations
import re
import threading
+from dataclasses import dataclass, field
from typing import Dict
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
PREDICATE_LABELS: Dict[str, str] = {
"is_a": "属于类别",
@@ -128,16 +127,15 @@
"""
-@state_dataclass
+@dataclass
class GraphState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
driver: object = None
- driver_lock: object = state_field(default_factory=lambda: threading.Lock())
+ driver_lock: object = field(default_factory=lambda: threading.Lock())
schema_ready: object = False
-@state_cache(maxsize=1)
-def process_state() -> GraphState:
- return GraphState()
+_STATE = GraphState()
+
-process_state()
+def process_state() -> GraphState:
+ return _STATE
diff --git a/src/backend/core/memory/outbox/enqueue.py b/src/backend/core/memory/outbox/enqueue.py
index ee2a0420..7a081f30 100644
--- a/src/backend/core/memory/outbox/enqueue.py
+++ b/src/backend/core/memory/outbox/enqueue.py
@@ -11,8 +11,9 @@
from core.memory.extractors.router import ExtractorType
from sqlalchemy.exc import IntegrityError
+from core.memory.outbox import common
+
logger = logging.getLogger(__name__)
-from core.memory.outbox import common as common
def _enqueue(
diff --git a/src/backend/core/memory/outbox/jobs.py b/src/backend/core/memory/outbox/jobs.py
index 4a7db90e..9bdc09e5 100644
--- a/src/backend/core/memory/outbox/jobs.py
+++ b/src/backend/core/memory/outbox/jobs.py
@@ -11,8 +11,9 @@
from core.memory.context import MemoryContext
from core.memory.extractors.router import ExtractorType
+from core.memory.outbox import common
+
logger = logging.getLogger(__name__)
-from core.memory.outbox import common as common
async def _extract_candidates(
diff --git a/src/backend/core/memory/outbox/leases.py b/src/backend/core/memory/outbox/leases.py
index 37cbc2e4..36ea6463 100644
--- a/src/backend/core/memory/outbox/leases.py
+++ b/src/backend/core/memory/outbox/leases.py
@@ -8,10 +8,12 @@
from typing import Any, Optional
from core.db.models import MemoryOutbox
+from core.db.threads import run_db
from sqlalchemy import and_, or_
+from core.memory.outbox import common
+
logger = logging.getLogger(__name__)
-from core.memory.outbox import common as common
def _claim_next(worker_id: str) -> Optional[str]:
@@ -203,7 +205,7 @@ async def _lease_heartbeat(
while True:
await asyncio.sleep(interval)
try:
- renewed = _renew_lease(job_id, worker_id)
+ renewed = await run_db(_renew_lease, job_id, worker_id)
except Exception:
logger.exception("[memory_outbox] lease renewal failed id=%s", job_id)
renewed = False
diff --git a/src/backend/core/memory/outbox/settlement.py b/src/backend/core/memory/outbox/settlement.py
index b3879faf..bedacf46 100644
--- a/src/backend/core/memory/outbox/settlement.py
+++ b/src/backend/core/memory/outbox/settlement.py
@@ -8,8 +8,9 @@
from core.db.models import MemoryOutbox
+from core.memory.outbox import common
+
logger = logging.getLogger(__name__)
-from core.memory.outbox import common as common
def _lock_pipeline_row(db, message_id: str) -> Optional[MemoryOutbox]:
diff --git a/src/backend/core/memory/outbox/worker.py b/src/backend/core/memory/outbox/worker.py
index 4f80c65e..11211396 100644
--- a/src/backend/core/memory/outbox/worker.py
+++ b/src/backend/core/memory/outbox/worker.py
@@ -7,26 +7,33 @@
import os
import socket
import uuid
+from dataclasses import dataclass
from typing import Any, Optional
from core.db.models import MemoryOutbox
+from core.db.threads import run_db, run_in_session
from core.memory.effect_lane import EffectLaneDeferred, ordered_l2_effect
+from core.memory.outbox import common
+from core.memory.outbox import jobs
+from core.memory.outbox import leases
+from core.memory.outbox import settlement
+
logger = logging.getLogger(__name__)
-from core.memory.outbox import common as common
-from core.memory.outbox import jobs as jobs
-from core.memory.outbox import leases as leases
-from core.memory.outbox import settlement as settlement
+def _claimed_row(db, job_id: str, worker_id: str) -> Optional[MemoryOutbox]:
+ row = db.get(MemoryOutbox, job_id)
+ if row is None or row.status != "processing" or row.lease_owner != worker_id:
+ return None
+ db.expunge(row)
+ return row
async def _process_claimed(job_id: str, worker_id: str) -> None:
- with common.SessionLocal() as db:
- row = db.get(MemoryOutbox, job_id)
- if row is None or row.status != "processing" or row.lease_owner != worker_id:
- return
- db.expunge(row)
+ row = await run_in_session(_claimed_row, job_id, worker_id)
+ if row is None:
+ return
owner_task = asyncio.current_task()
if owner_task is None: # pragma: no cover - asyncio owns this coroutine
@@ -40,7 +47,7 @@ async def _process_claimed(job_id: str, worker_id: str) -> None:
result: Any
async with get_background_semaphore():
- if not leases._owns_lease(job_id, worker_id):
+ if not await run_db(leases._owns_lease, job_id, worker_id):
lease_lost.set()
return
if row.job_kind == "pipeline":
@@ -67,20 +74,20 @@ async def _process_claimed(job_id: str, worker_id: str) -> None:
return
raise
except EffectLaneDeferred as exc:
- leases._defer_claim(job_id, worker_id, exc.retry_at)
+ await run_db(leases._defer_claim, job_id, worker_id, exc.retry_at)
except Exception as exc:
logger.warning("[memory_outbox] job failed id=%s: %s", job_id, exc)
- settlement._finish_failure(job_id, worker_id, exc)
+ await run_db(settlement._finish_failure, job_id, worker_id, exc)
else:
try:
- settlement._finish_success(job_id, worker_id, result)
+ await run_db(settlement._finish_success, job_id, worker_id, result)
except Exception as exc:
# Processing can succeed while the atomic local acknowledgement
# fails (for example the assistant message has not committed yet).
# Roll the owned row into retry instead of parking it until lease
# expiry; external receipts make replay safe.
logger.warning("[memory_outbox] acknowledgement failed id=%s: %s", job_id, exc)
- settlement._finish_failure(job_id, worker_id, exc)
+ await run_db(settlement._finish_failure, job_id, worker_id, exc)
finally:
heartbeat.cancel()
try:
@@ -93,7 +100,7 @@ async def drain_outbox(*, max_jobs: int = 100, worker_id: Optional[str] = None)
worker = worker_id or f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
processed = 0
while processed < max_jobs:
- job_id = leases._claim_next(worker)
+ job_id = await run_db(leases._claim_next, worker)
if job_id is None:
break
await _process_claimed(job_id, worker)
@@ -105,11 +112,11 @@ async def consume_outbox_job(job_id: str, *, wait_s: float = 5.0) -> dict[str, A
"""Try an admitted interactive edit now, while leaving retry durability intact."""
worker_id = f"api:{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
- if leases._claim_specific(job_id, worker_id):
+ if await run_db(leases._claim_specific, job_id, worker_id):
await _process_claimed(job_id, worker_id)
deadline = asyncio.get_running_loop().time() + max(0.0, wait_s)
while True:
- snapshot = leases.get_outbox_job(job_id)
+ snapshot = await run_db(leases.get_outbox_job, job_id)
if snapshot is None:
raise RuntimeError(f"outbox job disappeared: {job_id}")
if snapshot["status"] in common._TERMINAL or snapshot["status"] == "retry":
@@ -139,7 +146,7 @@ async def start(self) -> None:
if self._task is None or self._task.done():
self._stop.clear()
self._wake.clear()
- settlement.reconcile_settlement_jobs()
+ await run_db(settlement.reconcile_settlement_jobs)
process_state().active_worker = self
self._task = asyncio.create_task(self._loop(), name="memory-outbox-worker")
@@ -156,7 +163,7 @@ async def stop(self) -> None:
await self._task
except asyncio.CancelledError:
pass
- leases._release_worker_leases(self.worker_id)
+ await run_db(leases._release_worker_leases, self.worker_id)
self._task = None
def wake(self) -> None:
@@ -183,17 +190,13 @@ async def _loop(self) -> None:
self._wake.clear()
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class WorkerState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
active_worker: Optional['MemoryOutboxWorker'] = None
-@state_cache(maxsize=1)
-def process_state() -> WorkerState:
- return WorkerState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = WorkerState()
+
+
+def process_state() -> WorkerState:
+ return _STATE
diff --git a/src/backend/core/memory/pipeline.py b/src/backend/core/memory/pipeline.py
index a4769f44..65a56cf9 100644
--- a/src/backend/core/memory/pipeline.py
+++ b/src/backend/core/memory/pipeline.py
@@ -16,6 +16,7 @@
import asyncio
import logging
+from dataclasses import dataclass, field
from threading import Lock
from typing import Optional
@@ -30,10 +31,6 @@
# ─── Global semaphore (lazy init) ──────────────────────────────────────────
-
-
-
-
def get_background_semaphore() -> asyncio.Semaphore:
"""Return the global background-task semaphore. Must be first called inside an event loop."""
with process_state().sem_lock:
@@ -159,18 +156,14 @@ def _report_settlement(
logger.debug("[memory_pipeline] settlement report skipped: %s", exc)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class PipelineState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
bg_semaphore: Optional[asyncio.Semaphore] = None
- sem_lock: object = state_field(default_factory=lambda: Lock())
+ sem_lock: object = field(default_factory=lambda: Lock())
-@state_cache(maxsize=1)
-def process_state() -> PipelineState:
- return PipelineState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = PipelineState()
+
+
+def process_state() -> PipelineState:
+ return _STATE
diff --git a/src/backend/core/memory/procedures.py b/src/backend/core/memory/procedures.py
index 5d74b2fd..d3bd5705 100644
--- a/src/backend/core/memory/procedures.py
+++ b/src/backend/core/memory/procedures.py
@@ -11,9 +11,10 @@
from core.memory.executor import run_effect
from core.memory.ttl_sweeper import memory_entry_expired
-logger = logging.getLogger(__name__)
from . import backend
+logger = logging.getLogger(__name__)
+
def _expiration_from_ttl(ttl_days: int) -> Optional[str]:
"""UTC expiry date owned by L2; mem0 stores it as ordinary metadata."""
diff --git a/src/backend/core/memory/queries.py b/src/backend/core/memory/queries.py
index 122e7589..dcac2bcb 100644
--- a/src/backend/core/memory/queries.py
+++ b/src/backend/core/memory/queries.py
@@ -11,9 +11,10 @@
from core.memory.ttl_sweeper import memory_entry_expired
from core.memory.retrieval_types import MemoryRetrievalResult, RetrievedRelation, build_memory_item
-logger = logging.getLogger(__name__)
from . import backend
+logger = logging.getLogger(__name__)
+
async def retrieve_memories(
user_id: str,
diff --git a/src/backend/core/memory/sanitizer.py b/src/backend/core/memory/sanitizer.py
index 5761a620..e16d91ab 100644
--- a/src/backend/core/memory/sanitizer.py
+++ b/src/backend/core/memory/sanitizer.py
@@ -87,8 +87,6 @@ def clean(self) -> bool:
_RULES_CACHE_TTL = 300 # seconds
-
-
def invalidate_rules_cache() -> None:
"""Called proactively after an admin adds/removes rules; the next sanitize re-pulls from the DB."""
with process_state().rules_lock:
@@ -179,18 +177,14 @@ def sanitize(text: str) -> SanitizeResult:
return SanitizeResult(text=cleaned, hits=hits, reject=False)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class SanitizerState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- rules_cache: dict = state_field(default_factory=lambda: {'patterns': None, 'classified': None, 'loaded_at': 0.0})
- rules_lock: object = state_field(default_factory=lambda: Lock())
+ rules_cache: dict = field(default_factory=lambda: {'patterns': None, 'classified': None, 'loaded_at': 0.0})
+ rules_lock: object = field(default_factory=lambda: Lock())
-@state_cache(maxsize=1)
-def process_state() -> SanitizerState:
- return SanitizerState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = SanitizerState()
+
+
+def process_state() -> SanitizerState:
+ return _STATE
diff --git a/src/backend/core/memory/settlement_port.py b/src/backend/core/memory/settlement_port.py
index 5282dbc4..6bb3327c 100644
--- a/src/backend/core/memory/settlement_port.py
+++ b/src/backend/core/memory/settlement_port.py
@@ -1,5 +1,6 @@
"""Memory's named settlement boundary; callers retain their transaction scope."""
from __future__ import annotations
+from dataclasses import dataclass
from typing import Any, Protocol
@@ -10,9 +11,6 @@ def build_summary(self, message_id: str, *, session_factory: Any, items: list[di
def write_summary(self, db: Any, message_id: str, summary: Any) -> None: ...
-
-
-
def register_memory_settlement(service: MemorySettlement) -> None:
process_state().service = service
@@ -23,17 +21,13 @@ def memory_settlement() -> MemorySettlement:
return process_state().service
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class SettlementPortState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
service: MemorySettlement | None = None
-@state_cache(maxsize=1)
-def process_state() -> SettlementPortState:
- return SettlementPortState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = SettlementPortState()
+
+
+def process_state() -> SettlementPortState:
+ return _STATE
diff --git a/src/backend/core/memory/weights.py b/src/backend/core/memory/weights.py
index d41ea157..1fc6a885 100644
--- a/src/backend/core/memory/weights.py
+++ b/src/backend/core/memory/weights.py
@@ -29,7 +29,8 @@
import logging
import time
-from typing import Dict, Optional, Tuple
+from dataclasses import dataclass, field
+from typing import Dict, Tuple
logger = logging.getLogger(__name__)
@@ -48,7 +49,6 @@
_CACHE_TTL_S = 30.0
-
def _cache_key(user_id: str, workspace_id: str) -> str:
return f"{user_id}|{workspace_id or 'default'}"
@@ -156,18 +156,13 @@ def apply_overlay(
return kept
+@dataclass
+class WeightsState:
+ cache: Dict[str, Tuple[float, Dict[str, float], set]] = field(default_factory=lambda: {})
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-@state_dataclass
-class WeightsState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- cache: Dict[str, Tuple[float, Dict[str, float], set]] = state_field(default_factory=lambda: {})
+_STATE = WeightsState()
-@state_cache(maxsize=1)
-def process_state() -> WeightsState:
- return WeightsState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+def process_state() -> WeightsState:
+ return _STATE
diff --git a/src/backend/core/models/api_protocol_backfill.py b/src/backend/core/models/api_protocol_backfill.py
index 5e3ec4ba..8e9a8e8e 100644
--- a/src/backend/core/models/api_protocol_backfill.py
+++ b/src/backend/core/models/api_protocol_backfill.py
@@ -27,68 +27,80 @@ def _needs_probe(extra: dict) -> bool:
return int(extra.get("api_protocol_probe_version") or 1) < PROBE_VERSION
+def _pending_probes(db):
+ from core.models.repository import list_providers
+ from core.models.providers.registry import get_spec
+
+ pending = []
+ for provider in list_providers(db):
+ if provider.provider_type != "chat" or not provider.is_active:
+ continue
+ extra = provider.extra_config or {}
+ if not _needs_probe(extra):
+ continue
+ if not get_spec(getattr(provider, "provider", "") or "").speaks_responses:
+ continue
+ pending.append(
+ (
+ provider.provider_id,
+ provider.model_name,
+ provider.base_url,
+ provider.api_key,
+ dict(extra),
+ )
+ )
+ return pending
+
+
+def _save_protocols(db, updates):
+ from core.models.repository import update_provider
+
+ for provider_id, extra in updates:
+ update_provider(db, provider_id, extra_config=extra)
+
+
async def backfill_missing_api_protocol() -> int:
"""探测所有还没记录协议(或结论已过期)的 chat 上游并写库,返回写入的条数。"""
- from core.db.engine import SessionLocal
- from core.models.repository import list_providers, update_provider
+ from core.db.threads import run_in_session
from core.models.providers.protocol_probe import PROBE_VERSION, detect_api_protocol
- from core.models.providers.registry import get_spec
- with SessionLocal() as db:
- pending = []
- for provider in list_providers(db):
- if provider.provider_type != "chat" or not provider.is_active:
- continue
- extra = provider.extra_config or {}
- if not _needs_probe(extra):
- continue
- if not get_spec(getattr(provider, "provider", "") or "").speaks_responses:
- continue
- pending.append(
- (
- provider.provider_id,
- provider.model_name,
- provider.base_url,
- provider.api_key,
- dict(extra),
- )
- )
+ pending = await run_in_session(_pending_probes)
+ if not pending:
+ return 0
- if not pending:
- return 0
-
- results = await asyncio.gather(
- *(
- # 超时沿用 detect_api_protocol 自己的默认值:保存时探测与这里补齐是
- # 同一件事,给它第二个数只会让两条路径对同一个上游得出不同结论。
- detect_api_protocol(
- base_url=base_url,
- api_key=api_key,
- model_name=model_name,
- )
- for _, model_name, base_url, api_key, _ in pending
- ),
- return_exceptions=True,
- )
+ results = await asyncio.gather(
+ *(
+ # 超时沿用 detect_api_protocol 自己的默认值:保存时探测与这里补齐是
+ # 同一件事,给它第二个数只会让两条路径对同一个上游得出不同结论。
+ detect_api_protocol(
+ base_url=base_url,
+ api_key=api_key,
+ model_name=model_name,
+ )
+ for _, model_name, base_url, api_key, _ in pending
+ ),
+ return_exceptions=True,
+ )
- filled = 0
- for (provider_id, model_name, _, _, extra), result in zip(pending, results):
- if isinstance(result, BaseException) or not getattr(result, "found", False):
- logger.info(
- "[startup] api_protocol 未能探测 %s:%s",
- model_name,
- result if isinstance(result, BaseException) else "; ".join(result.notes),
- )
- continue
- extra["api_protocol"] = result.protocol
- extra["api_protocol_source"] = "probe"
- extra["api_protocol_probe_version"] = PROBE_VERSION
- update_provider(db, provider_id, extra_config=extra)
- filled += 1
+ updates = []
+ for (provider_id, model_name, _, _, extra), result in zip(pending, results):
+ if isinstance(result, BaseException) or not getattr(result, "found", False):
logger.info(
- "[startup] api_protocol 已探明 %s -> %s(%s)",
+ "[startup] api_protocol 未能探测 %s:%s",
model_name,
- result.protocol,
- "; ".join(result.notes),
+ result if isinstance(result, BaseException) else "; ".join(result.notes),
)
- return filled
+ continue
+ extra["api_protocol"] = result.protocol
+ extra["api_protocol_source"] = "probe"
+ extra["api_protocol_probe_version"] = PROBE_VERSION
+ updates.append((provider_id, extra))
+ logger.info(
+ "[startup] api_protocol 已探明 %s -> %s(%s)",
+ model_name,
+ result.protocol,
+ "; ".join(result.notes),
+ )
+ if updates:
+ await run_in_session(_save_protocols, updates)
+ return len(updates)
diff --git a/src/backend/core/models/chat_models.py b/src/backend/core/models/chat_models.py
index dd7929b5..79ad6fb1 100755
--- a/src/backend/core/models/chat_models.py
+++ b/src/backend/core/models/chat_models.py
@@ -4,6 +4,7 @@
import logging
import os
+from dataclasses import dataclass, field
from typing import Any, Optional
import httpx
@@ -466,17 +467,13 @@ def get_summarize_model(cfg: ModelConfig | None = None) -> ChatModelBase:
]
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class ChatModelsState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- HTTP_CLIENTS: dict[tuple[int, float], httpx.AsyncClient] = state_field(default_factory=lambda: {})
+ HTTP_CLIENTS: dict[tuple[int, float], httpx.AsyncClient] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> ChatModelsState:
- return ChatModelsState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = ChatModelsState()
+
+
+def process_state() -> ChatModelsState:
+ return _STATE
diff --git a/src/backend/core/models/completions/classifier.py b/src/backend/core/models/completions/classifier.py
index 1c89b4d5..c31f171d 100644
--- a/src/backend/core/models/completions/classifier.py
+++ b/src/backend/core/models/completions/classifier.py
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
+from dataclasses import dataclass
from typing import Dict, List, Optional
from core.config.settings import settings
@@ -88,24 +89,19 @@ async def classify_conversation(
# Singleton instance
-
def get_classifier() -> ConversationClassifier:
if process_state().classifier is None:
process_state().classifier = ConversationClassifier()
return process_state().classifier
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class ClassifierState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
classifier: ConversationClassifier | None = None
-@state_cache(maxsize=1)
-def process_state() -> ClassifierState:
- return ClassifierState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = ClassifierState()
+
+
+def process_state() -> ClassifierState:
+ return _STATE
diff --git a/src/backend/core/models/completions/summarizer.py b/src/backend/core/models/completions/summarizer.py
index 68753742..3f9b27b7 100644
--- a/src/backend/core/models/completions/summarizer.py
+++ b/src/backend/core/models/completions/summarizer.py
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
+from dataclasses import dataclass
from typing import Dict, List, Optional
from core.config.settings import settings
@@ -95,24 +96,19 @@ async def summarize_conversation(
# Singleton instance
-
def get_summarizer() -> ConversationSummarizer:
if process_state().summarizer is None:
process_state().summarizer = ConversationSummarizer()
return process_state().summarizer
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class SummarizerState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
summarizer: ConversationSummarizer | None = None
-@state_cache(maxsize=1)
-def process_state() -> SummarizerState:
- return SummarizerState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = SummarizerState()
+
+
+def process_state() -> SummarizerState:
+ return _STATE
diff --git a/src/backend/core/models/config.py b/src/backend/core/models/config.py
index e7a08d6f..9a457fed 100644
--- a/src/backend/core/models/config.py
+++ b/src/backend/core/models/config.py
@@ -9,7 +9,7 @@
import logging
import threading
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, replace
from typing import Any, Optional
from core.db.engine import SessionLocal
@@ -52,21 +52,40 @@ class ResolvedModelConfig:
extra: dict = field(default_factory=dict)
+@dataclass(frozen=True)
+class _Snapshot:
+ """One committed model configuration, published as a whole and never mutated.
+
+ ``revision`` is the database revision the rows were read under; ``None``
+ marks a snapshot that must not be trusted (nothing loaded yet, or a forced
+ reload).
+ """
+
+ revision: Optional[str]
+ version: int
+ roles: dict[str, ResolvedModelConfig] = field(default_factory=dict)
+ chat_providers: dict[str, ResolvedModelConfig] = field(default_factory=dict)
+ providers: dict[str, ResolvedModelConfig] = field(default_factory=dict)
+ chain: tuple[ResolvedModelConfig, ...] = ()
+
+
class ModelConfigService:
- """Thread-safe singleton that resolves role → model config from DB."""
+ """Thread-safe singleton that resolves role → model config from DB.
+
+ Every lookup compares the committed revision (one primary-key read) with the
+ snapshot it holds, so a change committed by any worker or process is seen
+ on the next lookup. Lookups never wait on each other: the snapshot is
+ swapped atomically, and only a revision mismatch takes the refresh lock,
+ which one thread holds while the others that saw the same mismatch wait for
+ its result instead of reading the tables again.
+ """
_instance: Optional["ModelConfigService"] = None
_lock = threading.Lock()
def __init__(self) -> None:
- self._cache: dict[str, Optional[ResolvedModelConfig]] = {}
- self._revision: str | None = None
- self._cache_lock = threading.RLock()
- # All runtime views share one committed revision.
- self._provider_cache: dict[str, Optional[ResolvedModelConfig]] = {}
- # Failover candidate order belongs to the same snapshot.
- self._chain_cache: Optional[list[ResolvedModelConfig]] = None
- self._version: int = 0 # bumped on invalidate
+ self._snapshot = _Snapshot(revision=None, version=0)
+ self._refresh_lock = threading.Lock()
@classmethod
def get_instance(cls) -> "ModelConfigService":
@@ -78,55 +97,62 @@ def get_instance(cls) -> "ModelConfigService":
@property
def version(self) -> int:
- self._maybe_refresh()
- return self._version
+ return self._current().version
# ── resolve ────────────────────────────────────────────────────────
def resolve(self, role_key: str) -> Optional[ResolvedModelConfig]:
- with self._cache_lock:
- self._maybe_refresh()
- return self._cache.get(role_key)
+ return self._current().roles.get(role_key)
def resolve_provider(self, provider_id: str) -> Optional[ResolvedModelConfig]:
- with self._cache_lock:
- self._maybe_refresh()
- return self._provider_cache.get((provider_id or "").strip())
+ return self._current().chat_providers.get((provider_id or "").strip())
def resolve_failover_chain(
self, primary: Optional[ResolvedModelConfig]
) -> list[ResolvedModelConfig]:
- with self._cache_lock:
- self._maybe_refresh()
- chain = [
- cfg
- for cfg in self._chain_cache or []
- if primary is None or cfg.provider_id != primary.provider_id
- ]
- return ([primary] if primary else []) + chain
+ chain = [
+ cfg
+ for cfg in self._current().chain
+ if primary is None or cfg.provider_id != primary.provider_id
+ ]
+ return ([primary] if primary else []) + chain
def invalidate_cache(self) -> None:
- with self._cache_lock:
- self._revision = None
- self._version += 1
+ """Force the next lookup to reload, for rows written without a revision bump."""
+ # Under the refresh lock, so a reload already reading the old rows
+ # cannot publish them over this request.
+ with self._refresh_lock:
+ self._snapshot = replace(self._snapshot, revision=None)
- def _maybe_refresh(self) -> None:
+ def _current(self) -> _Snapshot:
from core.db.model_config_revision import read_revision
- with self._cache_lock, SessionLocal() as db:
- revision = read_revision(db)
- if revision == self._revision:
- return
- # Read into a new snapshot, then publish under the same lock. An
- # error propagates; it must not silently authorize stale models.
- from core.db.model_config_revision import REVISION_KEY
-
- snapshot_revision = (
- select(ContentBlock.payload)
- .where(ContentBlock.id == REVISION_KEY)
- .scalar_subquery()
- )
- # One SELECT has one MVCC snapshot even under READ COMMITTED.
+ snapshot = self._snapshot
+ observed = None
+ if snapshot.revision is not None:
+ with SessionLocal() as db:
+ observed = read_revision(db)
+ if observed == snapshot.revision:
+ return snapshot
+ with self._refresh_lock:
+ # A reload finished while this thread waited. It is current only if
+ # it carries the revision this lookup observed after it began.
+ current = self._snapshot
+ if observed is not None and current.revision == observed:
+ return current
+ # An error propagates; it must not silently authorize stale models.
+ self._snapshot = self._load(current.version + 1)
+ return self._snapshot
+
+ def _load(self, version: int) -> _Snapshot:
+ from core.db.model_config_revision import REVISION_KEY
+
+ snapshot_revision = (
+ select(ContentBlock.payload).where(ContentBlock.id == REVISION_KEY).scalar_subquery()
+ )
+ with SessionLocal() as db:
+ # One SELECT has one MVCC snapshot even under READ COMMITTED, so the
+ # revision it carries is exactly the one these rows belong to.
rows = (
db.query(ModelProvider, ModelRoleAssignment, snapshot_revision)
.outerjoin(
@@ -135,33 +161,35 @@ def _maybe_refresh(self) -> None:
)
.all()
)
- providers = list({p.provider_id: p for p, _, _ in rows if p.is_active}.values())
- resolved = {p.provider_id: self._provider_to_resolved(p) for p in providers}
- roles = {
+ if rows:
+ revision = str((rows[0][2] or {}).get("revision", ""))
+ else:
+ from core.db.model_config_revision import read_revision
+
+ revision = read_revision(db)
+ providers = list({p.provider_id: p for p, _, _ in rows if p.is_active}.values())
+ resolved = {p.provider_id: self._provider_to_resolved(p) for p in providers}
+ chat = [p for p in providers if p.provider_type == "chat"]
+ ranked = sorted(
+ chat,
+ key=lambda p: (
+ -int(p.weight or 1),
+ -resolved[p.provider_id].context_length,
+ p.provider_id,
+ ),
+ )
+ return _Snapshot(
+ revision=revision,
+ version=version,
+ roles={
a.role_key: resolved[a.provider_id]
for _, a, _ in rows
if a is not None and a.provider_id in resolved
- }
- chat = {
- p.provider_id: resolved[p.provider_id]
- for p in providers
- if p.provider_type == "chat"
- }
- if rows:
- revision = str((rows[0][2] or {}).get("revision", ""))
- ranked = sorted(
- (p for p in providers if p.provider_type == "chat"),
- key=lambda p: (
- -int(p.weight or 1),
- -resolved[p.provider_id].context_length,
- p.provider_id,
- ),
- )
- self._cache = roles
- self._provider_cache = chat
- self._chain_cache = [resolved[p.provider_id] for p in ranked]
- self._revision = revision
- self._version += 1
+ },
+ chat_providers={p.provider_id: resolved[p.provider_id] for p in chat},
+ providers=resolved,
+ chain=tuple(resolved[p.provider_id] for p in ranked),
+ )
@staticmethod
def _provider_to_resolved(provider: ModelProvider) -> ResolvedModelConfig:
@@ -194,46 +222,17 @@ def _provider_to_resolved(provider: ModelProvider) -> ResolvedModelConfig:
def get_context_length_by_model_name(self, model_name: str) -> Optional[int]:
"""Look up the context length (tokens) by model_name.
- Checks the role cache first, then falls back to scanning extra_config.context_length across all providers.
+ Role assignments win; any other active provider with that model name
+ (one not assigned to a role may still be referenced) is the fallback.
Returns None when not configured (the caller decides its own fallback strategy).
"""
- if not model_name:
- return None
- target = model_name.strip()
+ target = (model_name or "").strip()
if not target:
return None
-
- self._maybe_refresh()
- for cfg in self._cache.values():
- if cfg is None:
- continue
+ snapshot = self._current()
+ for cfg in (*snapshot.roles.values(), *snapshot.providers.values()):
if cfg.model_name == target and cfg.context_length > 0:
return cfg.context_length
-
- # Fallback: scan the providers table directly (a provider not assigned to any role may still be referenced)
- try:
- db = SessionLocal()
- try:
- rows = (
- db.query(ModelProvider)
- .filter(ModelProvider.model_name == target)
- .filter(ModelProvider.is_active == True) # noqa: E712
- .all()
- )
- for provider in rows:
- extra = provider.extra_config or {}
- raw = extra.get("context_length")
- if raw:
- try:
- val = int(raw)
- if val > 0:
- return val
- except (TypeError, ValueError):
- continue
- finally:
- db.close()
- except Exception as exc:
- logger.debug("[ModelConfigService] context_length lookup failed: %s", exc)
return None
# ── MCP env overlay ───────────────────────────────────────────────
diff --git a/src/backend/core/models/embedding.py b/src/backend/core/models/embedding.py
index d721d29c..6961e8fe 100644
--- a/src/backend/core/models/embedding.py
+++ b/src/backend/core/models/embedding.py
@@ -1,4 +1,4 @@
-"""vector embedding responsibilities."""
+"""Call the configured embedding service."""
from __future__ import annotations
from core.models import embedding_state
import os
diff --git a/src/backend/core/models/embedding_state.py b/src/backend/core/models/embedding_state.py
index f56c4e46..4b04376d 100644
--- a/src/backend/core/models/embedding_state.py
+++ b/src/backend/core/models/embedding_state.py
@@ -1,23 +1,20 @@
"""Embedding request limits and dimension cache, shared across consumers."""
from __future__ import annotations
import os
+from dataclasses import dataclass, field
EMBED_DIMS = int(os.getenv("MEM0_EMBED_DIMS", "1024"))
EMBED_BATCH_SIZE = max(1, int(os.getenv("KB_EMBED_BATCH_SIZE", "32")))
EMBED_TIMEOUT = max(1, int(os.getenv("KB_EMBED_TIMEOUT", "120")))
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+@dataclass
+class EmbeddingState:
+ DETECTED_DIM_CACHE: dict[tuple[str, str], int] = field(default_factory=lambda: {})
-@state_dataclass
-class EmbeddingStateState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- DETECTED_DIM_CACHE: dict[tuple[str, str], int] = state_field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> EmbeddingStateState:
- return EmbeddingStateState()
+_STATE = EmbeddingState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+
+def process_state() -> EmbeddingState:
+ return _STATE
diff --git a/src/backend/core/models/failover.py b/src/backend/core/models/failover.py
index 96a45bb5..4dffc1ed 100644
--- a/src/backend/core/models/failover.py
+++ b/src/backend/core/models/failover.py
@@ -28,6 +28,7 @@
import logging
import time
from contextvars import ContextVar
+from dataclasses import dataclass, field
from typing import Any, AsyncGenerator, Callable, Optional
from agentscope.message import Msg
@@ -44,7 +45,6 @@
PROVIDER_COOLDOWN_SECONDS = 120.0
-
# Which candidate answered the call in flight. The model instance is cached and
# shared by every concurrent run, so "who is answering" cannot live on it: one
# run falling back would otherwise rewrite the context window and the
@@ -404,17 +404,13 @@ def with_failover(
)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class FailoverState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- cooldown: dict[str, float] = state_field(default_factory=lambda: {})
+ cooldown: dict[str, float] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> FailoverState:
- return FailoverState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = FailoverState()
+
+
+def process_state() -> FailoverState:
+ return _STATE
diff --git a/src/backend/core/models/providers/openai_compat.py b/src/backend/core/models/providers/openai_compat.py
index 65cbf291..6b99fa76 100644
--- a/src/backend/core/models/providers/openai_compat.py
+++ b/src/backend/core/models/providers/openai_compat.py
@@ -432,7 +432,7 @@ async def _call_api( # type: ignore[override]
return _stream_with_bounded_retry(
self,
reissue=lambda: client.chat.completions.create(**kwargs),
- parse=lambda started, raw: self._parse_stream_response(started, raw, audio_fmt),
+ parse=lambda started, raw: self._parse_stream_response(started, raw),
model_name=model_name,
start_datetime=start_datetime,
response=response,
diff --git a/src/backend/core/models/providers/vendor_models.py b/src/backend/core/models/providers/vendor_models.py
index a7fb307b..b50e00a6 100644
--- a/src/backend/core/models/providers/vendor_models.py
+++ b/src/backend/core/models/providers/vendor_models.py
@@ -338,7 +338,7 @@ async def _call_api( # type: ignore[override]
response = await litellm.acompletion(**kwargs)
if self.stream:
- return self._parse_stream_response(start_datetime, response, "wav")
+ return self._parse_stream_response(start_datetime, response)
return self._parse_completion_response(start_datetime, response, "wav")
diff --git a/src/backend/core/models/reranking.py b/src/backend/core/models/reranking.py
index fb4e5395..a1c5b2f8 100644
--- a/src/backend/core/models/reranking.py
+++ b/src/backend/core/models/reranking.py
@@ -1,4 +1,4 @@
-"""vector reranking responsibilities."""
+"""Call the configured reranking service."""
from __future__ import annotations
import os
import requests
diff --git a/src/backend/core/models/responses.py b/src/backend/core/models/responses.py
index 7413f274..59b90981 100644
--- a/src/backend/core/models/responses.py
+++ b/src/backend/core/models/responses.py
@@ -30,7 +30,7 @@
from core.models.providers._image_tokens import ImageTokenCountingMixin
from core.models.reasoning_replay import ReasoningReplayMixin
from core.models.tool_call_identity import ToolCallIdentityMixin
-from core.models.tool_result_media import ResponsesToolMediaMixin
+from core.models.tool_result_media import NoDiskToolMediaMixin
logger = logging.getLogger(__name__)
@@ -42,14 +42,6 @@
# 只有这两个终态事件会带上完整的 response 对象(含 output 数组)。
_TERMINAL_EVENTS = ("response.completed", "response.incomplete")
-# Responses 有两条思考通道:``reasoning_summary_text`` 是经过删减的摘要(OpenAI 返回
-# 的那一条),``reasoning_text`` 是模型的思考原文(自建推理服务返回的那一条)。
-# AgentScope 的解析器只认前者,后者整条被丢掉——实测自建 deepseekv4-flash-vision 一轮
-# 发 144 帧 reasoning_text.delta,界面上就是一个空的思考块。两条通道对界面是同一件东西,
-# 所以在事件入口把后者翻成前者,流式逐字、累积、进历史全部照旧由父类处理。
-_REASONING_TEXT_DELTA = "response.reasoning_text.delta"
-_REASONING_SUMMARY_DELTA = "response.reasoning_summary_text.delta"
-
# 对齐 codex:让上游把思考加密串交还给我们自己保管,服务端不留会话状态。
_REASONING_INCLUDE = ["reasoning.encrypted_content"]
@@ -103,24 +95,10 @@ def _as_dict(item: Any) -> dict[str, Any]:
return {}
-class _ReasoningTextDelta:
- """把思考原文增量伪装成解析器认得的摘要增量。
-
- 父类只读 ``type`` 和 ``delta`` 两个字段(``response`` 用于取 response_id,这类事件
- 本来就没有),所以一个同形的小对象足够,不必构造 SDK 的类型。
- """
-
- __slots__ = ("type", "delta", "response", "summary_index")
-
- def __init__(self, delta: str) -> None:
- self.type = _REASONING_SUMMARY_DELTA
- self.delta = delta
- self.response = None
- self.summary_index = 0
-
-
class _ReasoningItemCapture:
- """透传上游 SSE,旁路留下完整 reasoning 条目,并接上思考原文通道。
+ """透传上游 SSE,旁路留下完整 reasoning 条目。
+
+ AgentScope 原生解析思考原文与摘要;保留事件身份及分段索引,避免合并不同条目。
旁路而非重写解析器:那 130 行逐事件拼装跟着 SDK 版本走,抄过来就得跟着维护。
"""
@@ -129,6 +107,13 @@ def __init__(self, stream: Any) -> None:
self._stream = stream
self.items: list[dict[str, Any]] = []
+ async def __aenter__(self):
+ await self._stream.__aenter__()
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ return await self._stream.__aexit__(exc_type, exc_value, traceback)
+
def __aiter__(self): # noqa: ANN204
return self._iter()
@@ -137,11 +122,6 @@ async def _iter(self): # noqa: ANN202
kind = getattr(event, "type", None)
if kind in _TERMINAL_EVENTS:
self._collect(getattr(event, "response", None))
- elif kind == _REASONING_TEXT_DELTA:
- delta = getattr(event, "delta", None)
- if delta:
- yield _ReasoningTextDelta(delta)
- continue
yield event
def _collect(self, response: Any) -> None:
@@ -216,39 +196,8 @@ def _restore_reasoning_items(msgs: list[Msg], items: list[dict[str, Any]]) -> li
return restored
-def _repair_tool_output_call_ids(
- msgs: list[Msg], items: list[dict[str, Any]]
-) -> list[dict[str, Any]]:
- """把工具结果的 ``call_id`` 换回发起那次调用时上游给的那一个。
-
- Responses 线上一次函数调用有**两个**互不相同的 id:条目 id(``function_call.id``)
- 与调用 id(``function_call.call_id``),回传结果时必须按后者配对。AgentScope 建
- ``ToolResultBlock`` 时只带上了条目 id(``id=tool_call.id``),格式化又直接拿它当
- ``function_call_output.call_id`` 发出去,于是两边对不上。
-
- DeepSeek 官方端点正是两者不同(条目 id 是 uuid,调用 id 形如 ``call_00_…``),整轮
- 请求会被打回 ``No tool call found for tool output with call_id …``;只要模型调了工具,
- 这一轮就必然失败。调用块自己同时带着这两个 id,这里按它把结果侧改正。
-
- 历史回放时旧块只剩条目 id,两侧都退回同一个值,配对依然自洽。
- """
- call_ids = {
- block.id: str(getattr(block, "call_id", None) or "")
- for msg in msgs
- for block in msg.content or []
- if isinstance(block, ToolCallBlock)
- }
- for item in items:
- if not isinstance(item, dict) or item.get("type") != "function_call_output":
- continue
- call_id = call_ids.get(str(item.get("call_id") or ""))
- if call_id:
- item["call_id"] = call_id
- return items
-
-
class ResponsesReplayFormatter(
- ReasoningReplayMixin, ResponsesToolMediaMixin, OpenAIResponseFormatter
+ ReasoningReplayMixin, NoDiskToolMediaMixin, OpenAIResponseFormatter
):
"""Responses 线格式化:思考按上游原样回传,工具媒体绝不落盘。
@@ -257,7 +206,7 @@ class ResponsesReplayFormatter(
造成当前模型的推理。
"""
- def _format_response_data_block(self, block: Any, role: str = "user") -> Any:
+ def _format_response_data_block(self, block: Any) -> Any:
"""给每个 ``input_image`` 补上 ``detail``——严格实现少了它直接 400。
AgentScope 只产出 ``type`` + ``image_url``,而 OpenAI 的
@@ -269,7 +218,7 @@ def _format_response_data_block(self, block: Any, role: str = "user") -> Any:
管这条线上的每张图,不只工具返回的:用户上传的图走的是同一个方法。
``setdefault`` 是为了不覆盖上游将来自己给出的值。
"""
- item = super()._format_response_data_block(block, role) # type: ignore[misc]
+ item = super()._format_response_data_block(block) # type: ignore[misc]
if isinstance(item, dict) and item.get("type") == "input_image":
item.setdefault("detail", "auto")
return item
@@ -280,7 +229,7 @@ async def format(self, msgs: list[Msg]) -> list[dict[str, Any]]:
for item in items:
if "role" in item and "content" in item:
item.setdefault("type", "message")
- return _restore_reasoning_items(msgs, _repair_tool_output_call_ids(msgs, items))
+ return _restore_reasoning_items(msgs, items)
class OpenAICompatResponsesModel(
diff --git a/src/backend/core/models/tool_result_media.py b/src/backend/core/models/tool_result_media.py
index 36d3acbb..00f067d4 100644
--- a/src/backend/core/models/tool_result_media.py
+++ b/src/backend/core/models/tool_result_media.py
@@ -14,8 +14,7 @@
:class:`NoDiskToolMediaMixin` 堵掉落盘和静默降级,两条线都用。**图放在哪一条消息上
则按协议走各自的原生机制**:
-- **Responses**:折回 ``function_call_output`` 自己的 ``output``(
- :class:`ResponsesToolMediaMixin`)。该协议的函数输出本就能载图,codex 也是这么做的
+- **Responses**:由 AgentScope 原生写入 ``function_call_output`` 自己的 ``output``。该协议的函数输出本就能载图,codex 也是这么做的
(``FunctionCallOutputContentItem::InputImage``,见
``codex-rs/core/src/tools/handlers/view_image.rs``),图与 ``call_id`` 直接绑定,
多图并发天然不串。
@@ -117,59 +116,3 @@ def _is_sendable(self, block: DataBlock) -> bool:
patterns or "纯文本",
)
return False
-
-
-class ResponsesToolMediaMixin(NoDiskToolMediaMixin):
- """把媒体折回它所属的那条 ``function_call_output``(Responses 协议)。
-
- 父类 formatter 会把媒体提升成紧跟其后的一条 user 消息。那条消息与产出它的调用
- 之间只剩位置关系,多图并发时要靠额外的编号文案才能对上号;Responses 的函数输出
- 本身就能载内容部件,折回去即可与 ``call_id`` 直接绑定。
- """
-
- async def format(self, msgs: list[Msg]) -> list[dict[str, Any]]:
- # 逐条消息格式化后再折叠:父类对每条消息的处理彼此独立,逐条与整批结果一致,
- # 而逐条能保证「函数输出后面那条纯媒体 user 项」只可能是本条消息的提升产物,
- # 不会误收下一轮真实的用户消息。
- items: list[dict[str, Any]] = []
- for msg in msgs:
- # Concrete SDK formatter follows this mixin in each bound class MRO.
- formatted = await super().format([msg]) # type: ignore[safe-super]
- items.extend(_fold_media_into_function_output(formatted))
- return items
-
-
-def _fold_media_into_function_output(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
- folded: list[dict[str, Any]] = []
- for item in items:
- target = folded[-1] if folded else None
- if (
- target is not None
- and target.get("type") == "function_call_output"
- and _is_media_only(item)
- ):
- target["output"] = _as_output_parts(target.get("output")) + list(item["content"])
- continue
- folded.append(item)
- return folded
-
-
-def _is_media_only(item: dict[str, Any]) -> bool:
- """这条 user 项是不是上一条函数输出提升出来的纯媒体项。
-
- ``convert_tool_result_to_string`` 只交出 ``DataBlock``,不再带任何说明文案,
- 所以提升出来的项必然是「清一色非文本的内容部件」——据此判定,不去认父类内部
- 给这条消息起的名字。
- """
- content = item.get("content")
- if item.get("role") != "user" or not isinstance(content, list) or not content:
- return False
- return all(isinstance(part, dict) and part.get("type") != "input_text" for part in content)
-
-
-def _as_output_parts(output: Any) -> list[dict[str, Any]]:
- if isinstance(output, list):
- return list(output)
- if output:
- return [{"type": "input_text", "text": output}]
- return []
diff --git a/src/backend/core/ontology/asset_tags.py b/src/backend/core/ontology/asset_tags.py
deleted file mode 100644
index d5cfbd18..00000000
--- a/src/backend/core/ontology/asset_tags.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""asset tags responsibilities."""
-from __future__ import annotations
-from typing import Any
-
-def normalize_ontology_tags(value: Any) -> list[str]:
- if isinstance(value, str):
- items = value.replace(",", ",").split(",")
- elif isinstance(value, (list, tuple, set)):
- items = value
- else:
- return []
- return sorted({str(item).strip() for item in items if str(item).strip()})
diff --git a/src/backend/core/ontology/audit_events.py b/src/backend/core/ontology/audit_events.py
index 8be699eb..284501f4 100644
--- a/src/backend/core/ontology/audit_events.py
+++ b/src/backend/core/ontology/audit_events.py
@@ -1,4 +1,4 @@
-"""audit events responsibilities."""
+"""Persist ontology enforcement, activation and review events."""
from __future__ import annotations
from core.infra.time import utc_now
import uuid
diff --git a/src/backend/core/ontology/evolution.py b/src/backend/core/ontology/evolution.py
index c61f09c7..d438370c 100644
--- a/src/backend/core/ontology/evolution.py
+++ b/src/backend/core/ontology/evolution.py
@@ -12,6 +12,7 @@
import re
import uuid
from collections import defaultdict
+from dataclasses import dataclass, field
from threading import Lock
from typing import Any
@@ -26,8 +27,6 @@
logger = logging.getLogger(__name__)
-
-
def schedule_ontology_evolution(*, user_id: str) -> bool:
"""Debounce the low-priority evidence prefilter on the current event loop."""
with process_state().SCHEDULE_LOCK:
@@ -36,19 +35,11 @@ def schedule_ontology_evolution(*, user_id: str) -> bool:
process_state().EVOLUTION_RUNNING = True
async def _run() -> None:
- from core.db.engine import SessionLocal
-
- db = SessionLocal()
try:
- await OntologyEvolutionService(db).generate_candidates(
- min_occurrences=3,
- limit=500,
- user_id=user_id or "system",
- )
+ await generate_candidates(min_occurrences=3, limit=500, user_id=user_id or "system")
except Exception: # noqa: BLE001 - a background learner must never affect delivery
logger.warning("ontology evolution background pass failed", exc_info=True)
finally:
- db.close()
with process_state().SCHEDULE_LOCK:
process_state().EVOLUTION_RUNNING = False
@@ -56,6 +47,36 @@ async def _run() -> None:
return True
+async def generate_candidates(
+ *,
+ min_occurrences: int = 3,
+ limit: int = 500,
+ user_id: str = "system",
+ model_name: str | None = None,
+) -> dict[str, Any]:
+ """Turn recurring gate evidence into pending Domain Pack drafts.
+
+ The evidence is read and the drafts are written in two separate sessions;
+ the model call that proposes them in between holds no connection.
+ """
+ from core.db.threads import run_in_session
+
+ recurring, summaries = await run_in_session(
+ lambda db: OntologyEvolutionService(db).evidence_summaries(
+ min_occurrences=min_occurrences, limit=limit
+ )
+ )
+ if not summaries:
+ return {"created": 0, "prefiltered_groups": recurring, "items": []}
+ proposals = await OntologyEvolutionService._llm_proposals(
+ summaries, user_id=user_id, model_name=model_name
+ )
+ created = await run_in_session(
+ lambda db: OntologyEvolutionService(db).record_drafts(summaries, proposals)
+ )
+ return {"created": len(created), "prefiltered_groups": recurring, "items": created}
+
+
def _parse_json_array_lenient(text: str) -> list[Any] | None:
"""Parse a JSON array without coupling the service layer to orchestration."""
if not text:
@@ -82,14 +103,13 @@ def __init__(self, db: Session):
self.db = db
self.ontology = OntologyService(db)
- async def generate_candidates(
- self,
- *,
- min_occurrences: int = 3,
- limit: int = 500,
- user_id: str = "system",
- model_name: str | None = None,
- ) -> dict[str, Any]:
+ def evidence_summaries(
+ self, *, min_occurrences: int, limit: int
+ ) -> tuple[int, list[dict[str, Any]]]:
+ """Recurring gate decisions grouped by rule, with sanitized conversation samples.
+
+ Returns ``(recurring group count, sanitized summaries)``.
+ """
rows = (
self.db.query(OntologyEnforcementEvent)
.filter(
@@ -110,7 +130,7 @@ async def generate_candidates(
if key[0] and len(items) >= min_occurrences
]
if not recurring:
- return {"created": 0, "prefiltered_groups": 0, "items": []}
+ return 0, []
chat_ids = list(
dict.fromkeys(row.chat_id for _, items in recurring for row in items if row.chat_id)
@@ -157,14 +177,12 @@ async def generate_candidates(
for summary in summaries
if isinstance((item := self._sanitize_json_value(summary)), dict)
]
- if not sanitized_summaries:
- return {"created": 0, "prefiltered_groups": len(recurring), "items": []}
- summaries = sanitized_summaries
- proposals = await self._llm_proposals(
- sanitized_summaries,
- user_id=user_id,
- model_name=model_name,
- )
+ return len(recurring), sanitized_summaries
+
+ def record_drafts(
+ self, summaries: list[dict[str, Any]], proposals: list[dict[str, Any]]
+ ) -> list[str]:
+ """Store each summary's sanitized proposals (or a rule review) as pending drafts."""
created = []
for summary in summaries:
matching = [
@@ -207,11 +225,7 @@ async def generate_candidates(
}
)
created.append(row.draft_id)
- return {
- "created": len(created),
- "prefiltered_groups": len(recurring),
- "items": created,
- }
+ return created
def ingest_user_correction(
self,
@@ -354,8 +368,8 @@ def materialize_approved_draft(self, draft_id: str):
self.db.commit()
return version
+ @staticmethod
async def _llm_proposals(
- self,
summaries: list[dict[str, Any]],
*,
user_id: str,
@@ -481,18 +495,14 @@ def _sanitize_json_value(value: Any) -> Any | None:
return None
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class EvolutionState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- SCHEDULE_LOCK: object = state_field(default_factory=lambda: Lock())
+ SCHEDULE_LOCK: object = field(default_factory=lambda: Lock())
EVOLUTION_RUNNING: object = False
-@state_cache(maxsize=1)
-def process_state() -> EvolutionState:
- return EvolutionState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = EvolutionState()
+
+
+def process_state() -> EvolutionState:
+ return _STATE
diff --git a/src/backend/core/ontology/manual_review.py b/src/backend/core/ontology/manual_review.py
index 239ce66c..26a0322d 100644
--- a/src/backend/core/ontology/manual_review.py
+++ b/src/backend/core/ontology/manual_review.py
@@ -1,4 +1,4 @@
-"""manual review responsibilities."""
+"""Build the payload for human ontology review cards."""
from __future__ import annotations
from typing import Any
diff --git a/src/backend/core/ontology/output_review_state.py b/src/backend/core/ontology/output_review_state.py
index 0ef1edf3..48c1402b 100644
--- a/src/backend/core/ontology/output_review_state.py
+++ b/src/backend/core/ontology/output_review_state.py
@@ -1,4 +1,4 @@
-"""output review state responsibilities."""
+"""Claim and complete the single final-answer review of a governance run."""
from __future__ import annotations
from typing import Any
diff --git a/src/backend/core/ontology/review_operations.py b/src/backend/core/ontology/review_operations.py
index 9cbae9ee..89d517df 100644
--- a/src/backend/core/ontology/review_operations.py
+++ b/src/backend/core/ontology/review_operations.py
@@ -1,19 +1,20 @@
-"""review operations responsibilities."""
+"""Run one ontology review and revise the answer when it fails."""
from __future__ import annotations
from typing import Any
from core.harness.registry.capability_runtime import child_scope
from core.harness.registry.model_services import run_text_agent as _run_text_agent
from core.infra.json_output import parse_json_lenient as _parse_json_lenient
from core.infra.logging import get_logger
+from core.ontology import review_prompt
+from core.ontology import reviewer_state
+
logger = get_logger(__name__)
-from core.ontology import review_prompt as review_prompt_module
-from core.ontology import reviewer_state as reviewer_state_module
async def review_once(
perspective: str,
**kwargs,
) -> dict[str, Any]:
- prompt = review_prompt_module.review_prompt(
+ prompt = review_prompt.review_prompt(
perspective=perspective,
task=kwargs["task"],
answer=kwargs["answer"],
@@ -34,7 +35,7 @@ async def review_once(
),
)
obj = _parse_json_lenient(text)
- if not isinstance(obj, dict) or obj.get("verdict") not in reviewer_state_module.VALID_VERDICTS:
+ if not isinstance(obj, dict) or obj.get("verdict") not in reviewer_state.VALID_VERDICTS:
return {
"verdict": "escalate",
"evidence": [],
diff --git a/src/backend/core/ontology/review_prompt.py b/src/backend/core/ontology/review_prompt.py
index 1a837218..c3856ffc 100644
--- a/src/backend/core/ontology/review_prompt.py
+++ b/src/backend/core/ontology/review_prompt.py
@@ -1,4 +1,4 @@
-"""review prompt responsibilities."""
+"""Build the reviewer prompt from bounded evidence."""
from __future__ import annotations
import json
from typing import Any
diff --git a/src/backend/core/ontology/reviewer.py b/src/backend/core/ontology/reviewer.py
index e61b6a72..ddac9768 100644
--- a/src/backend/core/ontology/reviewer.py
+++ b/src/backend/core/ontology/reviewer.py
@@ -1,4 +1,4 @@
-"""reviewer responsibilities."""
+"""Review an answer against the ontology, repair it, and audit the outcome."""
from __future__ import annotations
import asyncio
import time
@@ -7,10 +7,11 @@
from core.ontology.revision import is_substantive_revision
from core.ontology.runtime_gates import evaluate_output
from core.infra.logging import get_logger
+from core.ontology import manual_review
+from core.ontology import review_operations
+from core.ontology import reviewer_state
+
logger = get_logger(__name__)
-from core.ontology import manual_review as manual_review_module
-from core.ontology import review_operations as review_operations_module
-from core.ontology import reviewer_state as reviewer_state_module
async def review_ontology_output(
*,
@@ -24,8 +25,8 @@ async def review_ontology_output(
model_name: str | None,
model_provider_id: str | None = None,
trace_complete: bool = True,
- remediate: reviewer_state_module.RemediationCallback | None = None,
- max_repair_attempts: int = reviewer_state_module.DEFAULT_MAX_REPAIR_ATTEMPTS,
+ remediate: reviewer_state.RemediationCallback | None = None,
+ max_repair_attempts: int = reviewer_state.DEFAULT_MAX_REPAIR_ATTEMPTS,
capability_scope: str = "",
) -> dict[str, Any]:
"""Review one answer, repair it with the originating agent, and audit the session."""
@@ -72,7 +73,7 @@ def _deterministic_result() -> tuple[Any, dict[str, Any]]:
"feedback": [],
"violations": [],
"affected_claims": [],
- "manual_review": manual_review_module.build_manual_review(
+ "manual_review": manual_review.build_manual_review(
required=False, violations=[], affected_claims=[], suggestions=[]
),
"latency_ms": round((time.monotonic() - started) * 1000),
@@ -135,18 +136,18 @@ def _deterministic_result() -> tuple[Any, dict[str, Any]]:
default=3,
)
perspectives = [
- reviewer_state_module.PERSPECTIVES[index % len(reviewer_state_module.PERSPECTIVES)] + f"(席位 {index + 1})"
+ reviewer_state.PERSPECTIVES[index % len(reviewer_state.PERSPECTIVES)] + f"(席位 {index + 1})"
for index in range(committee_size)
]
reviewers = await asyncio.gather(
- *(review_operations_module.review_once(perspective, **common) for perspective in perspectives)
+ *(review_operations.review_once(perspective, **common) for perspective in perspectives)
)
counts = Counter(item["verdict"] for item in reviewers)
verdict, votes = counts.most_common(1)[0]
if votes <= committee_size // 2:
verdict = "escalate"
else:
- reviewers = [await review_operations_module.review_once(reviewer_state_module.PERSPECTIVES[0], **common)]
+ reviewers = [await review_operations.review_once(reviewer_state.PERSPECTIVES[0], **common)]
verdict = reviewers[0]["verdict"]
if not deterministic.allowed and verdict == "pass":
@@ -189,16 +190,16 @@ def _deterministic_result() -> tuple[Any, dict[str, Any]]:
"answer": current_answer,
"violations": deterministic.violations,
"suggestions": deterministic.suggestions,
- "feedback": manual_review_module.dedupe_strings(round_feedback),
+ "feedback": manual_review.dedupe_strings(round_feedback),
"affected_claims": round_claims,
"citations": list(citations),
}
)
elif deterministic.allowed:
- revised = await review_operations_module.revise_answer(
+ revised = await review_operations.revise_answer(
task=task,
answer=current_answer,
- feedback=manual_review_module.dedupe_strings(round_feedback),
+ feedback=manual_review.dedupe_strings(round_feedback),
user_id=user_id,
model_name=model_name,
model_provider_id=model_provider_id,
@@ -220,21 +221,21 @@ def _deterministic_result() -> tuple[Any, dict[str, Any]]:
final_violations = list(deterministic.violations)
final_suggestions = list(deterministic.suggestions)
annotated = verdict == "escalate" or not deterministic.allowed
- manual_review = manual_review_module.build_manual_review(
+ review_card = manual_review.build_manual_review(
required=annotated,
violations=final_violations,
affected_claims=final_affected_claims,
suggestions=final_suggestions or all_feedback,
)
- new_tools = manual_review_module.dedupe_strings(
+ new_tools = manual_review.dedupe_strings(
[
str(item.get("tool_name") or "")
for item in trace[initial_trace_len:]
if item.get("type") == "tool_result"
]
)
- evidence = manual_review_module.dedupe_strings(all_evidence)
- feedback = manual_review_module.dedupe_strings(all_feedback + final_suggestions)
+ evidence = manual_review.dedupe_strings(all_evidence)
+ feedback = manual_review.dedupe_strings(all_feedback + final_suggestions)
latency_ms = round((time.monotonic() - started) * 1000)
try:
from core.ontology.audit_events import record_enforcement_event, record_review_run
@@ -306,7 +307,7 @@ def _deterministic_result() -> tuple[Any, dict[str, Any]]:
"feedback": feedback,
"violations": final_violations,
"affected_claims": final_affected_claims,
- "manual_review": manual_review,
+ "manual_review": review_card,
"latency_ms": latency_ms,
"revised": current_answer != original_answer,
"annotated": annotated,
diff --git a/src/backend/core/ontology/reviewer_state.py b/src/backend/core/ontology/reviewer_state.py
index cf7e022d..3f9c979b 100644
--- a/src/backend/core/ontology/reviewer_state.py
+++ b/src/backend/core/ontology/reviewer_state.py
@@ -1,4 +1,4 @@
-"""reviewer state responsibilities."""
+"""Verdicts, perspectives and repair limits for ontology review."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
diff --git a/src/backend/core/ontology/runtime_gates.py b/src/backend/core/ontology/runtime_gates.py
index d25191cc..2e368749 100644
--- a/src/backend/core/ontology/runtime_gates.py
+++ b/src/backend/core/ontology/runtime_gates.py
@@ -1,8 +1,8 @@
-"""runtime gates responsibilities."""
+"""Deterministic ontology checks on tool calls and outputs."""
from __future__ import annotations
from typing import Any, Iterable
from jsonschema import Draft202012Validator, ValidationError
-from core.ontology import validation_models as validation_models_module
+from core.ontology import validation_models
def evaluate_tool_call(
runtime: dict[str, Any],
@@ -10,10 +10,10 @@ def evaluate_tool_call(
tool_name: str,
tool_input: dict[str, Any],
completed_tools: Iterable[str] = (),
-) -> validation_models_module.OntologyGateDecision:
+) -> validation_models.OntologyGateDecision:
"""Apply zero-LLM L-a validation to one tool call."""
if not runtime.get("enabled"):
- return validation_models_module.OntologyGateDecision(True, "pass")
+ return validation_models.OntologyGateDecision(True, "pass")
completed = set(completed_tools)
violations: list[dict[str, Any]] = []
suggestions: list[str] = []
@@ -69,8 +69,8 @@ def evaluate_tool_call(
enforced = True
if not violations:
- return validation_models_module.OntologyGateDecision(True, "pass", matched_rule_ids=matched)
- return validation_models_module.OntologyGateDecision(
+ return validation_models.OntologyGateDecision(True, "pass", matched_rule_ids=matched)
+ return validation_models.OntologyGateDecision(
allowed=not enforced,
decision="deny" if enforced else "log",
violations=violations,
@@ -85,10 +85,10 @@ def evaluate_output(
answer: str,
citations: Iterable[dict[str, Any]] = (),
completed_tools: Iterable[str] | None = (),
-) -> validation_models_module.OntologyGateDecision:
+) -> validation_models.OntologyGateDecision:
"""Apply deterministic output constraints before any LLM reviewer."""
if not runtime.get("enabled"):
- return validation_models_module.OntologyGateDecision(True, "pass")
+ return validation_models.OntologyGateDecision(True, "pass")
citation_items = list(citations)
# ``None`` means the caller cannot observe a complete tool trace (the
# legacy non-streaming reply path). Output and citation rules still run,
@@ -147,8 +147,8 @@ def evaluate_output(
if rule.get("mode") == "enforce":
enforced = True
if not violations:
- return validation_models_module.OntologyGateDecision(True, "pass", matched_rule_ids=matched)
- return validation_models_module.OntologyGateDecision(
+ return validation_models.OntologyGateDecision(True, "pass", matched_rule_ids=matched)
+ return validation_models.OntologyGateDecision(
allowed=not enforced,
decision="deny" if enforced else "log",
violations=violations,
diff --git a/src/backend/core/ontology/runtime_pack.py b/src/backend/core/ontology/runtime_pack.py
index 0cfc15ee..230c50e2 100644
--- a/src/backend/core/ontology/runtime_pack.py
+++ b/src/backend/core/ontology/runtime_pack.py
@@ -1,12 +1,14 @@
-"""runtime pack responsibilities."""
+"""Compile active ontology packs into the runtime policy for a request."""
from __future__ import annotations
import json
import re
import uuid
-from dataclasses import field
from typing import Any, Iterable
from core.ontology.schemas import Constraint, OntologyPackDocument, Workflow
-from core.ontology import validator_state as validator_state_module
+
+
+REVIEW_RANK = {"none": 0, "checkpoint": 1, "committee": 2}
+
def select_workflows(document: OntologyPackDocument, task: str) -> list[Workflow]:
"""Select workflows by case-insensitive lexical trigger match."""
@@ -150,7 +152,7 @@ def build_runtime_payload(
for document in documents:
workflows = select_workflows(document, task)
for workflow in workflows:
- if validator_state_module.REVIEW_RANK[workflow.review_level] > validator_state_module.REVIEW_RANK[review_level]:
+ if REVIEW_RANK[workflow.review_level] > REVIEW_RANK[review_level]:
review_level = workflow.review_level
ref = f"{document.pack_id}:{workflow.id}"
activated_workflows.append(ref)
@@ -274,7 +276,7 @@ def activate_runtime_for_asset(
current["version_id"] = fragment["version_id"]
level = str(candidate.get("review_level") or "none")
- if validator_state_module.REVIEW_RANK.get(level, 0) > validator_state_module.REVIEW_RANK.get(runtime.get("review_level"), 0):
+ if REVIEW_RANK.get(level, 0) > REVIEW_RANK.get(runtime.get("review_level"), 0):
runtime["review_level"] = level
activated.add(ref)
runtime["activated_workflows"] = sorted(activated)
@@ -335,7 +337,7 @@ def render_runtime_prompt(runtime: dict[str, Any]) -> str:
compact_pack["relations"].pop()
continue
if compact_pack["concepts"]:
- removed = compact_pack["concepts"].pop()
+ compact_pack["concepts"].pop()
remaining = {item["id"] for item in compact_pack["concepts"]}
compact_pack["relations"] = [
relation
diff --git a/src/backend/core/ontology/runtime_policy.py b/src/backend/core/ontology/runtime_policy.py
index 4a551593..e0f56b8d 100644
--- a/src/backend/core/ontology/runtime_policy.py
+++ b/src/backend/core/ontology/runtime_policy.py
@@ -1,4 +1,4 @@
-"""runtime policy responsibilities."""
+"""Resolve a user's ontology preference into a runtime policy and asset tags."""
from __future__ import annotations
from core.ontology import service as ontology_service
from typing import Any
diff --git a/src/backend/core/ontology/service.py b/src/backend/core/ontology/service.py
index 05848698..462b3166 100644
--- a/src/backend/core/ontology/service.py
+++ b/src/backend/core/ontology/service.py
@@ -1,4 +1,4 @@
-"""service responsibilities."""
+"""Manage ontology packs, drafts and versions, and resolve asset tags."""
from __future__ import annotations
from core.infra.time import utc_now
import hashlib
@@ -14,7 +14,17 @@
from core.ontology.runtime_pack import build_runtime_payload
from sqlalchemy import or_
from sqlalchemy.orm import Session
-from core.ontology import asset_tags
+
+
+def normalize_ontology_tags(value: Any) -> list[str]:
+ if isinstance(value, str):
+ items = value.replace(",", ",").split(",")
+ elif isinstance(value, (list, tuple, set)):
+ items = value
+ else:
+ return []
+ return sorted({str(item).strip() for item in items if str(item).strip()})
+
class OntologyService:
def __init__(self, db: Session):
@@ -355,7 +365,7 @@ def resolve_asset_tags(
.one_or_none()
)
if row:
- return asset_tags.normalize_ontology_tags(row.tags)
+ return normalize_ontology_tags(row.tags)
# A DB row that exists but is outside this user's scope must never
# fall through to the process-global loader (which indexes private
# skills for materialization). Only true filesystem/built-in skills
@@ -369,7 +379,7 @@ def resolve_asset_tags(
from core.capabilities.skills.loader_factory import get_skill_loader
metadata = get_skill_loader().load_all_metadata().get(asset_id)
- return asset_tags.normalize_ontology_tags(getattr(metadata, "tags", []))
+ return normalize_ontology_tags(getattr(metadata, "tags", []))
if kind == "tool":
owner_filter = (
@@ -398,10 +408,10 @@ def resolve_asset_tags(
for tool in row.tools_json or []:
if not isinstance(tool, dict) or str(tool.get("name") or "") != asset_id:
continue
- tags.update(asset_tags.normalize_ontology_tags(extra.get("ontology_tags")))
- tags.update(asset_tags.normalize_ontology_tags(tool.get("tags")))
- tags.update(asset_tags.normalize_ontology_tags(tool.get("ontology_tags")))
- tags.update(asset_tags.normalize_ontology_tags(tool_tag_map.get(asset_id)))
+ tags.update(normalize_ontology_tags(extra.get("ontology_tags")))
+ tags.update(normalize_ontology_tags(tool.get("tags")))
+ tags.update(normalize_ontology_tags(tool.get("ontology_tags")))
+ tags.update(normalize_ontology_tags(tool_tag_map.get(asset_id)))
return sorted(tags)
if kind == "subagent":
@@ -411,7 +421,7 @@ def resolve_asset_tags(
agent = UserAgentService(self.db).get_by_id(asset_id, user_id=user_id or None)
except (LookupError, PermissionError):
return []
- return asset_tags.normalize_ontology_tags(agent.get("ontology_tags"))
+ return normalize_ontology_tags(agent.get("ontology_tags"))
return []
diff --git a/src/backend/core/ontology/validation_models.py b/src/backend/core/ontology/validation_models.py
index 119326c8..cda206d4 100644
--- a/src/backend/core/ontology/validation_models.py
+++ b/src/backend/core/ontology/validation_models.py
@@ -1,4 +1,4 @@
-"""validation models responsibilities."""
+"""Validation issues, reports and gate decisions."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
diff --git a/src/backend/core/ontology/validator.py b/src/backend/core/ontology/validator.py
index 0b196eb7..dcf9092b 100644
--- a/src/backend/core/ontology/validator.py
+++ b/src/backend/core/ontology/validator.py
@@ -1,10 +1,10 @@
-"""validator responsibilities."""
+"""Validate ontology packs before activation."""
from __future__ import annotations
from typing import Any, Iterable
from core.ontology.schemas import OntologyPackDocument
from jsonschema import Draft202012Validator
from pydantic import ValidationError as PydanticValidationError
-from core.ontology import validation_models as validation_models_module
+from core.ontology import validation_models
class DomainPackValidator:
"""Validate structure, JSON Schema, and tool references before activation."""
@@ -15,21 +15,21 @@ def validate(
*,
tool_schemas: dict[str, dict[str, Any]] | None = None,
known_tools: Iterable[str] = (),
- ) -> tuple[OntologyPackDocument | None, validation_models_module.ValidationReport]:
- errors: list[validation_models_module.ValidationIssue] = []
- warnings: list[validation_models_module.ValidationIssue] = []
+ ) -> tuple[OntologyPackDocument | None, validation_models.ValidationReport]:
+ errors: list[validation_models.ValidationIssue] = []
+ warnings: list[validation_models.ValidationIssue] = []
try:
document = OntologyPackDocument.model_validate(payload)
except PydanticValidationError as exc:
for item in exc.errors(include_url=False):
errors.append(
- validation_models_module.ValidationIssue(
+ validation_models.ValidationIssue(
"error",
".".join(str(part) for part in item["loc"]),
item["msg"],
)
)
- return None, validation_models_module.ValidationReport(False, errors, warnings)
+ return None, validation_models.ValidationReport(False, errors, warnings)
schemas = tool_schemas or {}
known = set(known_tools) | set(schemas)
@@ -39,12 +39,12 @@ def validate(
try:
Draft202012Validator.check_schema(rule.schema_)
except Exception as exc: # SchemaError is version-specific
- errors.append(validation_models_module.ValidationIssue("error", f"{path}.schema", str(exc)))
+ errors.append(validation_models.ValidationIssue("error", f"{path}.schema", str(exc)))
tool_name = rule.target.tool
if not tool_name:
continue
if tool_name not in known:
- issue = validation_models_module.ValidationIssue(
+ issue = validation_models.ValidationIssue(
"warning" if document.config.allow_unresolved_tools else "error",
f"{path}.target.tool",
f"unknown tool reference: {tool_name}",
@@ -55,7 +55,7 @@ def validate(
properties = tool_input_schema(schemas[tool_name]).get("properties", {})
if rule.target.parameter not in properties:
errors.append(
- validation_models_module.ValidationIssue(
+ validation_models.ValidationIssue(
"error",
f"{path}.target.parameter",
f"unknown parameter {rule.target.parameter!r} for tool {tool_name}",
@@ -65,7 +65,7 @@ def validate(
for index, workflow in enumerate(document.workflows):
for tool_name in set(workflow.required_tools + workflow.forbidden_tools):
if tool_name not in known:
- issue = validation_models_module.ValidationIssue(
+ issue = validation_models.ValidationIssue(
"warning" if document.config.allow_unresolved_tools else "error",
f"workflows.{index}",
f"unknown tool reference: {tool_name}",
@@ -74,7 +74,7 @@ def validate(
overlap = set(workflow.required_tools) & set(workflow.forbidden_tools)
if overlap:
errors.append(
- validation_models_module.ValidationIssue(
+ validation_models.ValidationIssue(
"error",
f"workflows.{index}",
f"tools cannot be both required and forbidden: {sorted(overlap)}",
@@ -86,14 +86,14 @@ def validate(
for tool_name in trigger.ids:
if tool_name in known:
continue
- issue = validation_models_module.ValidationIssue(
+ issue = validation_models.ValidationIssue(
"warning" if document.config.allow_unresolved_tools else "error",
f"workflows.{index}.asset_triggers.{trigger_index}.ids",
f"unknown tool reference: {tool_name}",
)
(warnings if document.config.allow_unresolved_tools else errors).append(issue)
- return document, validation_models_module.ValidationReport(not errors, errors, warnings)
+ return document, validation_models.ValidationReport(not errors, errors, warnings)
def tool_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
diff --git a/src/backend/core/ontology/validator_state.py b/src/backend/core/ontology/validator_state.py
deleted file mode 100644
index 69423a37..00000000
--- a/src/backend/core/ontology/validator_state.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""validator state responsibilities."""
-from __future__ import annotations
-
-
-REVIEW_RANK = {"none": 0, "checkpoint": 1, "committee": 2}
diff --git a/src/backend/core/projects/__init__.py b/src/backend/core/projects/__init__.py
index 28687fb0..51ddcc2b 100644
--- a/src/backend/core/projects/__init__.py
+++ b/src/backend/core/projects/__init__.py
@@ -1 +1 @@
-"""projects domain package."""
+"""Projects: access, files, environment and personal workspaces."""
diff --git a/src/backend/core/projects/instructions.py b/src/backend/core/projects/instructions.py
index c2b38df6..e415d61c 100644
--- a/src/backend/core/projects/instructions.py
+++ b/src/backend/core/projects/instructions.py
@@ -4,14 +4,12 @@
File content is authoritative; the database field is only a legacy fallback.
"""
from __future__ import annotations
-from core.infra.time import utc_now
import hashlib
import os
import stat
import tempfile
-import uuid
-from contextlib import contextmanager, nullcontext
+from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
diff --git a/src/backend/core/projects/user_folders.py b/src/backend/core/projects/user_folders.py
index 95fac40e..24727283 100644
--- a/src/backend/core/projects/user_folders.py
+++ b/src/backend/core/projects/user_folders.py
@@ -12,10 +12,8 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
-from core.db.models import Artifact, UserFolder
-from core.db.repository import ArtifactRepository, AuditLogRepository
-from core.files.edition import personal_artifact_create_fields
-from core.db.artifact_scope import is_personal_artifact
+from core.db.models import UserFolder
+from core.db.repository import AuditLogRepository
from core.files.storage import get_storage
from sqlalchemy.orm import Session
diff --git a/src/backend/core/runs/binding.py b/src/backend/core/runs/binding.py
index 62e99bb8..53455534 100644
--- a/src/backend/core/runs/binding.py
+++ b/src/backend/core/runs/binding.py
@@ -9,35 +9,33 @@
from core.db.engine import SessionLocal
from core.db.models import ChatRun, ChatSession
+from core.db.threads import run_db
from core.runs.journal_domain import DurableRunBinding, RunAlreadyExists, RunJournalError, RunLeaseLost
from sqlalchemy.orm import sessionmaker
-@asynccontextmanager
-async def durable_run_binding(
+def _admit(
+ session_factory: sessionmaker,
+ journal,
*,
user_id: str,
- chat_id: Optional[str],
+ chat_id: str,
kind: str,
external_id: str,
- request_payload: Optional[Mapping[str, Any]] = None,
- recovery_snapshot: Optional[Mapping[str, Any]] = None,
- session_factory: sessionmaker = SessionLocal,
- lease_seconds: int = 300,
- heartbeat_interval: Optional[float] = None,
- binding_run_id: Optional[str] = None,
-):
- """Give non-chat Agent entry points the same durable run/heartbeat contract."""
-
- from core.runs.journal import RunJournal
-
- actual_chat_id = str(chat_id or f"internal_{uuid.uuid4().hex[:24]}")
+ run_id: str,
+ owner: str,
+ request_payload: Optional[Mapping[str, Any]],
+ recovery_snapshot: Optional[Mapping[str, Any]],
+ lease_seconds: int,
+ deterministic: bool,
+) -> None:
+ """Create the run's chat if needed, accept the run and claim its lease."""
with session_factory() as db:
- session = db.get(ChatSession, actual_chat_id)
+ session = db.get(ChatSession, chat_id)
if session is None:
db.add(
ChatSession(
- chat_id=actual_chat_id,
+ chat_id=chat_id,
user_id=user_id,
title=f"{kind} run",
)
@@ -46,16 +44,13 @@ async def durable_run_binding(
elif session.user_id != user_id:
raise RunJournalError("durable run chat belongs to another user")
- run_id = str(binding_run_id or f"run_{uuid.uuid4().hex}")
- owner = f"{kind}:{uuid.uuid4().hex}"
- journal = RunJournal(session_factory)
snapshot = {"kind": kind, "external_id": external_id}
snapshot.update(dict(recovery_snapshot or {}))
try:
journal.accept(
run_id=run_id,
message_id=f"msg_{uuid.uuid4().hex}",
- chat_id=actual_chat_id,
+ chat_id=chat_id,
user_id=user_id,
request_payload={
"kind": kind,
@@ -69,7 +64,7 @@ async def durable_run_binding(
# The primary key resolves the check-then-create race across processes;
# only translate an error when that exact row now exists so unrelated
# database failures retain their original signal.
- if binding_run_id:
+ if deterministic:
with session_factory() as db:
if db.get(ChatRun, run_id) is not None:
raise RunAlreadyExists(run_id) from exc
@@ -77,6 +72,45 @@ async def durable_run_binding(
if not journal.claim(run_id, owner=owner, lease_seconds=lease_seconds):
raise RunLeaseLost(run_id)
+
+@asynccontextmanager
+async def durable_run_binding(
+ *,
+ user_id: str,
+ chat_id: Optional[str],
+ kind: str,
+ external_id: str,
+ request_payload: Optional[Mapping[str, Any]] = None,
+ recovery_snapshot: Optional[Mapping[str, Any]] = None,
+ session_factory: sessionmaker = SessionLocal,
+ lease_seconds: int = 300,
+ heartbeat_interval: Optional[float] = None,
+ binding_run_id: Optional[str] = None,
+):
+ """Give non-chat Agent entry points the same durable run/heartbeat contract."""
+
+ from core.runs.journal import RunJournal
+
+ actual_chat_id = str(chat_id or f"internal_{uuid.uuid4().hex[:24]}")
+ run_id = str(binding_run_id or f"run_{uuid.uuid4().hex}")
+ owner = f"{kind}:{uuid.uuid4().hex}"
+ journal = RunJournal(session_factory)
+ await run_db(
+ _admit,
+ session_factory,
+ journal,
+ user_id=user_id,
+ chat_id=actual_chat_id,
+ kind=kind,
+ external_id=external_id,
+ run_id=run_id,
+ owner=owner,
+ request_payload=request_payload,
+ recovery_snapshot=recovery_snapshot,
+ lease_seconds=lease_seconds,
+ deterministic=bool(binding_run_id),
+ )
+
lost = asyncio.Event()
worker_task = asyncio.current_task()
if worker_task is None: # pragma: no cover - async context always has a task
@@ -115,7 +149,8 @@ async def _heartbeat() -> None:
admission.check()
except asyncio.CancelledError:
if not lost.is_set():
- journal.complete(
+ await run_db(
+ journal.complete,
run_id,
owner=owner,
status="cancelled",
@@ -129,19 +164,22 @@ async def _heartbeat() -> None:
if admission.stopped is not None:
from core.chat.compaction.budget_checkpoint import ContextBudgetJournal
- ContextBudgetJournal(session_factory).pause_context(
+ await run_db(
+ ContextBudgetJournal(session_factory).pause_context,
{"run_id": run_id, "journal_owner": owner},
admission.stopped,
admission.checkpoint,
)
elif unknown is not None:
- journal.needs_attention(
+ await run_db(
+ journal.needs_attention,
run_id,
owner=owner,
reason=f"tool outcome pending policy recovery: {unknown}",
)
else:
- journal.complete(
+ await run_db(
+ journal.complete,
run_id,
owner=owner,
status="failed",
@@ -149,7 +187,7 @@ async def _heartbeat() -> None:
)
raise
else:
- journal.complete(run_id, owner=owner, status="completed")
+ await run_db(journal.complete, run_id, owner=owner, status="completed")
finally:
heartbeat.cancel()
with suppress(asyncio.CancelledError):
diff --git a/src/backend/core/runs/journal.py b/src/backend/core/runs/journal.py
index 06d06c09..c11ec987 100644
--- a/src/backend/core/runs/journal.py
+++ b/src/backend/core/runs/journal.py
@@ -40,6 +40,7 @@ def accept(
user_id: str,
request_payload: Mapping[str, Any],
recovery_snapshot: Optional[Mapping[str, Any]] = None,
+ on_accept=None,
) -> ChatRun:
"""Commit acceptance before any in-process worker is created."""
with self._sessions() as db:
@@ -53,6 +54,8 @@ def accept(
recovery_snapshot=recovery_snapshot,
now=self._clock(),
)
+ if on_accept is not None:
+ on_accept(db, row)
db.commit()
db.refresh(row)
db.expunge(row)
@@ -105,7 +108,7 @@ def accept_in_session(
db.flush()
return row
- def claim(self, run_id: str, *, owner: str, lease_seconds: int) -> bool:
+ def claim(self, run_id: str, *, owner: str, lease_seconds: int, pending_only: bool = False) -> bool:
"""Atomically acquire a free/expired lease; exactly one claimant wins."""
if not owner:
raise ValueError("owner is required")
@@ -120,7 +123,7 @@ def claim(self, run_id: str, *, owner: str, lease_seconds: int) -> bool:
db.query(ChatRun)
.filter(
ChatRun.run_id == run_id,
- ChatRun.status.in_(LIVE_STATUSES),
+ ChatRun.status.in_(("pending",) if pending_only else LIVE_STATUSES),
or_(
ChatRun.lease_owner.is_(None),
ChatRun.lease_expires_at.is_(None),
diff --git a/src/backend/core/runs/ownership.py b/src/backend/core/runs/ownership.py
index 505249c0..42908987 100644
--- a/src/backend/core/runs/ownership.py
+++ b/src/backend/core/runs/ownership.py
@@ -247,6 +247,7 @@ def create_run_record(
user_id: str,
request_payload: Dict[str, Any],
recovery_snapshot: Optional[Dict[str, Any]] = None,
+ on_accept=None,
) -> ChatRun:
"""Durably accept a run before any worker task can be spawned."""
run_id = f"run_{uuid.uuid4().hex[:16]}"
@@ -258,6 +259,7 @@ def create_run_record(
user_id=user_id,
request_payload=writer._json_safe(request_payload),
recovery_snapshot=writer._json_safe(recovery_snapshot or {}),
+ on_accept=on_accept,
)
diff --git a/src/backend/core/runs/runtime.py b/src/backend/core/runs/runtime.py
index bed651fa..76790c07 100644
--- a/src/backend/core/runs/runtime.py
+++ b/src/backend/core/runs/runtime.py
@@ -16,6 +16,7 @@
import core.runs.settings as settings
import core.runs.tasks as tasks
from core.db.models import ChatRun
+from core.db.threads import run_db
from core.infra.logging import get_logger
logger = get_logger(__name__)
@@ -31,11 +32,16 @@ class ChatRunPermissionDenied(Exception):
def register_run_task(run_id: str, coro: Awaitable[None], *, name: str) -> None:
"""Spawn the worker coroutine, register it in _active_runs, auto-cleanup on done."""
+ active = tasks.process_state().active_runs
+ if run_id in active and not active[run_id].done():
+ coro.close()
+ return
task = asyncio.create_task(coro, name=name)
tasks.process_state().active_runs[run_id] = task
def finished(worker):
- tasks.process_state().active_runs.pop(run_id, None)
+ if active.get(run_id) is worker:
+ active.pop(run_id, None)
if not worker.cancelled() and worker.exception() is not None:
logger.error(
"run_worker_unhandled_error",
@@ -67,7 +73,7 @@ async def wait_run(run_id: str) -> ChatRun:
raise
# Explicit run cancellation stopped the worker; the durable row
# below is the authoritative outcome.
- run = get_run(run_id)
+ run = await run_db(get_run, run_id)
if run is None:
raise ChatRunNotFound(f"chat run {run_id} not found")
if run.status in settings.LIVE_STATUSES:
@@ -96,27 +102,14 @@ def is_run_cancelled(run_id: str) -> bool:
return bool(row and row[0] in settings._TERMINAL_STATUSES)
-async def cancel_run(
+def _claim_cancellation(
run_id: str,
*,
user_id: str,
- stop_terminal_worker: bool = False,
- cancel_guard: Optional[Callable[[Any], bool]] = None,
-) -> bool:
- """Mark status=cancelled. Idempotent on terminal runs.
-
- For chat-run kinds (regular chat / plan-generate) we forcefully cancel the
- underlying asyncio task — these don't hold long-lived per-step HTTP MCP
- clients so anyio cancel propagation is safe.
-
- For plan-execute kinds we DO NOT call task.cancel(). plan_mode polls
- is_run_cancelled() between LLM streaming heartbeats and self-cancels
- inside its own task. Cross-task task.cancel() on a plan-execute worker
- risks an anyio cancel-scope deadlock: the worker holds per-step
- streamable_http MCP clients whose internal SSE tasks may be blocked on
- socket recv at cancel time, causing anyio's _deliver_cancellation to
- self-reschedule every 10ms and starve the whole event loop.
- """
+ stop_terminal_worker: bool,
+ cancel_guard: Optional[Callable[[Any], bool]],
+) -> Optional[ChatRun]:
+ """Write the cancelled state; returns the run, or None when there is nothing to stop."""
run = get_run(run_id)
if run is None:
raise ChatRunNotFound(f"chat run {run_id} not found")
@@ -124,14 +117,14 @@ async def cancel_run(
raise ChatRunPermissionDenied(f"run {run_id} owned by another user")
already_cancelled = stop_terminal_worker and run.status == "cancelled"
if run.status not in settings.LIVE_STATUSES and not already_cancelled:
- return False
+ return None
if already_cancelled and cancel_guard is not None:
# Even terminal-worker cleanup needs current caller ownership. Release
# the short guard transaction before stream or worker operations.
with engine.SessionLocal() as db:
if not cancel_guard(db):
- return False
+ return None
if not already_cancelled and not ownership.journal().cancel(
run_id,
@@ -139,6 +132,39 @@ async def cancel_run(
**({"cancel_guard": cancel_guard} if cancel_guard is not None else {}),
):
# Race: the worker/reaper just beat us to writing the terminal state — treat as "no longer running".
+ return None
+ return run
+
+
+async def cancel_run(
+ run_id: str,
+ *,
+ user_id: str,
+ stop_terminal_worker: bool = False,
+ cancel_guard: Optional[Callable[[Any], bool]] = None,
+) -> bool:
+ """Mark status=cancelled. Idempotent on terminal runs.
+
+ For chat-run kinds (regular chat / plan-generate) we forcefully cancel the
+ underlying asyncio task — these don't hold long-lived per-step HTTP MCP
+ clients so anyio cancel propagation is safe.
+
+ For plan-execute kinds we DO NOT call task.cancel(). plan_mode polls
+ is_run_cancelled() between LLM streaming heartbeats and self-cancels
+ inside its own task. Cross-task task.cancel() on a plan-execute worker
+ risks an anyio cancel-scope deadlock: the worker holds per-step
+ streamable_http MCP clients whose internal SSE tasks may be blocked on
+ socket recv at cancel time, causing anyio's _deliver_cancellation to
+ self-reschedule every 10ms and starve the whole event loop.
+ """
+ run = await run_db(
+ _claim_cancellation,
+ run_id,
+ user_id=user_id,
+ stop_terminal_worker=stop_terminal_worker,
+ cancel_guard=cancel_guard,
+ )
+ if run is None:
return False
payload = run.request_payload if isinstance(run.request_payload, dict) else {}
@@ -171,7 +197,7 @@ async def cancel_run(
except Exception as exc:
logger.warning("cancel_run_await_failed", run_id=run_id, error=str(exc))
if task.done():
- ownership._acknowledge_terminal_writer(run_id)
+ await run_db(ownership._acknowledge_terminal_writer, run_id)
elif not cooperative_only:
# Cross-process / post-restart: worker not in this process. Inject the
# terminal markers ourselves so any active follower exits cleanly.
diff --git a/src/backend/core/runs/tasks.py b/src/backend/core/runs/tasks.py
index c78cc0ea..746f7350 100644
--- a/src/backend/core/runs/tasks.py
+++ b/src/backend/core/runs/tasks.py
@@ -4,25 +4,20 @@
import asyncio
import typing as _typing
+from dataclasses import dataclass, field
from core.infra.logging import get_logger
logger = get_logger(__name__)
+@dataclass
+class TasksState:
+ active_runs: _typing.Dict[str, asyncio.Task] = field(default_factory=lambda: {})
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
+_STATE = TasksState()
-@state_dataclass
-class TasksState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- active_runs: _typing.Dict[str, asyncio.Task] = state_field(default_factory=lambda: {})
-@state_cache(maxsize=1)
def process_state() -> TasksState:
- return TasksState()
-
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+ return _STATE
diff --git a/src/backend/core/runs/tool_effect_recovery.py b/src/backend/core/runs/tool_effect_recovery.py
index c5ada64f..7a137b11 100644
--- a/src/backend/core/runs/tool_effect_recovery.py
+++ b/src/backend/core/runs/tool_effect_recovery.py
@@ -13,10 +13,10 @@
from agentscope.message import ToolCallBlock
from agentscope.tool._response import ToolResponse
-from core.db.engine import SessionLocal
from core.db.models import ChatRun, RemoteToolEffect, ToolEffectReceipt
from core.runs.tool_effects.types import ReconciliationResult, ToolIntent
from core.runs.tool_effects.errors import ToolEffectError
+from core.db.threads import run_in_session
def _contains_withheld(value: Any) -> bool:
@@ -44,7 +44,7 @@ async def replay_tool_intent(intent: ToolIntent) -> dict[str, Any]:
from core.llm.factory import create_agent_executor
from core.llm.mcp.manager import close_clients
- with SessionLocal() as db:
+ def _replay_context(db):
run = db.get(ChatRun, intent.run_id)
if run is None:
raise ToolEffectError(f"missing run for tool recovery: {intent.run_id}")
@@ -55,6 +55,8 @@ async def replay_tool_intent(intent: ToolIntent) -> dict[str, Any]:
context = dict(worker_args.get("context") or {})
user_id = run.user_id
chat_id = run.chat_id
+ return context, user_id, chat_id
+ context, user_id, chat_id = await run_in_session(_replay_context)
from core.harness.registry.run_capabilities import has_run_capabilities
@@ -126,38 +128,36 @@ async def replay_tool_intent(intent: ToolIntent) -> dict[str, Any]:
await close_clients(clients)
+def _scheduled_task_receipt(db, intent: ToolIntent):
+ """Settle from the local receipt, or return the cloud call that can settle it."""
+ run = db.get(ChatRun, intent.run_id)
+ if run is None:
+ return ReconciliationResult.unknown()
+ receipt = db.get(ToolEffectReceipt, intent.effect_id)
+ if receipt is not None:
+ if receipt.user_id != run.user_id or receipt.tool_name != intent.tool_name:
+ return ReconciliationResult.unknown()
+ return ReconciliationResult.applied(dict(receipt.result_payload or {}))
+ remote = db.get(RemoteToolEffect, intent.effect_id)
+ if remote is not None and remote.gateway_url:
+ return {"url": remote.gateway_url, "schema_hash": remote.schema_hash, "user_id": run.user_id}
+ # A device operation may predate persisted cross-end bindings.
+ # Its missing local receipt says nothing about cloud state.
+ from core.harness.registry.host_runtime import requires_remote_execution as bridge_enabled
+
+ if bridge_enabled() and intent.redacted_args.get("execution_location") != "local":
+ return ReconciliationResult.unknown()
+ return ReconciliationResult.not_applied()
+
+
async def reconcile_scheduled_task_intent(intent: ToolIntent) -> ReconciliationResult:
"""Use the adapter receipt committed in the same transaction as the mutation."""
_plain_replay_args(intent)
- with SessionLocal() as db:
- run = db.get(ChatRun, intent.run_id)
- if run is None:
- return ReconciliationResult.unknown()
- receipt = db.get(ToolEffectReceipt, intent.effect_id)
- if receipt is None:
- remote = db.get(RemoteToolEffect, intent.effect_id)
- if remote is not None and remote.gateway_url:
- remote_call = {
- "url": remote.gateway_url,
- "schema_hash": remote.schema_hash,
- "user_id": run.user_id,
- }
- else:
- remote_call = None
- if remote_call is None:
- # A device operation may predate persisted cross-end bindings.
- # Its missing local receipt says nothing about cloud state.
- from core.harness.registry.host_runtime import requires_remote_execution as bridge_enabled
-
- if bridge_enabled() and intent.redacted_args.get("execution_location") != "local":
- return ReconciliationResult.unknown()
- return ReconciliationResult.not_applied()
- else:
- if receipt.user_id != run.user_id or receipt.tool_name != intent.tool_name:
- return ReconciliationResult.unknown()
- return ReconciliationResult.applied(dict(receipt.result_payload or {}))
- return await _reconcile_cloud_receipt(intent, remote_call)
+ settled = await run_in_session(_scheduled_task_receipt, intent)
+ if isinstance(settled, ReconciliationResult):
+ return settled
+ return await _reconcile_cloud_receipt(intent, settled)
async def _reconcile_cloud_receipt(intent, remote_call):
diff --git a/src/backend/core/runs/tool_effects/arguments.py b/src/backend/core/runs/tool_effects/arguments.py
index f9f09810..84c89911 100644
--- a/src/backend/core/runs/tool_effects/arguments.py
+++ b/src/backend/core/runs/tool_effects/arguments.py
@@ -1,15 +1,15 @@
-"""arguments responsibilities."""
+"""Canonicalise and redact tool arguments and derive idempotency keys."""
from __future__ import annotations
import hashlib
import json
from typing import Any, Mapping, Optional
from core.infra.data_masking import mask_sensitive_data
-from core.runs.tool_effects import errors as errors_module
-from core.runs.tool_effects import journal_state as journal_state_module
+from core.runs.tool_effects import errors
+from core.runs.tool_effects import journal_constants
def embedded_redact(value: str) -> str:
cleaned = value
- for pattern in journal_state_module.EMBEDDED_SECRET_PATTERNS:
+ for pattern in journal_constants.EMBEDDED_SECRET_PATTERNS:
cleaned = pattern.sub(r"\1[REDACTED]", cleaned)
return cleaned
@@ -21,7 +21,7 @@ def sanitize_value(value: Any, *, key: str = "") -> Any:
if isinstance(value, (list, tuple)):
return [sanitize_value(item, key=key) for item in value]
if isinstance(value, str):
- if lower in journal_state_module.WITHHOLD_FIELDS:
+ if lower in journal_constants.WITHHOLD_FIELDS:
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
return {"_withheld": True, "sha256": digest, "length": len(value)}
return embedded_redact(value)
@@ -33,7 +33,7 @@ def canonical_tool_args(args: Mapping[str, Any]) -> tuple[str, str, Mapping[str,
args or {}, sort_keys=True, separators=(",", ":"), default=str
)
args_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
- by_field = mask_sensitive_data(dict(args or {}), field_patterns=journal_state_module.REDACT_FIELDS)
+ by_field = mask_sensitive_data(dict(args or {}), field_patterns=journal_constants.REDACT_FIELDS)
return canonical, args_hash, sanitize_value(by_field)
@@ -59,7 +59,7 @@ def resolved_idempotency_key(
scoped = f"{principal_scope}\x00{caller_key}".encode("utf-8")
return "caller:" + hashlib.sha256(scoped).hexdigest(), True
if not tool_call_id:
- raise errors_module.ToolIntentCommitError(
+ raise errors.ToolIntentCommitError(
"tool_call_id or explicit idempotency_key is required"
)
return stable_idempotency_key(run_id, tool_name, tool_call_id), False
diff --git a/src/backend/core/runs/tool_effects/errors.py b/src/backend/core/runs/tool_effects/errors.py
index 3083001b..dcaf1373 100644
--- a/src/backend/core/runs/tool_effects/errors.py
+++ b/src/backend/core/runs/tool_effects/errors.py
@@ -1,4 +1,4 @@
-"""errors responsibilities."""
+"""Errors raised by the tool effect ledger."""
from __future__ import annotations
from typing import Optional
diff --git a/src/backend/core/runs/tool_effects/gateway.py b/src/backend/core/runs/tool_effects/gateway.py
index b6176ceb..293e64c5 100644
--- a/src/backend/core/runs/tool_effects/gateway.py
+++ b/src/backend/core/runs/tool_effects/gateway.py
@@ -1,24 +1,23 @@
-"""gateway responsibilities."""
+"""The state machine every side-effecting tool call goes through."""
from __future__ import annotations
from core.harness import tool_effect_context
-from core.runs.tool_effects import journal as journal_module_
+from core.runs.tool_effects.journal import ToolEffectJournal
import asyncio
import inspect
import uuid
from contextlib import suppress
from typing import Any, Awaitable, Callable, Mapping, Optional
-from core.runs.tool_effects import errors as errors_module
-from core.runs.tool_effects import journal as journal_module
-from core.runs.tool_effects import registry as registry_module
-from core.runs.tool_effects import types as types_module
+from core.runs.tool_effects import errors
+from core.runs.tool_effects.registry import ToolRecoveryRegistry
+from core.runs.tool_effects import types
class ToolEffectGateway:
"""The single execution state machine used by production middleware and tests."""
def __init__(
self,
- journal: journal_module_.ToolEffectJournal,
- registry: registry_module.ToolRecoveryRegistry,
+ journal: ToolEffectJournal,
+ registry: ToolRecoveryRegistry,
*,
poll_interval: float = 0.05,
lease_seconds: int = 300,
@@ -29,18 +28,18 @@ def __init__(
self.lease_seconds = lease_seconds
@staticmethod
- async def _call(handler, intent: types_module.ToolIntent): # noqa: ANN001, ANN205
+ async def _call(handler, intent: types.ToolIntent): # noqa: ANN001, ANN205
value = handler(intent) if handler is not None else None
return await value if inspect.isawaitable(value) else value
@staticmethod
- def _reconciliation(value: Any) -> types_module.ReconciliationResult:
- if isinstance(value, types_module.ReconciliationResult):
+ def _reconciliation(value: Any) -> types.ReconciliationResult:
+ if isinstance(value, types.ReconciliationResult):
return value
return (
- types_module.ReconciliationResult.applied(value)
+ types.ReconciliationResult.applied(value)
if value is not None
- else types_module.ReconciliationResult.unknown()
+ else types.ReconciliationResult.unknown()
)
def _start_heartbeat(
@@ -89,7 +88,7 @@ async def execute_outcome(
invoke: Callable[[], Awaitable[Any] | Any],
idempotency_key: Optional[str] = None,
classify_failure: Optional[Callable[[Any], bool]] = None,
- ) -> types_module.ToolExecutionOutcome:
+ ) -> types.ToolExecutionOutcome:
claim_owner = f"tool:{uuid.uuid4().hex}"
policy = self.registry.resolve(tool_name)
while True:
@@ -106,7 +105,7 @@ async def execute_outcome(
lease_seconds=self.lease_seconds,
)
if decision.action == "known":
- return types_module.ToolExecutionOutcome(
+ return types.ToolExecutionOutcome(
decision.result,
decision.intent.effect_id,
decision.intent.result_id,
@@ -114,7 +113,7 @@ async def execute_outcome(
False,
)
if decision.action == "unknown":
- raise errors_module.ToolOutcomeUnknown(decision.effect_id)
+ raise errors.ToolOutcomeUnknown(decision.effect_id)
if decision.action == "wait":
await asyncio.sleep(self.poll_interval)
terminal = await asyncio.to_thread(
@@ -123,8 +122,8 @@ async def execute_outcome(
if terminal is None:
continue
if terminal.action == "unknown":
- raise errors_module.ToolOutcomeUnknown(decision.effect_id)
- return types_module.ToolExecutionOutcome(
+ raise errors.ToolOutcomeUnknown(decision.effect_id)
+ return types.ToolExecutionOutcome(
terminal.result,
terminal.intent.effect_id,
terminal.intent.result_id,
@@ -132,7 +131,7 @@ async def execute_outcome(
False,
)
if decision.action not in ("execute", "reconcile"):
- raise errors_module.ToolEffectError(
+ raise errors.ToolEffectError(
f"unsupported tool intent action: {decision.action}"
)
@@ -166,12 +165,12 @@ async def execute_outcome(
claim_owner=claim_owner,
reason="reconcile policy has no reconciliation adapter",
)
- raise errors_module.ToolOutcomeUnknown(decision.effect_id)
+ raise errors.ToolOutcomeUnknown(decision.effect_id)
reconciliation = self._reconciliation(
await self._call(policy.reconciler, decision.intent)
)
if claim_lost.is_set():
- raise errors_module.ToolEffectLeaseLost(decision.effect_id)
+ raise errors.ToolEffectLeaseLost(decision.effect_id)
if reconciliation.outcome == "applied":
result = await asyncio.to_thread(
self.journal.commit_result,
@@ -181,7 +180,7 @@ async def execute_outcome(
result=reconciliation.result,
reconciled=True,
)
- return types_module.ToolExecutionOutcome(
+ return types.ToolExecutionOutcome(
result,
decision.intent.effect_id,
decision.intent.result_id,
@@ -195,11 +194,11 @@ async def execute_outcome(
claim_owner=claim_owner,
reason="reconciliation could not determine external outcome",
)
- raise errors_module.ToolOutcomeUnknown(decision.effect_id)
+ raise errors.ToolOutcomeUnknown(decision.effect_id)
value = invoke()
result = await value if inspect.isawaitable(value) else value
if claim_lost.is_set():
- raise errors_module.ToolEffectLeaseLost(decision.effect_id)
+ raise errors.ToolEffectLeaseLost(decision.effect_id)
failed = (
bool(classify_failure(result))
if classify_failure is not None
@@ -218,7 +217,7 @@ async def execute_outcome(
result=result,
failed=failed,
)
- return types_module.ToolExecutionOutcome(
+ return types.ToolExecutionOutcome(
result,
decision.intent.effect_id,
decision.intent.result_id,
@@ -233,7 +232,7 @@ async def execute_outcome(
claim_owner=claim_owner,
)
raise
- except errors_module.ToolOutcomeUnknown:
+ except errors.ToolOutcomeUnknown:
with suppress(Exception):
self.journal.abandon_invocation_claim(
decision.effect_id,
@@ -248,7 +247,7 @@ async def execute_outcome(
run_owner=owner,
claim_owner=claim_owner,
)
- raise errors_module.ToolOutcomeUnknown(decision.effect_id) from exc
+ raise errors.ToolOutcomeUnknown(decision.effect_id) from exc
finally:
await self._stop_heartbeat(heartbeat)
tool_effect_context.CURRENT_TOOL_EFFECT.reset(token)
diff --git a/src/backend/core/runs/tool_effects/intent_transactions.py b/src/backend/core/runs/tool_effects/intent_transactions.py
index 327dad9a..753386cf 100644
--- a/src/backend/core/runs/tool_effects/intent_transactions.py
+++ b/src/backend/core/runs/tool_effects/intent_transactions.py
@@ -1,4 +1,4 @@
-"""intent transactions operations."""
+"""Record tool intents and decide whether a call may run."""
from __future__ import annotations
import uuid
from datetime import timedelta
@@ -8,14 +8,14 @@
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import Session
-from core.runs.tool_effects import arguments as arguments_module
-from core.runs.tool_effects import errors as errors_module
-from core.runs.tool_effects import journal_time as journal_time_module
-from core.runs.tool_effects import registry as registry_module
-from core.runs.tool_effects import types as types_module
+from core.runs.tool_effects import arguments
+from core.runs.tool_effects import errors
+from core.runs.tool_effects import registry
+from core.runs.tool_effects import types
+from core.infra.time import as_utc
-def intent_from_row(row: ToolEffectLedger) -> types_module.ToolIntent:
- return types_module.ToolIntent(
+def intent_from_row(row: ToolEffectLedger) -> types.ToolIntent:
+ return types.ToolIntent(
effect_id=row.effect_id,
result_id=row.result_id,
run_id=row.run_id,
@@ -26,7 +26,7 @@ def intent_from_row(row: ToolEffectLedger) -> types_module.ToolIntent:
redacted_args=dict(row.redacted_args or {}),
idempotency_key=row.idempotency_key or "",
recovery_policy=row.recovery_policy,
- created_at=journal_time_module.aware(row.created_at),
+ created_at=as_utc(row.created_at),
)
@@ -39,11 +39,11 @@ def terminal_row(db: Session, effect_id: str) -> Optional[ToolEffectLedger]:
def decision_from_terminal(
- intent: types_module.ToolIntent, terminal: ToolEffectLedger
-) -> types_module.IntentDecision:
+ intent: types.ToolIntent, terminal: ToolEffectLedger
+) -> types.IntentDecision:
if terminal.event_type == "unknown_outcome":
- return types_module.IntentDecision("unknown", intent, result_state="unknown")
- return types_module.IntentDecision(
+ return types.IntentDecision("unknown", intent, result_state="unknown")
+ return types.IntentDecision(
"known",
intent,
terminal.result_payload,
@@ -55,7 +55,7 @@ def validate_identity(
row: ToolEffectLedger, *, tool_name: str, args_hash: str
) -> None:
if row.tool_name != tool_name or row.args_hash != args_hash:
- raise errors_module.ToolIdempotencyConflict(
+ raise errors.ToolIdempotencyConflict(
"idempotency_key already belongs to different tool arguments"
)
@@ -70,7 +70,7 @@ def existing_decision(
tool_name: str,
args_hash: str,
lease_seconds: int,
-) -> types_module.IntentDecision:
+) -> types.IntentDecision:
with self._sessions() as db:
intent_row = (
db.query(ToolEffectLedger)
@@ -84,16 +84,16 @@ def existing_decision(
caller_run = db.get(ChatRun, caller_run_id)
original_run = db.get(ChatRun, intent.run_id)
if caller_run is None or original_run is None:
- raise errors_module.ToolIntentCommitError(f"run does not exist: {caller_run_id}")
+ raise errors.ToolIntentCommitError(f"run does not exist: {caller_run_id}")
if original_run.user_id != caller_run.user_id:
- raise errors_module.ToolIdempotencyConflict(
+ raise errors.ToolIdempotencyConflict(
"idempotency_key belongs to a different principal"
)
terminal = self._terminal_row(db, intent.effect_id)
if terminal is not None and caller_run_id == intent.run_id:
return self._decision_from_terminal(intent, terminal)
- now = journal_time_module.aware(self._clock())
- caller_expiry = journal_time_module.aware(caller_run.lease_expires_at)
+ now = as_utc(self._clock())
+ caller_expiry = as_utc(caller_run.lease_expires_at)
if (
caller_run.status not in LIVE_STATUSES
or caller_run.lease_owner != run_owner
@@ -101,11 +101,11 @@ def existing_decision(
or now is None
or caller_expiry <= now
):
- raise errors_module.ToolIntentCommitError(f"run lease is not owned: {caller_run_id}")
+ raise errors.ToolIntentCommitError(f"run lease is not owned: {caller_run_id}")
if terminal is not None:
return self._decision_from_terminal(intent, terminal)
if intent.run_id != caller_run_id:
- return types_module.IntentDecision("wait", intent)
+ return types.IntentDecision("wait", intent)
lease = (
db.query(ToolEffectLease)
@@ -117,20 +117,20 @@ def existing_decision(
terminal = self._terminal_row(db, intent.effect_id)
if terminal is not None:
return self._decision_from_terminal(intent, terminal)
- lease_expiry = journal_time_module.aware(lease.lease_expires_at)
+ lease_expiry = as_utc(lease.lease_expires_at)
if (
lease.run_owner == run_owner
and lease_expiry is not None
and lease_expiry > now
):
if lease.claim_owner == claim_owner:
- return types_module.IntentDecision(
+ return types.IntentDecision(
"reconcile"
if intent.recovery_policy == "reconcile"
else "execute",
intent,
)
- return types_module.IntentDecision("wait", intent)
+ return types.IntentDecision("wait", intent)
previous_owner = lease.run_owner
previous_claim = lease.claim_owner
@@ -160,7 +160,7 @@ def existing_decision(
)
if not affected:
db.rollback()
- return types_module.IntentDecision("wait", intent)
+ return types.IntentDecision("wait", intent)
db.expire_all()
lease = db.get(ToolEffectLease, intent.effect_id)
self._append_event(
@@ -184,9 +184,9 @@ def existing_decision(
reason="non-replayable tool intent has no committed result",
)
db.commit()
- return types_module.IntentDecision("unknown", intent)
+ return types.IntentDecision("unknown", intent)
db.commit()
- return types_module.IntentDecision(
+ return types.IntentDecision(
"reconcile" if intent.recovery_policy == "reconcile" else "execute",
intent,
)
@@ -205,16 +205,16 @@ def begin_intent(
idempotency_key: Optional[str] = None,
lease_seconds: int = 300,
_conflict_retries: int = 4,
-) -> types_module.IntentDecision:
- if recovery_policy not in registry_module.RECOVERY_POLICIES:
+) -> types.IntentDecision:
+ if recovery_policy not in registry.RECOVERY_POLICIES:
raise ValueError(f"invalid recovery policy: {recovery_policy}")
- _canonical, args_hash, redacted = arguments_module.canonical_tool_args(args)
+ _canonical, args_hash, redacted = arguments.canonical_tool_args(args)
with self._sessions() as db:
caller_run = db.get(ChatRun, run_id)
if caller_run is None:
- raise errors_module.ToolIntentCommitError(f"run does not exist: {run_id}")
+ raise errors.ToolIntentCommitError(f"run does not exist: {run_id}")
principal_scope = caller_run.user_id
- key, _explicit = arguments_module.resolved_idempotency_key(
+ key, _explicit = arguments.resolved_idempotency_key(
run_id=run_id,
principal_scope=str(principal_scope),
tool_name=tool_name,
@@ -302,8 +302,8 @@ def begin_intent(
)
run.updated_at = self._clock()
db.commit()
- return types_module.IntentDecision("execute", self._intent_from_row(row))
- except errors_module.ToolEffectError:
+ return types.IntentDecision("execute", self._intent_from_row(row))
+ except errors.ToolEffectError:
raise
except (IntegrityError, OperationalError) as exc:
with self._sessions() as db:
@@ -335,8 +335,8 @@ def begin_intent(
lease_seconds=lease_seconds,
_conflict_retries=_conflict_retries - 1,
)
- raise errors_module.ToolIntentCommitError(str(exc)) from exc
+ raise errors.ToolIntentCommitError(str(exc)) from exc
except (RunNotFound, RunLeaseLost) as exc:
- raise errors_module.ToolIntentCommitError(str(exc)) from exc
+ raise errors.ToolIntentCommitError(str(exc)) from exc
except Exception as exc: # noqa: BLE001
- raise errors_module.ToolIntentCommitError(str(exc)) from exc
+ raise errors.ToolIntentCommitError(str(exc)) from exc
diff --git a/src/backend/core/runs/tool_effects/invocation_claims.py b/src/backend/core/runs/tool_effects/invocation_claims.py
index 4a8ac529..c67ae972 100644
--- a/src/backend/core/runs/tool_effects/invocation_claims.py
+++ b/src/backend/core/runs/tool_effects/invocation_claims.py
@@ -1,9 +1,9 @@
-"""invocation claims operations."""
+"""Acquire, renew and abandon invocation claims."""
from __future__ import annotations
from datetime import timedelta
from core.db.models import ChatRun, ToolEffectLease, ToolEffectLedger
from core.runs.journal import LIVE_STATUSES
-from core.runs.tool_effects import errors as errors_module
+from core.runs.tool_effects import errors
def acquire_invocation_claim(
self,
@@ -41,7 +41,7 @@ def acquire_invocation_claim(
)
if not affected or self._terminal_row(db, effect_id) is not None:
db.rollback()
- raise errors_module.ToolEffectLeaseLost(effect_id)
+ raise errors.ToolEffectLeaseLost(effect_id)
db.commit()
@@ -67,7 +67,7 @@ def renew_invocation_claim(
lease_seconds=lease_seconds,
)
return True
- except errors_module.ToolEffectLeaseLost:
+ except errors.ToolEffectLeaseLost:
return False
diff --git a/src/backend/core/runs/tool_effects/journal.py b/src/backend/core/runs/tool_effects/journal.py
index a9cfe8c1..7b593351 100644
--- a/src/backend/core/runs/tool_effects/journal.py
+++ b/src/backend/core/runs/tool_effects/journal.py
@@ -1,25 +1,18 @@
-"""journal responsibilities."""
+"""Synchronous transaction boundary behind the tool effect gateway."""
from __future__ import annotations
from core.runs.tool_effects import intent_transactions
from core.runs.tool_effects import result_transactions
from core.runs.tool_effects import invocation_claims
from core.runs.tool_effects import retention
from core.runs.tool_effects import recovery_claims
-import uuid
-from datetime import datetime, timedelta
+from datetime import datetime
from typing import Any, Callable, Mapping, Optional
from core.db.engine import SessionLocal
from core.db.models import ChatRun, ToolEffectLease, ToolEffectLedger
-from core.runs.journal import LIVE_STATUSES, RunJournal, RunLeaseLost, RunNotFound
-from sqlalchemy import and_, case, or_
-from sqlalchemy.exc import IntegrityError, OperationalError
-from sqlalchemy.orm import Session, aliased, sessionmaker
-from core.runs.tool_effects import arguments as arguments_module
-from core.runs.tool_effects import errors as errors_module
-from core.runs.tool_effects import journal_state as journal_state_module
-from core.runs.tool_effects import journal_time as journal_time_module
-from core.runs.tool_effects import registry as registry_module
-from core.runs.tool_effects import types as types_module
+from core.runs.journal import RunJournal
+from sqlalchemy.orm import Session, sessionmaker
+from core.runs.tool_effects import types
+from core.infra.time import utc_now
class ToolEffectJournal:
"""Small synchronous transaction boundary behind the async gateway."""
@@ -28,14 +21,14 @@ def __init__(
self,
session_factory: sessionmaker = SessionLocal,
*,
- clock: Callable[[], datetime] = journal_time_module.utc_now,
+ clock: Callable[[], datetime] = utc_now,
) -> None:
self._sessions = session_factory
self._clock = clock
self._runs = RunJournal(session_factory, clock=clock)
@staticmethod
- def _intent_from_row(row: ToolEffectLedger) -> types_module.ToolIntent:
+ def _intent_from_row(row: ToolEffectLedger) -> types.ToolIntent:
return intent_transactions.intent_from_row(row)
@staticmethod
@@ -44,8 +37,8 @@ def _terminal_row(db: Session, effect_id: str) -> Optional[ToolEffectLedger]:
@staticmethod
def _decision_from_terminal(
- intent: types_module.ToolIntent, terminal: ToolEffectLedger
- ) -> types_module.IntentDecision:
+ intent: types.ToolIntent, terminal: ToolEffectLedger
+ ) -> types.IntentDecision:
return intent_transactions.decision_from_terminal(intent, terminal)
@staticmethod
@@ -94,7 +87,7 @@ def _existing_decision(
tool_name: str,
args_hash: str,
lease_seconds: int,
- ) -> types_module.IntentDecision:
+ ) -> types.IntentDecision:
return intent_transactions.existing_decision(self, key=key, caller_run_id=caller_run_id, run_owner=run_owner, claim_owner=claim_owner, tool_name=tool_name, args_hash=args_hash, lease_seconds=lease_seconds)
def begin_intent(
@@ -110,7 +103,7 @@ def begin_intent(
idempotency_key: Optional[str] = None,
lease_seconds: int = 300,
_conflict_retries: int = 4,
- ) -> types_module.IntentDecision:
+ ) -> types.IntentDecision:
return intent_transactions.begin_intent(self, run_id=run_id, owner=owner, claim_owner=claim_owner, tool_call_id=tool_call_id, tool_name=tool_name, args=args, recovery_policy=recovery_policy, idempotency_key=idempotency_key, lease_seconds=lease_seconds, _conflict_retries=_conflict_retries)
def acquire_invocation_claim(
@@ -190,7 +183,7 @@ def mark_unknown(
) -> None:
return result_transactions.mark_unknown(self, effect_id, owner=owner, claim_owner=claim_owner, reason=reason)
- def terminal_decision(self, effect_id: str) -> Optional[types_module.IntentDecision]:
+ def terminal_decision(self, effect_id: str) -> Optional[types.IntentDecision]:
return result_transactions.terminal_decision(self, effect_id)
def pending_effect_ids(self) -> list[str]:
@@ -199,7 +192,7 @@ def pending_effect_ids(self) -> list[str]:
def prune_settled(self, *, retention_days: int = 7) -> int:
return retention.prune_settled(self, retention_days=retention_days)
- def settle_terminal_run_intent(self, effect_id: str) -> Optional[types_module.ToolIntent]:
+ def settle_terminal_run_intent(self, effect_id: str) -> Optional[types.ToolIntent]:
return retention.settle_terminal_run_intent(self, effect_id)
def claim_recovery_intent(
@@ -208,7 +201,7 @@ def claim_recovery_intent(
*,
recovery_owner: str,
lease_seconds: int = 120,
- ) -> Optional[types_module.ToolIntent]:
+ ) -> Optional[types.ToolIntent]:
return recovery_claims.claim_recovery_intent(self, effect_id, recovery_owner=recovery_owner, lease_seconds=lease_seconds)
def commit_recovered_result(
diff --git a/src/backend/core/runs/tool_effects/journal_state.py b/src/backend/core/runs/tool_effects/journal_constants.py
similarity index 91%
rename from src/backend/core/runs/tool_effects/journal_state.py
rename to src/backend/core/runs/tool_effects/journal_constants.py
index 2ed59ef2..6963b27b 100644
--- a/src/backend/core/runs/tool_effects/journal_state.py
+++ b/src/backend/core/runs/tool_effects/journal_constants.py
@@ -1,4 +1,4 @@
-"""journal state responsibilities."""
+"""Retention and redaction settings for the tool effect journal."""
from __future__ import annotations
import re
diff --git a/src/backend/core/runs/tool_effects/journal_time.py b/src/backend/core/runs/tool_effects/journal_time.py
deleted file mode 100644
index 1fac5224..00000000
--- a/src/backend/core/runs/tool_effects/journal_time.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""journal time responsibilities."""
-from __future__ import annotations
-from datetime import datetime, timezone
-from typing import Optional
-
-def utc_now() -> datetime:
- return datetime.now(timezone.utc)
-
-
-def aware(value: Optional[datetime]) -> Optional[datetime]:
- if value is None:
- return None
- if value.tzinfo is None:
- return value.replace(tzinfo=timezone.utc)
- return value.astimezone(timezone.utc)
diff --git a/src/backend/core/runs/tool_effects/recovery.py b/src/backend/core/runs/tool_effects/recovery.py
index fbd953b8..5fd565b3 100644
--- a/src/backend/core/runs/tool_effects/recovery.py
+++ b/src/backend/core/runs/tool_effects/recovery.py
@@ -1,30 +1,30 @@
-"""recovery responsibilities."""
+"""Recover tool effects left incomplete by a lost process."""
from __future__ import annotations
-from core.runs.tool_effects import journal as journal_module_
+from core.runs.tool_effects.journal import ToolEffectJournal
import asyncio
import time
import uuid
from contextlib import suppress
from typing import Optional
import logging
+from core.runs.tool_effects import errors
+from core.runs.tool_effects import gateway
+from core.runs.tool_effects.registry import DEFAULT_TOOL_RECOVERY_REGISTRY, ToolRecoveryRegistry
+from core.runs.tool_effects import types
+
logger = logging.getLogger(__name__)
-from core.runs.tool_effects import errors as errors_module
-from core.runs.tool_effects import gateway as gateway_module
-from core.runs.tool_effects import journal as journal_module
-from core.runs.tool_effects import registry as registry_module
-from core.runs.tool_effects import types as types_module
async def recover_incomplete_tool_effects(
*,
- journal: Optional[journal_module_.ToolEffectJournal] = None,
- registry: Optional[registry_module.ToolRecoveryRegistry] = None,
+ journal: Optional[ToolEffectJournal] = None,
+ registry: Optional[ToolRecoveryRegistry] = None,
lease_seconds: int = 120,
heartbeat_interval: Optional[float] = None,
-) -> list[types_module.RecoverySweepDecision]:
+) -> list[types.RecoverySweepDecision]:
"""Recover each orphan independently; one broken adapter cannot starve later work."""
- journal = journal or journal_module_.ToolEffectJournal()
- registry = registry or registry_module.DEFAULT_TOOL_RECOVERY_REGISTRY
+ journal = journal or ToolEffectJournal()
+ registry = registry or DEFAULT_TOOL_RECOVERY_REGISTRY
from core.harness.usage import UsageAttempt, attempt_status_for_exception, record_usage_safely
from core.harness.ledger import HarnessUsageLedger
@@ -34,7 +34,7 @@ async def _recorded_recovery_call(handler, intent, phase): # noqa: ANN001
started = time.monotonic()
status = "failed"
try:
- result = await gateway_module.ToolEffectGateway._call(handler, intent)
+ result = await gateway.ToolEffectGateway._call(handler, intent)
status = "success"
return result
except asyncio.CancelledError:
@@ -60,12 +60,12 @@ async def _recorded_recovery_call(handler, intent, phase): # noqa: ANN001
except Exception: # noqa: BLE001
logger.debug("tool recovery usage persistence failed", exc_info=True)
- decisions: list[types_module.RecoverySweepDecision] = []
+ decisions: list[types.RecoverySweepDecision] = []
for effect_id in await asyncio.to_thread(journal.pending_effect_ids):
terminal_intent = journal.settle_terminal_run_intent(effect_id)
if terminal_intent is not None:
decisions.append(
- types_module.RecoverySweepDecision(
+ types.RecoverySweepDecision(
effect_id, terminal_intent.run_id, "terminal_unknown"
)
)
@@ -110,32 +110,32 @@ async def _heartbeat() -> None:
reason="non-replayable tool intent has no committed result",
)
decisions.append(
- types_module.RecoverySweepDecision(effect_id, intent.run_id, "needs_attention")
+ types.RecoverySweepDecision(effect_id, intent.run_id, "needs_attention")
)
continue
if intent.recovery_policy == "replay_safe":
if policy.replayer is None:
- raise errors_module.ToolEffectError("replay_safe policy has no replay adapter")
+ raise errors.ToolEffectError("replay_safe policy has no replay adapter")
result = await _recorded_recovery_call(
policy.replayer, intent, "replay"
)
if claim_lost.is_set():
- raise errors_module.ToolEffectLeaseLost(effect_id)
+ raise errors.ToolEffectLeaseLost(effect_id)
journal.commit_recovered_result(
effect_id, result, recovery_owner=recovery_owner
)
decisions.append(
- types_module.RecoverySweepDecision(effect_id, intent.run_id, "replayed")
+ types.RecoverySweepDecision(effect_id, intent.run_id, "replayed")
)
continue
if policy.reconciler is None:
- raise errors_module.ToolEffectError("reconcile policy has no reconciliation adapter")
- reconciliation = gateway_module.ToolEffectGateway._reconciliation(
+ raise errors.ToolEffectError("reconcile policy has no reconciliation adapter")
+ reconciliation = gateway.ToolEffectGateway._reconciliation(
await _recorded_recovery_call(policy.reconciler, intent, "reconcile")
)
if claim_lost.is_set():
- raise errors_module.ToolEffectLeaseLost(effect_id)
+ raise errors.ToolEffectLeaseLost(effect_id)
if reconciliation.outcome == "applied":
result = reconciliation.result
elif (
@@ -152,23 +152,23 @@ async def _heartbeat() -> None:
reason="reconciliation could not determine external outcome",
)
decisions.append(
- types_module.RecoverySweepDecision(effect_id, intent.run_id, "needs_attention")
+ types.RecoverySweepDecision(effect_id, intent.run_id, "needs_attention")
)
continue
if claim_lost.is_set():
- raise errors_module.ToolEffectLeaseLost(effect_id)
+ raise errors.ToolEffectLeaseLost(effect_id)
journal.commit_recovered_result(
effect_id, result, recovery_owner=recovery_owner
)
decisions.append(
- types_module.RecoverySweepDecision(effect_id, intent.run_id, "reconciled")
+ types.RecoverySweepDecision(effect_id, intent.run_id, "reconciled")
)
except Exception: # noqa: BLE001 - release this claim and continue the sweep
try:
journal.release_recovery_claim(effect_id, recovery_owner=recovery_owner)
- except errors_module.ToolEffectError:
+ except errors.ToolEffectError:
pass
- decisions.append(types_module.RecoverySweepDecision(effect_id, intent.run_id, "retry"))
+ decisions.append(types.RecoverySweepDecision(effect_id, intent.run_id, "retry"))
finally:
heartbeat.cancel()
with suppress(asyncio.CancelledError):
diff --git a/src/backend/core/runs/tool_effects/recovery_claims.py b/src/backend/core/runs/tool_effects/recovery_claims.py
index 126992df..c2f72da2 100644
--- a/src/backend/core/runs/tool_effects/recovery_claims.py
+++ b/src/backend/core/runs/tool_effects/recovery_claims.py
@@ -1,12 +1,12 @@
-"""recovery claims operations."""
+"""Claim and settle intents during recovery."""
from __future__ import annotations
from datetime import timedelta
from typing import Any, Optional
from core.db.models import ChatRun, ToolEffectLease, ToolEffectLedger
from core.runs.journal import LIVE_STATUSES
from sqlalchemy import or_
-from core.runs.tool_effects import errors as errors_module
-from core.runs.tool_effects import types as types_module
+from core.runs.tool_effects import errors
+from core.runs.tool_effects import types
def claim_recovery_intent(
self,
@@ -14,7 +14,7 @@ def claim_recovery_intent(
*,
recovery_owner: str,
lease_seconds: int = 120,
-) -> Optional[types_module.ToolIntent]:
+) -> Optional[types.ToolIntent]:
with self._sessions() as db:
intent = (
db.query(ToolEffectLedger)
@@ -124,7 +124,7 @@ def commit_recovered_result(
.one()
)
if lease.run_owner != recovery_owner or run.lease_owner != recovery_owner:
- raise errors_module.ToolEffectLeaseLost(effect_id)
+ raise errors.ToolEffectLeaseLost(effect_id)
run.lease_owner = None
run.lease_expires_at = None
run.updated_at = self._clock()
@@ -148,7 +148,7 @@ def release_recovery_claim(
.one()
)
if lease.run_owner != recovery_owner or lease.claim_owner != recovery_owner:
- raise errors_module.ToolEffectLeaseLost(effect_id)
+ raise errors.ToolEffectLeaseLost(effect_id)
run = (
db.query(ChatRun)
.filter(ChatRun.run_id == lease.run_id)
@@ -156,7 +156,7 @@ def release_recovery_claim(
.one()
)
if run.lease_owner != recovery_owner:
- raise errors_module.ToolEffectLeaseLost(effect_id)
+ raise errors.ToolEffectLeaseLost(effect_id)
retry_at = self._clock() + timedelta(seconds=max(1, retry_seconds))
lease.claim_owner = None
lease.lease_expires_at = retry_at
diff --git a/src/backend/core/runs/tool_effects/registry.py b/src/backend/core/runs/tool_effects/registry.py
index a3dc72a2..36d1608e 100644
--- a/src/backend/core/runs/tool_effects/registry.py
+++ b/src/backend/core/runs/tool_effects/registry.py
@@ -1,7 +1,7 @@
-"""registry responsibilities."""
+"""Recovery policy for each tool; unknown tools are never replayed."""
from __future__ import annotations
from typing import Any, Awaitable, Callable, Optional, cast
-from core.runs.tool_effects import types as types_module
+from core.runs.tool_effects import types
RECOVERY_POLICIES = ("replay_safe", "reconcile", "never_replay")
@@ -10,16 +10,16 @@ class ToolRecoveryRegistry:
"""Explicit adapter contracts. Unknown tools fail closed as never-replay."""
def __init__(self) -> None:
- self._policies: dict[str, types_module.ToolPolicy] = {}
+ self._policies: dict[str, types.ToolPolicy] = {}
def register(
self,
tool_name: str,
policy: str,
*,
- replayer: Optional[Callable[[types_module.ToolIntent], Awaitable[Any] | Any]] = None,
+ replayer: Optional[Callable[[types.ToolIntent], Awaitable[Any] | Any]] = None,
reconciler: Optional[
- Callable[[types_module.ToolIntent], Awaitable[types_module.ReconciliationResult | Any] | Any]
+ Callable[[types.ToolIntent], Awaitable[types.ReconciliationResult | Any] | Any]
] = None,
) -> None:
if policy not in RECOVERY_POLICIES:
@@ -30,22 +30,22 @@ def register(
replayer is not None or reconciler is not None
):
raise ValueError("never_replay does not accept recovery adapters")
- self._policies[str(tool_name)] = types_module.ToolPolicy(policy, replayer, reconciler)
+ self._policies[str(tool_name)] = types.ToolPolicy(policy, replayer, reconciler)
- def resolve(self, tool_name: str) -> types_module.ToolPolicy:
- return self._policies.get(str(tool_name), types_module.ToolPolicy("never_replay"))
+ def resolve(self, tool_name: str) -> types.ToolPolicy:
+ return self._policies.get(str(tool_name), types.ToolPolicy("never_replay"))
-async def default_replayer(intent: types_module.ToolIntent) -> Any:
+async def default_replayer(intent: types.ToolIntent) -> Any:
from core.runs.tool_effect_recovery import replay_tool_intent
return await replay_tool_intent(intent)
-async def scheduled_task_reconciler(intent: types_module.ToolIntent) -> types_module.ReconciliationResult:
+async def scheduled_task_reconciler(intent: types.ToolIntent) -> types.ReconciliationResult:
from core.runs.tool_effect_recovery import reconcile_scheduled_task_intent
- return cast(types_module.ReconciliationResult, await reconcile_scheduled_task_intent(intent))
+ return cast(types.ReconciliationResult, await reconcile_scheduled_task_intent(intent))
def build_default_tool_recovery_registry() -> ToolRecoveryRegistry:
diff --git a/src/backend/core/runs/tool_effects/result_transactions.py b/src/backend/core/runs/tool_effects/result_transactions.py
index 1c159605..b66b8e3b 100644
--- a/src/backend/core/runs/tool_effects/result_transactions.py
+++ b/src/backend/core/runs/tool_effects/result_transactions.py
@@ -1,12 +1,12 @@
-"""result transactions operations."""
+"""Commit tool results or mark their outcome unknown."""
from __future__ import annotations
from typing import Any, Optional
from core.db.models import ChatRun, ToolEffectLease, ToolEffectLedger
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import Session, aliased
-from core.runs.tool_effects import errors as errors_module
-from core.runs.tool_effects import journal_time as journal_time_module
-from core.runs.tool_effects import types as types_module
+from core.runs.tool_effects import errors
+from core.runs.tool_effects import types
+from core.infra.time import as_utc
def append_event(
self,
@@ -65,7 +65,7 @@ def mark_unknown_locked(
if self._terminal_row(db, intent.effect_id) is not None:
return
if lease.run_owner != owner or lease.claim_owner != claim_owner:
- raise errors_module.ToolEffectLeaseLost(intent.effect_id)
+ raise errors.ToolEffectLeaseLost(intent.effect_id)
self._append_event(
db,
run,
@@ -102,10 +102,10 @@ def commit_result_locked(
terminal = self._terminal_row(db, intent.effect_id)
if terminal is not None:
if terminal.event_type == "unknown_outcome":
- raise errors_module.ToolOutcomeUnknown(intent.effect_id)
+ raise errors.ToolOutcomeUnknown(intent.effect_id)
return terminal.result_payload
if lease.run_owner != owner:
- raise errors_module.ToolEffectLeaseLost(intent.effect_id)
+ raise errors.ToolEffectLeaseLost(intent.effect_id)
run = self._runs._locked_owned_run(db, intent.run_id, owner)
self._append_event(
db,
@@ -152,7 +152,7 @@ def commit_result(
terminal = self._terminal_row(db, effect_id)
if terminal is not None:
if terminal.event_type == "unknown_outcome":
- raise errors_module.ToolOutcomeUnknown(effect_id)
+ raise errors.ToolOutcomeUnknown(effect_id)
return terminal.result_payload
lease = (
db.query(ToolEffectLease)
@@ -160,8 +160,8 @@ def commit_result(
.with_for_update()
.one()
)
- now = journal_time_module.aware(self._clock())
- lease_expiry = journal_time_module.aware(lease.lease_expires_at)
+ now = as_utc(self._clock())
+ lease_expiry = as_utc(lease.lease_expires_at)
if (
lease.run_owner != run_owner
or lease.claim_owner != claim_owner
@@ -177,9 +177,9 @@ def commit_result(
terminal = self._terminal_row(db, effect_id)
if terminal is not None:
if terminal.event_type == "unknown_outcome":
- raise errors_module.ToolOutcomeUnknown(effect_id)
+ raise errors.ToolOutcomeUnknown(effect_id)
return terminal.result_payload
- raise errors_module.ToolEffectLeaseLost(effect_id)
+ raise errors.ToolEffectLeaseLost(effect_id)
value = self._commit_result_locked(
db,
intent,
@@ -196,7 +196,7 @@ def commit_result(
terminal = self.terminal_decision(effect_id)
if terminal is not None:
if terminal.action == "unknown":
- raise errors_module.ToolOutcomeUnknown(effect_id)
+ raise errors.ToolOutcomeUnknown(effect_id)
return terminal.result
if _conflict_retries > 0:
return self.commit_result(
@@ -208,7 +208,7 @@ def commit_result(
reconciled=reconciled,
_conflict_retries=_conflict_retries - 1,
)
- raise errors_module.ToolEffectError(str(exc)) from exc
+ raise errors.ToolEffectError(str(exc)) from exc
return value
@@ -248,7 +248,7 @@ def mark_unknown(
db.commit()
-def terminal_decision(self, effect_id: str) -> Optional[types_module.IntentDecision]:
+def terminal_decision(self, effect_id: str) -> Optional[types.IntentDecision]:
with self._sessions() as db:
intent = (
db.query(ToolEffectLedger)
diff --git a/src/backend/core/runs/tool_effects/retention.py b/src/backend/core/runs/tool_effects/retention.py
index 6ce7ef08..0d089fe1 100644
--- a/src/backend/core/runs/tool_effects/retention.py
+++ b/src/backend/core/runs/tool_effects/retention.py
@@ -1,13 +1,13 @@
-"""retention operations."""
+"""Prune settled tool effect records and close orphaned intents."""
from __future__ import annotations
from datetime import timedelta
from typing import Optional
from core.db.models import ChatRun, ToolEffectLease, ToolEffectLedger
from core.runs.journal import LIVE_STATUSES
from sqlalchemy import and_, case
-from core.runs.tool_effects import journal_state as journal_state_module
-from core.runs.tool_effects import journal_time as journal_time_module
-from core.runs.tool_effects import types as types_module
+from core.runs.tool_effects import journal_constants
+from core.runs.tool_effects import types
+from core.infra.time import as_utc
def prune_settled(self, *, retention_days: int = 7) -> int:
"""Drop per-run recovery/observability facts of long-terminal runs.
@@ -32,9 +32,9 @@ def prune_settled(self, *, retention_days: int = 7) -> int:
"""
from core.db.models import ChatRunOperation, HarnessEventLog, HarnessUsageAttempt
- now = journal_time_module.aware(self._clock())
+ now = as_utc(self._clock())
cutoff = now - timedelta(days=retention_days)
- attention_cutoff = now - timedelta(days=retention_days * journal_state_module.ATTENTION_RETENTION_FACTOR)
+ attention_cutoff = now - timedelta(days=retention_days * journal_constants.ATTENTION_RETENTION_FACTOR)
with self._sessions() as db:
earliest_cutoff = max(cutoff, attention_cutoff)
@@ -97,7 +97,7 @@ def _prunable(run_col, stamp):
return deleted
-def settle_terminal_run_intent(self, effect_id: str) -> Optional[types_module.ToolIntent]:
+def settle_terminal_run_intent(self, effect_id: str) -> Optional[types.ToolIntent]:
"""Close an orphan intent without replay after its owning run is terminal.
Cancellation/failure is a hard replay boundary: the user's stopped run
diff --git a/src/backend/core/runs/tool_effects/types.py b/src/backend/core/runs/tool_effects/types.py
index f682f27e..2052f25d 100644
--- a/src/backend/core/runs/tool_effects/types.py
+++ b/src/backend/core/runs/tool_effects/types.py
@@ -1,6 +1,5 @@
-"""types responsibilities."""
+"""Value types for tool intents, decisions and outcomes."""
from __future__ import annotations
-from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Awaitable, Callable, Mapping, Optional
diff --git a/src/backend/core/runs/watchdog.py b/src/backend/core/runs/watchdog.py
index 799613fa..6521a8e5 100644
--- a/src/backend/core/runs/watchdog.py
+++ b/src/backend/core/runs/watchdog.py
@@ -17,6 +17,7 @@
import core.runs.settings as settings
import core.runs.tasks as tasks
from core.infra.logging import get_logger
+from core.db.threads import run_in_session
logger = get_logger(__name__)
@@ -186,7 +187,7 @@ async def reap_stale_runs() -> int:
now = clock._utcnow()
cutoff = now - _datetime.timedelta(seconds=settings._STALE_RUN_MAX_AGE_SEC)
hard_cutoff = now - _datetime.timedelta(seconds=settings._HARD_MAX_AGE_SEC)
- with engine.SessionLocal() as db:
+ def _stale_candidates(db):
candidates = (
db.query(
models.ChatRun.run_id,
@@ -203,6 +204,8 @@ async def reap_stale_runs() -> int:
.filter(func.coalesce(models.ChatRun.started_at, models.ChatRun.created_at) < cutoff)
.all()
)
+ return candidates
+ candidates = await run_in_session(_stale_candidates)
if not candidates:
return 0
diff --git a/src/backend/core/sandbox/commands/context.py b/src/backend/core/sandbox/commands/context.py
index 57e7749c..3a412598 100644
--- a/src/backend/core/sandbox/commands/context.py
+++ b/src/backend/core/sandbox/commands/context.py
@@ -6,7 +6,7 @@
def completion_context(row):
"""One stable sideband record per completed command, never a chat row."""
- from core.llm.context.ir_state import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
+ from core.llm.context.ir_constants import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
from core.llm.context.items import make_text_context_item, session_context_metadata
event_id = "command_event_" + row["task_id"]
prompt = notice_text([row])
diff --git a/src/backend/core/sandbox/commands/delivery.py b/src/backend/core/sandbox/commands/delivery.py
index e3efa789..bb969009 100644
--- a/src/backend/core/sandbox/commands/delivery.py
+++ b/src/backend/core/sandbox/commands/delivery.py
@@ -63,7 +63,7 @@ def attach(db, run):
return attach
def prepare(rows, owner):
- from core.llm.context.ir_state import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
+ from core.llm.context.ir_constants import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
from core.llm.context.items import make_text_context_item, session_context_metadata
first = rows[0]
with store.SessionLocal() as db:
diff --git a/src/backend/core/sandbox/commands/runtime.py b/src/backend/core/sandbox/commands/runtime.py
index 891d6d6d..c9e1c2a6 100644
--- a/src/backend/core/sandbox/commands/runtime.py
+++ b/src/backend/core/sandbox/commands/runtime.py
@@ -43,6 +43,22 @@ def __init__(self):
self.tasks = {}
self.providers = {}
self._locks = {}
+ self._heartbeat = None
+
+ def _ensure_heartbeat(self):
+ if self._heartbeat is None or self._heartbeat.done():
+ self._heartbeat = asyncio.create_task(self._renew_handles())
+
+ async def _renew_handles(self):
+ while self.providers:
+ try:
+ await asyncio.wait_for(
+ asyncio.to_thread(store.renew_owned, list(self.providers), self.owner),
+ timeout=store.LEASE_SECONDS / 3,
+ )
+ except Exception:
+ logger.exception("Command handle renewal failed")
+ await asyncio.sleep(store.LEASE_SECONDS / 3)
def bind_loop(self):
"""Initialize every worker before a temporary agent thread can call it."""
@@ -65,24 +81,12 @@ async def start(
return await self._attach(tid, provider, req, result)
except BaseException as exc:
if result and result.get("status") == "running" and tid not in self.providers:
- # Persistence can fail after launch (for example a hard-deleted chat).
- stopped = await provider.write_stdin(
- result["session_id"],
- sandbox_session_id=req.session_id,
- user_id=req.user_id,
- chars="\x03",
- yield_time_ms=1000,
- )
- while stopped.get("status") == "running":
- stopped = await provider.write_stdin(
- result["session_id"],
- sandbox_session_id=req.session_id,
- user_id=req.user_id,
- chars="",
- yield_time_ms=1000,
- )
- if stopped.get("status") != "exited":
- raise RuntimeError("Unattached process stop not confirmed") from exc
+ # Preserve control even if persistence or the first stop RPC fails.
+ self.providers[tid] = provider, req, result["session_id"]
+ self._ensure_heartbeat()
+ await self._interrupt(tid)
+ self.providers.pop(tid, None)
+ self._locks.pop(tid, None)
try:
await asyncio.to_thread(
store.finish, tid, "unknown", "Launch state unknown; do not rerun: " + str(exc)
@@ -123,6 +127,7 @@ async def _attach(self, tid, provider, req, result):
)
if row["status"] == "running":
self.providers[tid] = provider, req, result["session_id"]
+ self._ensure_heartbeat()
self.tasks[tid] = asyncio.create_task(self._monitor(tid, row))
payload["next"] = (
"Command is managed in the background. Completion will notify this chat. Do independent work; do not poll or restart it. If no independent work remains, end this response once; completion will resume this chat."
@@ -168,66 +173,95 @@ async def adopt(self, provider, process_id, *, user_id, session_id, chat_id, sou
async def _monitor(self, tid, row):
provider, req, pid = self.providers[tid]
last_touch = 0
+ failures = 0
try:
while True:
- if not await asyncio.to_thread(store.chat_available, tid):
- result = await self._interrupt(tid)
- try:
+ try:
+ if not await asyncio.to_thread(store.chat_available, tid):
+ result = await self._interrupt(tid)
+ try:
+ await asyncio.to_thread(
+ store.control_result,
+ tid,
+ self.owner,
+ "cancelled",
+ "Conversation removed",
+ True,
+ result,
+ )
+ except LookupError:
+ pass # Physical deletion removed the row; the owned process was stopped.
+ break
+ row = await asyncio.to_thread(store.snapshot, tid)
+ if row["status"] not in store.LIVE or row["owner"] != self.owner:
+ if row["execution_ref"]:
+ await release_execution(provider, pid, req)
+ else:
+ await self._interrupt(tid)
+ break
+ if row["cancel_requested"]:
+ result = await self._interrupt(tid)
+ await asyncio.to_thread(
+ store.control_result, tid, self.owner, "cancelled", "", True, result
+ )
+ break
+ if row["deadline_at"] and time.time() >= row["deadline_at"]:
+ result = await self._interrupt(tid)
await asyncio.to_thread(
store.control_result,
tid,
self.owner,
- "cancelled",
- "Conversation removed",
- True,
+ "failed",
+ "Command execution deadline exceeded",
+ False,
result,
)
- except LookupError:
- pass # Physical deletion removed the row; the owned process was stopped.
- break
- row = await asyncio.to_thread(store.snapshot, tid)
- if row["status"] not in store.LIVE or row["owner"] != self.owner:
- await release_execution(provider, pid, req)
- break
- if row["cancel_requested"]:
- result = await self._interrupt(tid)
- await asyncio.to_thread(
- store.control_result, tid, self.owner, "cancelled", "", True, result
- )
- break
- if row["deadline_at"] and time.time() >= row["deadline_at"]:
- result = await self._interrupt(tid)
- await asyncio.to_thread(
- store.control_result,
- tid,
- self.owner,
- "failed",
- "Command execution deadline exceeded",
- False,
- result,
- )
- break
- async with self._locks.setdefault(tid, asyncio.Lock()):
- result = await provider.write_stdin(
- pid,
- sandbox_session_id=req.session_id,
- user_id=req.user_id,
- chars="",
- yield_time_ms=250,
- )
- row = await asyncio.to_thread(store.collect, tid, self.owner, result)
- if row["status"] not in store.LIVE:
- break
- if time.monotonic() - last_touch >= 10:
- await provider.touch_session(req.session_id)
- last_touch = time.monotonic()
- await asyncio.sleep(0.5)
+ break
+ async with self._locks.setdefault(tid, asyncio.Lock()):
+ result = await provider.write_stdin(
+ pid,
+ sandbox_session_id=req.session_id,
+ user_id=req.user_id,
+ chars="",
+ yield_time_ms=250,
+ )
+ row = await asyncio.to_thread(store.collect, tid, self.owner, result)
+ if row["status"] not in store.LIVE:
+ break
+ if time.monotonic() - last_touch >= 10:
+ await provider.touch_session(req.session_id)
+ last_touch = time.monotonic()
+ failures = 0
+ await asyncio.sleep(0.5)
+ except Exception:
+ failures += 1
+ logger.warning("Command observation failed task=%s attempt=%s",
+ tid, failures, exc_info=True)
+ if failures >= 3:
+ if row["execution_ref"]:
+ # Durable recovery can retry without rerunning the command.
+ await release_execution(provider, pid, req)
+ await asyncio.to_thread(store.release_lease, tid, self.owner)
+ break
+ # A nonrecoverable local process must be stopped, never
+ # reported terminal merely because observation failed.
+ result = await self._interrupt(tid)
+ await asyncio.to_thread(
+ store.control_result, tid, self.owner, "failed",
+ "Command observation failed", False, result,
+ )
+ break
+ await asyncio.sleep(2 ** (failures - 1))
except asyncio.CancelledError:
if row["execution_ref"]:
await release_execution(provider, pid, req)
+ self.providers.pop(tid, None)
+ self._locks.pop(tid, None)
await asyncio.to_thread(store.release_lease, tid, self.owner)
else:
result = await self._interrupt(tid)
+ self.providers.pop(tid, None)
+ self._locks.pop(tid, None)
await asyncio.to_thread(
store.control_result,
tid,
@@ -238,13 +272,11 @@ async def _monitor(self, tid, row):
result,
)
raise
- except Exception as exc:
- logger.exception("Command monitor lost task=%s", tid)
- await asyncio.to_thread(store.finish, tid, "unknown", str(exc), owner=self.owner)
- finally:
+ else:
self.providers.pop(tid, None)
- self.tasks.pop(tid, None)
self._locks.pop(tid, None)
+ finally:
+ self.tasks.pop(tid, None)
async def _interrupt(self, tid):
provider, req, pid = self.providers[tid]
@@ -259,8 +291,8 @@ async def _interrupt(self, tid):
while result.get("status") == "running":
try:
await asyncio.to_thread(store.collect, tid, self.owner, result)
- except LookupError:
- pass # A hard-deleted chat still owns a process that must stop.
+ except Exception:
+ logger.warning("Command stop bookkeeping deferred task=%s", tid)
next_result = await provider.write_stdin(
pid,
sandbox_session_id=req.session_id,
@@ -291,6 +323,11 @@ async def wait(self, tid, user_id, session_id, wait_ms=0):
@service_loop
async def recover(self, provider):
+ # Failed local stops remain owned and are retried by this worker's
+ # existing recovery supervisor before any no-reference classification.
+ for tid in list(self.providers):
+ if tid not in self.tasks:
+ self.tasks[tid] = asyncio.create_task(self._stop_retained(tid))
for row in await asyncio.to_thread(store.pending_recovery):
tid = row["task_id"]
if tid in self.tasks and not self.tasks[tid].done():
@@ -313,6 +350,7 @@ async def recover(self, provider):
session_id=row["sandbox_session_id"],
user_id=row["user_id"],
)
+ result = None
try:
result = await recover_execution(provider, row["execution_ref"], req)
ref = {**row["execution_ref"], "cursor": result["execution_cursor"]}
@@ -321,9 +359,43 @@ async def recover(self, provider):
)
if recovered["status"] == "running":
self.providers[tid] = provider, req, result["session_id"]
+ self._ensure_heartbeat()
self.tasks[tid] = asyncio.create_task(self._monitor(tid, recovered))
- except Exception as exc:
- await asyncio.to_thread(store.finish, tid, "unknown", str(exc), owner=self.owner)
+ except BaseException as exc:
+ if result and result.get("status") == "running":
+ await release_execution(provider, result["session_id"], req)
+ await asyncio.to_thread(store.release_lease, tid, self.owner)
+ if isinstance(exc, asyncio.CancelledError):
+ raise
+ logger.warning("Command recovery deferred task=%s", tid, exc_info=True)
+
+ async def _stop_retained(self, tid):
+ try:
+ try:
+ row = await asyncio.to_thread(store.snapshot, tid)
+ except LookupError:
+ row = None
+ if row and row["execution_ref"]:
+ provider, req, pid = self.providers[tid]
+ await release_execution(provider, pid, req)
+ self.providers.pop(tid, None)
+ self._locks.pop(tid, None)
+ await asyncio.to_thread(store.release_lease, tid, self.owner)
+ return
+ result = await self._interrupt(tid)
+ self.providers.pop(tid, None)
+ self._locks.pop(tid, None)
+ try:
+ await asyncio.to_thread(
+ store.control_result, tid, self.owner, "failed",
+ "Command observation failed", False, result,
+ )
+ except LookupError:
+ pass
+ except Exception:
+ logger.exception("Command stop requires another recovery pass task=%s", tid)
+ finally:
+ self.tasks.pop(tid, None)
@service_loop
async def shutdown(self):
@@ -334,28 +406,44 @@ async def shutdown(self):
results = await asyncio.gather(*tasks, return_exceptions=True)
# A task cancelled before its first instruction cannot run its finally.
for tid, (provider, req, pid) in list(self.providers.items()):
- row = await asyncio.to_thread(store.snapshot, tid)
- if row["execution_ref"]:
- await release_execution(provider, pid, req)
- await asyncio.to_thread(store.release_lease, tid, self.owner)
- else:
- result = await self._interrupt(tid)
- await asyncio.to_thread(
- store.control_result,
- tid,
- self.owner,
- "cancelled",
- "Command owner stopped",
- True,
- result,
- )
- self.providers.pop(tid, None)
- self._locks.pop(tid, None)
+ try:
+ await self._shutdown_handle(tid, provider, req, pid)
+ except Exception as exc:
+ results.append(exc)
for result in results:
if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError):
raise result
self.tasks.clear()
+ if self._heartbeat is not None:
+ self._heartbeat.cancel()
+ await asyncio.gather(self._heartbeat, return_exceptions=True)
+ self._heartbeat = None
self.loop = None
+ async def _shutdown_handle(self, tid, provider, req, pid):
+ try:
+ row = await asyncio.to_thread(store.snapshot, tid)
+ except LookupError:
+ await self._interrupt(tid)
+ self.providers.pop(tid, None)
+ self._locks.pop(tid, None)
+ return
+ if row["execution_ref"]:
+ await release_execution(provider, pid, req)
+ await asyncio.to_thread(store.release_lease, tid, self.owner)
+ else:
+ result = await self._interrupt(tid)
+ await asyncio.to_thread(
+ store.control_result,
+ tid,
+ self.owner,
+ "cancelled",
+ "Command owner stopped",
+ True,
+ result,
+ )
+ self.providers.pop(tid, None)
+ self._locks.pop(tid, None)
+
runtime = CommandRuntime()
diff --git a/src/backend/core/sandbox/commands/store.py b/src/backend/core/sandbox/commands/store.py
index 7f8b3e89..2ddf87a5 100644
--- a/src/backend/core/sandbox/commands/store.py
+++ b/src/backend/core/sandbox/commands/store.py
@@ -248,3 +248,14 @@ def renew_launch(task_id, owner):
if row.owner != owner or row.status != "starting":
raise RuntimeError("Command launch ownership lost")
row.lease_until = time.time() + LAUNCH_LEASE_SECONDS
+
+
+def renew_owned(task_ids, owner):
+ """Keep live local handles fenced even while output/stop RPCs are blocked."""
+ now = time.time()
+ with SessionLocal() as db:
+ db.execute(update(CommandTask).where(
+ CommandTask.task_id.in_(task_ids), CommandTask.owner == owner,
+ CommandTask.status.in_(LIVE), CommandTask.lease_until > now,
+ ).values(lease_until=now + LEASE_SECONDS))
+ db.commit()
diff --git a/src/backend/core/sandbox/commands/subscription.py b/src/backend/core/sandbox/commands/subscription.py
index 3311f844..e7fd3725 100644
--- a/src/backend/core/sandbox/commands/subscription.py
+++ b/src/backend/core/sandbox/commands/subscription.py
@@ -20,9 +20,7 @@ def require_edit(chat_id, user_id):
async def authorize(request, chat_id, expected_user=None):
- with store.SessionLocal() as db:
- credentials = await security(request)
- user = await get_current_user(request, credentials=credentials, db=db)
+ user = await get_current_user(request, await security(request))
if user.api_key_id or user.public_chat_visitor:
raise HTTPException(403, "Command events require an account session")
if expected_user is not None and user.user_id != expected_user:
diff --git a/src/backend/core/sandbox/factory.py b/src/backend/core/sandbox/factory.py
index f74054ca..7a57ccfb 100644
--- a/src/backend/core/sandbox/factory.py
+++ b/src/backend/core/sandbox/factory.py
@@ -8,6 +8,7 @@
from __future__ import annotations
import logging
+from dataclasses import dataclass
from core.config.settings import settings
@@ -16,8 +17,6 @@
logger = logging.getLogger(__name__)
-
-
def _ordinary_provider() -> SandboxProvider:
if process_state().provider is not None:
return process_state().provider
@@ -55,17 +54,13 @@ def reset_provider_cache() -> None:
process_state().provider = None
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class FactoryState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
provider: SandboxProvider | None = None
-@state_cache(maxsize=1)
-def process_state() -> FactoryState:
- return FactoryState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = FactoryState()
+
+
+def process_state() -> FactoryState:
+ return _STATE
diff --git a/src/backend/core/sandbox/oslayer/windows_runtime.py b/src/backend/core/sandbox/oslayer/windows_runtime.py
index 4154c73d..2a0f8397 100644
--- a/src/backend/core/sandbox/oslayer/windows_runtime.py
+++ b/src/backend/core/sandbox/oslayer/windows_runtime.py
@@ -27,16 +27,13 @@
import os
import threading
from collections import Counter
+from dataclasses import dataclass, field
# Environment variables Windows programs read to find their temp directory.
TEMP_ENV_KEYS = ("TEMP", "TMP")
ROOTS_FILENAME = "sandbox_labeled_roots.json"
-
-
-
-
def root_key(path: str) -> str:
return os.path.normcase(os.path.normpath(os.path.abspath(path)))
@@ -169,19 +166,15 @@ def release() -> None:
]
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class WindowsRuntimeState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- LOCK: object = state_field(default_factory=lambda: threading.Lock())
- IN_USE: Counter[str] = state_field(default_factory=lambda: Counter())
+ LOCK: object = field(default_factory=lambda: threading.Lock())
+ IN_USE: Counter[str] = field(default_factory=lambda: Counter())
TOKEN: object = None
-@state_cache(maxsize=1)
-def process_state() -> WindowsRuntimeState:
- return WindowsRuntimeState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = WindowsRuntimeState()
+
+
+def process_state() -> WindowsRuntimeState:
+ return _STATE
diff --git a/src/backend/core/sandbox/processes/interactive.py b/src/backend/core/sandbox/processes/interactive.py
index 6c1fbd34..ccbd5848 100644
--- a/src/backend/core/sandbox/processes/interactive.py
+++ b/src/backend/core/sandbox/processes/interactive.py
@@ -7,7 +7,6 @@
import logging
import secrets
import zipfile
-from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import httpx
diff --git a/src/backend/core/sandbox/providers/opensandbox/adoption.py b/src/backend/core/sandbox/providers/opensandbox/adoption.py
index a21591a6..c247e48d 100644
--- a/src/backend/core/sandbox/providers/opensandbox/adoption.py
+++ b/src/backend/core/sandbox/providers/opensandbox/adoption.py
@@ -6,10 +6,6 @@
import core.sandbox.sessions.registry as session_registry
from core.sandbox.providers.opensandbox.runtime import (
- _LEGACY_ENTRYPOINTS,
- _LIGHT_ENTRYPOINT,
- _SETUP_ENTRYPOINT,
- _user_bound_sandbox_required,
logger,
)
from core.sandbox.providers.opensandbox.volumes import (
@@ -24,28 +20,12 @@
class _OpenSandboxAdoptionMixin:
async def _adopt_existing_sandboxes(self) -> None:
- """Find sandboxes this app previously left on the OpenSandbox server and route
- them to adopt / kill.
-
- Classification rules (strictest to loosest):
- 1. ``state != "Running"`` → kill (container already dead, metadata leftover)
- 2. ``metadata[hugagent-owner] == "backend"`` → 先看 session_registry:
- 仍绑在会话上的原样留着(会话下一轮自己 attach 回来),无主的才 adopt 进对应的池
- 3. ``entrypoint`` matches ``_LIGHT_ENTRYPOINT`` / a legacy entrypoint,
- but the ``hugagent-owner`` tag is missing → legacy orphan, kill
- 4. anything else (someone else's sandbox, or an unrecognized entrypoint) → leave alone
-
- Adopted sandboxes go into ``_pool.idle``; surplus adopt candidates beyond
- ``max_idle`` are also killed to avoid unbounded pile-up. This method is called
- once before ``shutdown``; calling it again is idempotent (sandboxes scanned a
- second time are already in the pool, and an SDK ``connect`` failure
- auto-kills).
- """
+ """Adopt explicitly owned, unbound sandboxes; leave other clients alone."""
stats = {
"adopted_jupyter": 0,
"adopted_light": 0,
"skipped_bound": 0,
- "killed_legacy": 0,
+ "killed_invalid": 0,
"killed_dead": 0,
"killed_overflow": 0,
}
@@ -56,11 +36,11 @@ async def _adopt_existing_sandboxes(self) -> None:
logger.info(
"[opensandbox] sandbox adoption complete: "
"adopted jupyter=%d light=%d; left to sessions=%d; "
- "killed legacy=%d dead=%d overflow=%d",
+ "killed invalid=%d dead=%d overflow=%d",
stats["adopted_jupyter"],
stats["adopted_light"],
stats["skipped_bound"],
- stats["killed_legacy"],
+ stats["killed_invalid"],
stats["killed_dead"],
stats["killed_overflow"],
)
@@ -77,9 +57,8 @@ async def _classify_and_adopt(
try:
state = (getattr(info.status, "state", None) or "").lower()
metadata = info.metadata or {}
- entrypoint = list(info.entrypoint or [])
owner_tag = metadata.get(_METADATA_OWNER_KEY)
- if owner_tag and owner_tag != _METADATA_OWNER_VALUE:
+ if owner_tag != _METADATA_OWNER_VALUE:
# Another owner's sandbox (another deployment, or a module's own
# controller-managed computers) is never adopted or reaped here.
return
@@ -88,10 +67,6 @@ async def _classify_and_adopt(
return # The creator has not yet delivered this instance.
kind_tag = metadata.get(_METADATA_POOL_KIND_KEY)
- known_legacy = entrypoint in (_LIGHT_ENTRYPOINT, _SETUP_ENTRYPOINT) or entrypoint in _LEGACY_ENTRYPOINTS
- # Untagged instances with unknown entrypoints belong to other clients.
- if owner_tag != _METADATA_OWNER_VALUE and not known_legacy:
- return
# 1. Dead owned sandbox → guarded cleanup
if state != "running":
if await self._kill_by_id(mgr, info.id, reason=f"non-running state={state}"):
@@ -103,145 +78,114 @@ async def _classify_and_adopt(
return
# 2. Our own sandbox → adopt
- if owner_tag == _METADATA_OWNER_VALUE:
- if kind_tag not in ("jupyter", "light"):
- logger.warning(
- "[opensandbox] adopt: own sandbox %s missing pool-kind tag, killing",
- info.id,
- )
- if await self._kill_by_id(mgr, info.id, reason="missing pool-kind"):
- stats["killed_legacy"] += 1
- return
+ if kind_tag not in ("jupyter", "light"):
+ logger.warning(
+ "[opensandbox] adopt: own sandbox %s missing pool-kind tag, killing",
+ info.id,
+ )
+ if await self._kill_by_id(mgr, info.id, reason="missing pool-kind"):
+ stats["killed_invalid"] += 1
+ return
- # User-bound jupyter sandboxes are re-homed by user_id. When any
- # private-data mount is enabled, a sandbox without a user tag cannot
- # carry the current user's volumes; kill it rather than silently hand
- # out a credential-less or cross-user container.
- user_tag = metadata.get(_METADATA_USER_ID_KEY)
- user_bound_enabled = _user_bound_sandbox_required()
- if kind_tag == "jupyter" and user_bound_enabled and not user_tag:
- if await self._kill_by_id(
- mgr,
- info.id,
- reason="jupyter sandbox missing user-id tag (legacy / cross-user unsafe)",
- ):
- stats["killed_legacy"] += 1
- return
- # Created before the per-user skill view: its /workspace/skills is the
- # whole shared dir, which no longer holds this user's private skills.
- # Mounts can't be changed after creation, so drop it.
- if (
- kind_tag == "jupyter"
- and user_tag
- and metadata.get(_METADATA_SKILLS_LAYOUT_KEY) != _METADATA_SKILLS_LAYOUT_VALUE
+ # Persistent sandboxes require an explicit user owner so adoption
+ # cannot assign private mounts to another account.
+ user_tag = metadata.get(_METADATA_USER_ID_KEY)
+ if kind_tag == "jupyter" and not user_tag:
+ if await self._kill_by_id(
+ mgr,
+ info.id,
+ reason="jupyter sandbox missing user-id tag",
):
- if await self._kill_by_id(
- mgr, info.id, reason="jupyter sandbox predates the per-user skill view"
- ):
- stats["killed_legacy"] += 1
- return
- # Reverse case: the sandbox still carries a user tag after all
- # user-bound mounts were disabled. Its original volumes remain mounted;
- # generic-pool reuse could expose them to another user, so kill it.
- if kind_tag == "jupyter" and user_tag and not user_bound_enabled:
- if await self._kill_by_id(
- mgr,
- info.id,
- reason="jupyter sandbox carries user-id tag but user-bound mounts "
- "are disabled (stale private mounts would cross-user leak)",
- ):
- stats["killed_legacy"] += 1
- return
-
- # 仍绑在会话上的容器不是无主的,收进空闲池就会被按空闲回收掉——而会话
- # 那边还在用它。绑定活在 session_registry 里(跨进程、比空闲窗口活得
- # 久),重启并不会让它消失,所以这里原样留着:会话下一轮照登记 attach
- # 回来。真没人来接,服务端自己的 TTL 会收走。
- owner_session = await session_registry.bound_session(info.id)
- if owner_session:
- stats["skipped_bound"] += 1
- logger.info(
- "[opensandbox] adopt: sandbox %s still bound to session %s; "
- "leaving it to that session",
- info.id,
- owner_session,
- )
- return
-
- if not await self._track_sandbox(str(info.id)):
- return # Another live worker already owns this idle handle.
- try:
- sbx = await Sandbox.connect(
- info.id,
- connection_config=self._make_config(),
- skip_health_check=(kind_tag == "light"),
- )
- except Exception as exc: # noqa: BLE001
- logger.warning(
- "[opensandbox] adopt: connect %s failed (%s), retaining container",
- info.id, exc,
- )
- await session_registry.untrack(str(info.id), self._tracking_owner_token())
- self._tracking_ids.discard(str(info.id))
- return
+ stats["killed_invalid"] += 1
+ return
+ # Created before the per-user skill view: its /workspace/skills is the
+ # whole shared dir, which no longer holds this user's private skills.
+ # Mounts can't be changed after creation, so drop it.
+ if (
+ kind_tag == "jupyter"
+ and user_tag
+ and metadata.get(_METADATA_SKILLS_LAYOUT_KEY) != _METADATA_SKILLS_LAYOUT_VALUE
+ ):
+ if await self._kill_by_id(
+ mgr, info.id, reason="jupyter sandbox predates the per-user skill view"
+ ):
+ stats["killed_invalid"] += 1
+ return
+ # 仍绑在会话上的容器不是无主的,收进空闲池就会被按空闲回收掉——而会话
+ # 那边还在用它。绑定活在 session_registry 里(跨进程、比空闲窗口活得
+ # 久),重启并不会让它消失,所以这里原样留着:会话下一轮照登记 attach
+ # 回来。真没人来接,服务端自己的 TTL 会收走。
+ owner_session = await session_registry.bound_session(info.id)
+ if owner_session:
+ stats["skipped_bound"] += 1
+ logger.info(
+ "[opensandbox] adopt: sandbox %s still bound to session %s; "
+ "leaving it to that session",
+ info.id,
+ owner_session,
+ )
+ return
- # Direct-connect to execd (bypasses the server proxy's ~3s/request overhead); falls back automatically when unreachable.
- await self._repoint_execd_direct(sbx)
+ if not await self._track_sandbox(str(info.id)):
+ return # Another live worker already owns this idle handle.
+ try:
+ sbx = await Sandbox.connect(
+ info.id,
+ connection_config=self._make_config(),
+ skip_health_check=(kind_tag == "light"),
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "[opensandbox] adopt: connect %s failed (%s), retaining container",
+ info.id, exc,
+ )
+ await session_registry.untrack(str(info.id), self._tracking_owner_token())
+ self._tracking_ids.discard(str(info.id))
+ return
- # jupyter + user tag → user-bound pool
- if kind_tag == "jupyter" and user_tag and user_bound_enabled:
- ok = await self._jupyter_user_pool.adopt(user_tag, sbx)
- if not ok:
- await self._destroy_sandbox(sbx)
- stats["killed_overflow"] += 1
- return
- stats["adopted_jupyter"] += 1
- logger.info(
- "[opensandbox] adopt: claimed jupyter sandbox %s for user=%s",
- info.id,
- user_tag,
- )
- return
+ # Direct-connect to execd (bypasses the server proxy's ~3s/request overhead); falls back automatically when unreachable.
+ await self._repoint_execd_direct(sbx)
- # light (or fallback jupyter with all user-bound mounts disabled) → generic pool
- # pool full → kill rather than leak
- bucket = self._pool._buckets[kind_tag] # noqa: SLF001
- if len(bucket.idle) >= bucket.max_idle:
- if await self._kill_by_id(
- mgr,
- info.id,
- reason=f"{kind_tag} bucket already at max_idle={bucket.max_idle}",
- ):
- stats["killed_overflow"] += 1
- return
- ok = await self._pool.adopt_idle(kind_tag, sbx)
+ # jupyter + user tag → user-bound pool
+ if kind_tag == "jupyter" and user_tag:
+ ok = await self._jupyter_user_pool.adopt(user_tag, sbx)
if not ok:
- # adopt_idle refused (race: another entry enqueued concurrently filled the capacity) — destroy
await self._destroy_sandbox(sbx)
stats["killed_overflow"] += 1
return
- stats[f"adopted_{kind_tag}"] += 1
+ stats["adopted_jupyter"] += 1
logger.info(
- "[opensandbox] adopt: claimed %s sandbox %s",
- kind_tag,
+ "[opensandbox] adopt: claimed jupyter sandbox %s for user=%s",
info.id,
+ user_tag,
)
return
- # 3. No tag, but the entrypoint looks like one we previously created (orphan left by an older version) → kill
- if (
- entrypoint in (_LIGHT_ENTRYPOINT, _SETUP_ENTRYPOINT)
- or entrypoint in _LEGACY_ENTRYPOINTS
- ):
+ # Light sandboxes use the shared pool.
+ # pool full → kill rather than leak
+ bucket = self._pool._buckets[kind_tag] # noqa: SLF001
+ if len(bucket.idle) >= bucket.max_idle:
if await self._kill_by_id(
mgr,
info.id,
- reason=f"legacy untagged sandbox with our entrypoint {entrypoint}",
+ reason=f"{kind_tag} bucket already at max_idle={bucket.max_idle}",
):
- stats["killed_legacy"] += 1
+ stats["killed_overflow"] += 1
return
+ ok = await self._pool.adopt_idle(kind_tag, sbx)
+ if not ok:
+ # adopt_idle refused (race: another entry enqueued concurrently filled the capacity) — destroy
+ await self._destroy_sandbox(sbx)
+ stats["killed_overflow"] += 1
+ return
+ stats[f"adopted_{kind_tag}"] += 1
+ logger.info(
+ "[opensandbox] adopt: claimed %s sandbox %s",
+ kind_tag,
+ info.id,
+ )
+ return
- # 4. Someone else's sandbox / unrecognized entrypoint → leave alone
except Exception as exc: # noqa: BLE001
logger.warning(
"[opensandbox] adopt: classifying %s failed: %s",
diff --git a/src/backend/core/sandbox/providers/opensandbox/creation.py b/src/backend/core/sandbox/providers/opensandbox/creation.py
index d1e68a8e..35abf70d 100644
--- a/src/backend/core/sandbox/providers/opensandbox/creation.py
+++ b/src/backend/core/sandbox/providers/opensandbox/creation.py
@@ -19,7 +19,6 @@
from core.sandbox.providers.opensandbox.runtime import (
_LIGHT_ENTRYPOINT,
_SETUP_ENTRYPOINT,
- _user_bound_sandbox_required,
logger,
)
from core.sandbox.providers.opensandbox.volumes import _make_skills_volumes, _pool_metadata
@@ -33,11 +32,11 @@ def _session_entrypoint(self, user_id):
return _SETUP_ENTRYPOINT if user_id else _LIGHT_ENTRYPOINT
async def _additional_workspace_mounts(self, user_id):
- from core.files.spaces.mounts import prepare_volumes
+ from core.files.spaces.mounts import provision_volumes
if not user_id:
return [], {}
- return await asyncio.to_thread(prepare_volumes, user_id)
+ return await asyncio.to_thread(provision_volumes, user_id)
async def _create_jupyter_sandbox(
self,
@@ -82,9 +81,7 @@ async def _create_jupyter_sandbox(
except Exception as e:
raise SandboxConnectError(f"无法创建 OpenSandbox 持久会话沙盒: {e}") from e
- # 目录约定已由 entrypoint 在启动时建好,直接打标,让 _persistent_setup 整段跳过
- # —— 这才是首条命令省下那一秒的地方。老沙箱(旧 entrypoint 建的、adopt 回来的)
- # 没有这个标记,仍按原路在首次执行前补建,不受影响。
+ # The entrypoint prepared the workspace; avoid repeating setup on first use.
if setup_uid:
try:
sbx._jx_setup_done = True # type: ignore[attr-defined]
@@ -336,18 +333,8 @@ async def _probe_pooled_sandbox_alive(self, sbx: Any) -> bool:
return True
async def _create_session(self, user_id: Optional[str] = None) -> _Session:
- """Construct a new persistent session (user-bound sandbox, running directly via execd, no Jupyter kernel).
-
- Plan F: when ``user_id`` is given and any private-data bind mount is on,
- **bypass the general warm pool** — the pool's sandboxes have no user-bound
- MySpace/account-credential volumes. Instead, take the user-bound path via
- ``_create_jupyter_sandbox(user_id=...)``. Adopted old sandboxes have already
- been slotted per user into ``_jupyter_user_pool`` (see B.7).
-
- When ``user_id`` is absent (legacy / fallback), take the old path: grab one
- from the general pool without account volumes.
- """
- use_user_pool = bool(user_id and (_user_bound_sandbox_required() or str(user_id).startswith("api_ak_")))
+ """Route account sessions to their user pool; anonymous sessions use the general pool."""
+ use_user_pool = bool(user_id)
if use_user_pool:
sandbox = await self._jupyter_user_pool.acquire(user_id)
@@ -403,6 +390,5 @@ async def _create_owned_sandbox(self, *args, **kwargs):
sandbox = await Sandbox.create(*args, **kwargs)
await self._repoint_execd_direct(sandbox)
if not await self._track_sandbox(str(sandbox.id)):
- # Another owner may already be using it. Never kill or deliver it.
raise SandboxConnectError("sandbox ownership was acquired elsewhere")
return sandbox
diff --git a/src/backend/core/sandbox/providers/opensandbox/models.py b/src/backend/core/sandbox/providers/opensandbox/models.py
index 17f1d360..4f256929 100644
--- a/src/backend/core/sandbox/providers/opensandbox/models.py
+++ b/src/backend/core/sandbox/providers/opensandbox/models.py
@@ -9,12 +9,9 @@
@dataclass
class SandboxSessionRecord:
sandbox: Any # opensandbox.Sandbox
- # The user_id owning this sandbox (binds MySpace/account-credential volumes +
- # user re-homing on adopt across restarts). None when legacy paths call
- # _get_or_create_session without a user_id — execution then falls back to a
- # credential-less general sandbox.
+ # None identifies an anonymous session without account volumes.
user_id: Optional[str] = None
- # Plan F: which pool this sandbox was actually taken from. **release must follow this,
+ # Which pool this sandbox was actually taken from. **release must follow this,
# never re-decide from the current user-bound-volume settings** — a mid-flight
# flag change (ops toggle, hot reload) would route release to a different pool,
# corrupting counts / cross-user contamination. The snapshot restore path builds
diff --git a/src/backend/core/sandbox/providers/opensandbox/provider.py b/src/backend/core/sandbox/providers/opensandbox/provider.py
index 388cf225..813832c7 100644
--- a/src/backend/core/sandbox/providers/opensandbox/provider.py
+++ b/src/backend/core/sandbox/providers/opensandbox/provider.py
@@ -21,7 +21,6 @@
from core.sandbox.providers.opensandbox.models import SandboxSessionRecord as _Session
from core.sandbox.providers.opensandbox.pool import SandboxPool
from core.sandbox.providers.opensandbox.runtime import (
- _user_bound_sandbox_required,
logger,
write_file,
)
@@ -51,9 +50,7 @@ class OpenSandboxProvider(
_service_loop: Optional[asyncio.AbstractEventLoop] = None
def __init__(self) -> None:
- # Only the opensandbox SDK is needed (execd commands.run); no longer depends on
- # code_interpreter (the Jupyter kernel was removed, all languages go through
- # execd, see cloud_processes).
+ # All languages execute through execd.
try:
from opensandbox import Sandbox
except ImportError as e:
@@ -63,7 +60,10 @@ def __init__(self) -> None:
) from e
s = settings.sandbox
- self._domain = s.opensandbox_domain
+ # SDK and direct health/renew requests must use the same absolute endpoint.
+ self._domain = s.opensandbox_domain.rstrip("/")
+ if not self._domain.startswith(("http://", "https://")):
+ self._domain = "http://" + self._domain
self._api_key = s.opensandbox_api_key
self._image = s.opensandbox_image
self._ttl_s = s.idle_ttl_s
@@ -118,9 +118,7 @@ def __init__(self) -> None:
# serves the fallback path where user_id is missing; normal user-bound traffic
# goes through _jupyter_user_pool.
self._pool = SandboxPool(
- jupyter_min_idle=(
- s.opensandbox_pool_jupyter_min_idle if not _user_bound_sandbox_required() else 0
- ),
+ jupyter_min_idle=0,
jupyter_max_idle=s.opensandbox_pool_jupyter_max_idle,
light_min_idle=s.opensandbox_pool_light_min_idle,
light_max_idle=s.opensandbox_pool_light_max_idle,
@@ -131,9 +129,7 @@ def __init__(self) -> None:
liveness_fn=self._probe_pooled_sandbox_alive,
owner_fn=self._pooled_sandbox_owner,
)
- # Plan F: jupyter sandboxes are bucketed by user_id. When bind mount is enabled,
- # all jupyter acquires go through here; adopted old sandboxes are placed into the
- # matching bucket by their hugagent-user-id metadata.
+ # Account sessions and adopted instances share the same per-user pool.
self._jupyter_user_pool = _JupyterUserPool(
factory=lambda uid: self._create_jupyter_sandbox(user_id=uid),
destroy_fn=self._destroy_sandbox,
@@ -338,23 +334,15 @@ async def health(self) -> bool:
# instances (still alive but not tracked in memory, backend_managed=False).
# Pool figures come from the stats of the two pools.
+ def bind_loop(self) -> None:
+ loop = asyncio.get_running_loop()
+ if self._service_loop is not None and self._service_loop is not loop:
+ raise RuntimeError("Sandbox provider already belongs to another event loop")
+ self._service_loop = loop
+
async def warmup(self) -> None:
- """Startup-time pool warmup entry point (called by the FastAPI startup hook,
- repeatable, idempotent).
-
- Steps:
- 1. ``_adopt_existing_sandboxes``: scan all sandboxes on the OpenSandbox server;
- live sandboxes carrying this app's metadata tag are reclaimed into
- ``_pool.idle``; those with "our previous entrypoint but missing the tag" or
- in an unusable state are killed outright to clean up orphans.
- 2. ``_pool.warmup``: if adoption did not fill min_idle, top up the remaining
- deficit via ``factory()`` (same behavior as before).
- Effect: after a backend restart, idle fills almost instantly with no need to
- recreate 4 sandboxes (saves ~60s).
- """
- # 锚定服务循环:warmup 由 FastAPI 启动钩子在主循环上调用,之后所有跨循环的
- # provider 调用都转发到这里(见 ``_on_service_loop``)。
- self._service_loop = asyncio.get_running_loop()
+ """Adopt owned instances and start pool/snapshot maintenance."""
+ self.bind_loop()
try:
await self._adopt_existing_sandboxes()
except Exception as exc: # noqa: BLE001 — an adoption failure must not block warmup
@@ -371,11 +359,7 @@ async def warmup(self) -> None:
if self._snapshot_gc_task is None or self._snapshot_gc_task.done():
self._snapshot_gc_task = asyncio.create_task(self._snapshot_gc_loop())
- async def shutdown(self) -> None:
- """Called on process exit: gracefully shut down all persistent sessions + idle sandboxes in the pools + snapshot workers."""
- processes = getattr(self, "_command_processes", None)
- if processes:
- await processes.close_all()
+ async def stop_maintenance(self) -> None:
# Stop the snapshot workers first: no more new park / GC triggers
self._snapshot_workers_shutdown.set()
for task in (self._snapshot_idle_task, self._snapshot_gc_task):
@@ -389,6 +373,13 @@ async def shutdown(self) -> None:
self._snapshot_idle_task = None
self._snapshot_gc_task = None
+ async def shutdown(self) -> None:
+ """Called on process exit: gracefully shut down all persistent sessions + idle sandboxes in the pools + snapshot workers."""
+ processes = getattr(self, "_command_processes", None)
+ if processes:
+ await processes.close_all()
+ await self.stop_maintenance()
+
for sid, sess in list(self._sessions.items()):
try:
await self._release_session(sid, sess, detach_only=True)
diff --git a/src/backend/core/sandbox/providers/opensandbox/runtime.py b/src/backend/core/sandbox/providers/opensandbox/runtime.py
index 63e27793..a63fb6b7 100644
--- a/src/backend/core/sandbox/providers/opensandbox/runtime.py
+++ b/src/backend/core/sandbox/providers/opensandbox/runtime.py
@@ -45,20 +45,7 @@ async def write_file(sbx: Any, path: str, content: Any) -> None:
logger.warning("[opensandbox] chown %s 失败(继续): %r", path, exc)
-def _user_bound_sandbox_required() -> bool:
- """Account sessions always require account-specific volumes."""
- return True
-
-
-# Entrypoints earlier versions created sandboxes with. Nothing is created with them
-# any more (every sandbox uses _LIGHT_ENTRYPOINT), but adopt still recognizes them so
-# orphans left by an older backend get killed instead of leaking.
-_LEGACY_ENTRYPOINTS = (
- ["/opt/opensandbox/code-interpreter.sh"],
- ["/opt/hugagent/entrypoint-as-ubuntu.sh"],
-)
-
-# Plan F Q1: environment variables used when running bash via sandbox.commands.run (execd).
+# Environment variables used when running bash via sandbox.commands.run (execd).
# execd does not source /opt/opensandbox/code-interpreter-env.sh; PATH then only contains the
# system bin paths, so python resolves to a "system python" like /usr/bin/python3 that has no
# pandas/docx etc. installed. We explicitly provide a PATH matching where packages are
@@ -123,7 +110,7 @@ def _join_logs(parts: list) -> str:
"自带换行的不再补" 这一条正是为它留的:两者合起来逐字节还原
(``A``、``\n``、``\n``、``B`` → ``A\n\n\nB\n``),补重了空行会翻倍。
execd 早于 v1.0.17 时根本不为空行发事件,那种情况下空行在到达后端之前就没了,
- 这里补不回来——版本钉在 ``docker/opensandbox-config.toml.tpl``。
+ 这里补不回来——版本钉在 ``docker/opensandbox-server/opensandbox-config.toml.tpl``。
"""
if not parts:
return ""
diff --git a/src/backend/core/sandbox/providers/opensandbox/snapshots.py b/src/backend/core/sandbox/providers/opensandbox/snapshots.py
index 1d0387eb..898de7af 100644
--- a/src/backend/core/sandbox/providers/opensandbox/snapshots.py
+++ b/src/backend/core/sandbox/providers/opensandbox/snapshots.py
@@ -94,7 +94,7 @@ def _upsert() -> Optional[str]:
return await asyncio.to_thread(_upsert)
- async def _delete_snapshot_row(self, chat_id: str) -> None:
+ async def _delete_snapshot_row(self, chat_id: str, snapshot_id: str) -> None:
"""Delete the chat_sandbox_snapshots row (leaves the remote snapshot untouched)."""
def _delete() -> None:
@@ -102,7 +102,8 @@ def _delete() -> None:
ChatSandboxSnapshot = _get_snapshot_model()
with SessionLocal() as db:
db.query(ChatSandboxSnapshot).filter(
- ChatSandboxSnapshot.chat_id == chat_id
+ ChatSandboxSnapshot.chat_id == chat_id,
+ ChatSandboxSnapshot.snapshot_id == snapshot_id,
).delete()
db.commit()
@@ -213,6 +214,7 @@ async def _snapshot_idle_sessions_once(self) -> None:
The idle threshold starts snapshotting; server expiry includes a grace
period so that the scan and snapshot can finish before container removal.
"""
+ await self._discover_idle_sessions()
threshold = settings.sandbox.idle_ttl_s
now = time.monotonic()
candidates: list[tuple[str, _Session]] = []
@@ -233,9 +235,13 @@ async def _snapshot_idle_sessions_once(self) -> None:
candidates.append((chat_id, sess))
if len(candidates) >= self._PARK_BATCH_LIMIT:
break
- for chat_id, sess in candidates:
+ for chat_id, _ in candidates:
self._parking_in_flight.add(chat_id)
- asyncio.create_task(self._park_session_via_snapshot(chat_id, sess))
+ # Children belong to the maintenance task: losing leadership cancels
+ # and joins every in-flight snapshot before releasing ownership.
+ async with asyncio.TaskGroup() as group:
+ for chat_id, sess in candidates:
+ group.create_task(self._park_session_via_snapshot(chat_id, sess))
async def _park_session_via_snapshot(self, chat_id: str, sess: _Session) -> None:
"""take snapshot → wait Ready → upsert DB → release the session.
@@ -309,7 +315,7 @@ async def _park_session_via_snapshot(self, chat_id: str, sess: _Session) -> None
)
# Clean up the old remote snapshot outside the lock (async)
if old_snap:
- asyncio.create_task(self._delete_snapshot(old_snap))
+ await self._delete_snapshot(old_snap)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
@@ -383,7 +389,7 @@ async def _delete_one(snapshot_id: str) -> bool:
if not ok:
continue
try:
- await self._delete_snapshot_row(chat_id)
+ await self._delete_snapshot_row(chat_id, snapshot_id)
except Exception as e: # noqa: BLE001
logger.warning(
"[opensandbox] gc: delete db row chat=%s failed: %s",
diff --git a/src/backend/core/sandbox/providers/opensandbox/tracking.py b/src/backend/core/sandbox/providers/opensandbox/tracking.py
index 04e2005e..6fa04478 100644
--- a/src/backend/core/sandbox/providers/opensandbox/tracking.py
+++ b/src/backend/core/sandbox/providers/opensandbox/tracking.py
@@ -85,3 +85,34 @@ async def _confirm_sandbox_exists(self, sandbox_id):
raise SandboxNotFoundError(f"sandbox {sandbox_id} no longer exists")
if response.status_code >= 400:
raise SandboxError(f"sandbox metadata query failed: HTTP {response.status_code}")
+
+
+ async def _discover_idle_sessions(self):
+ """Maintenance owns shared bindings, regardless of which worker served them."""
+ import time
+ from core.sandbox.providers.opensandbox.volumes import _METADATA_USER_ID_KEY
+ for chat, sandbox_id, active_at in await session_registry.bindings():
+ if chat in self._sessions or time.time() - active_at < settings.sandbox.idle_ttl_s:
+ continue
+ lock = await self._get_session_lock(chat)
+ if lock.locked():
+ continue
+ try:
+ async with lock, session_registry.claim(chat):
+ if await session_registry.lookup(chat) != sandbox_id:
+ continue
+ response = await self._get_http_client().get(
+ f"{self._domain.rstrip('/')}/v1/sandboxes/{sandbox_id}"
+ )
+ if response.status_code == 404:
+ if response.json().get("code") == "DOCKER::SANDBOX_NOT_FOUND":
+ await session_registry.forget(chat, sandbox_id)
+ continue
+ response.raise_for_status()
+ user_id = (response.json().get("metadata") or {}).get(_METADATA_USER_ID_KEY)
+ session = await self._attach_session(sandbox_id, user_id)
+ if session is not None:
+ session.last_active_ts = time.monotonic() - max(0, time.time() - active_at)
+ self._sessions[chat] = session
+ except Exception as exc:
+ logger.warning("sandbox discovery deferred chat=%s: %s", chat, exc)
diff --git a/src/backend/core/sandbox/providers/opensandbox/volumes.py b/src/backend/core/sandbox/providers/opensandbox/volumes.py
index 17a8e420..dab22752 100644
--- a/src/backend/core/sandbox/providers/opensandbox/volumes.py
+++ b/src/backend/core/sandbox/providers/opensandbox/volumes.py
@@ -2,55 +2,11 @@
from __future__ import annotations
-import logging
-import os
-import re
-from pathlib import Path
-from typing import Any, Optional
+from typing import Optional
from core.config.settings import settings
from core.sandbox.sessions.metadata import USER_ID_METADATA_KEY
from core.sandbox.workspace.identity import safe_user_id
-from extensions.paths import SKILLS_DIR
-
-logger = logging.getLogger(__name__)
-
-# Built-in skills enter the sandbox at /workspace/skills/ via a host bind mount, read-only.
-#
-# Note: an absolute host path is required — the OpenSandbox server creates sandbox
-# containers through the host docker daemon, and docker.sock sees the host's view.
-# HOST_REPO_PATH is injected by docker-compose (the .env variable of the same name);
-# when missing we fall back to the in-container path derived from Path(__file__) —
-# in dev the backend already bind-mounts `${HOST_REPO_PATH}:${HOST_REPO_PATH}`, so
-# paths inside and outside the container are identical and the derived result is
-# also a valid host path.
-_BUILTIN_SKILLS_HOST_PATH: Optional[str] = None
-
-
-def _resolve_builtin_skills_host_path() -> Optional[str]:
- """Compute the absolute host path of the built-in skills dir, for sandbox volumes."""
- global _BUILTIN_SKILLS_HOST_PATH
- if _BUILTIN_SKILLS_HOST_PATH is not None:
- return _BUILTIN_SKILLS_HOST_PATH or None # "" → None
- host_repo = os.environ.get("HOST_REPO_PATH", "").strip()
- if host_repo:
- candidate = f"{host_repo.rstrip('/')}/src/backend/extensions/skills"
- else:
- # In-container derived path: this file lives in src/backend/core/sandbox/providers/opensandbox/,
- # walk back up to src/backend then down into extensions/skills. In dev the backend
- # bind-mounts the whole repo via HOST_REPO_PATH, so the absolute path inside
- # the container equals the host path.
- candidate = str(SKILLS_DIR)
- if not Path(candidate).is_dir():
- logger.warning(
- "[opensandbox] built-in skills dir not found at %s; skill bind-mount disabled",
- candidate,
- )
- _BUILTIN_SKILLS_HOST_PATH = ""
- return None
- _BUILTIN_SKILLS_HOST_PATH = candidate
- return candidate
-
# Metadata tags used for adoption / cross-process pool reuse. Every sandbox created by
# this app carries them; after a backend restart _adopt_existing_sandboxes uses this tag
@@ -68,7 +24,7 @@ def _resolve_builtin_skills_host_path() -> Optional[str]:
_METADATA_OWNER_KEY = "hugagent-owner"
_METADATA_OWNER_VALUE = "backend"
_METADATA_POOL_KIND_KEY = "hugagent-pool-kind"
-# Plan F: jupyter sandboxes are bucketed by user_id. Every jupyter sandbox created by this
+# Persistent sandboxes are bucketed by user_id. Every jupyter sandbox created by this
# service carries this tag; after a backend restart _adopt_existing_sandboxes uses it to
# reclaim orphan sandboxes into the correct user's pool — cross-user reuse would let one
# user see another's myspace bind contents, a security hole that must be prevented.
@@ -110,33 +66,14 @@ def _make_skills_volumes(user_id: Optional[str] = None) -> list:
whoever asks next and must never carry someone's private files.
Read-only, and the bind is live, so newly imported skills appear at once.
- Without ``HOST_STORAGE_PATH`` fall back to the built-in source tree alone and
- warn; on failure return an empty list — the sandbox is created as usual, just
- without /workspace/skills/, and skill references hit a plain not-found.
+ HOST_STORAGE_PATH must identify the same storage root on the Docker host.
"""
- try:
- from opensandbox.models.sandboxes import Host, Volume
- except ImportError: # older SDKs lack these two — can only continue without mounting
- return []
+ from opensandbox.models.sandboxes import Host, Volume
+ from core.sandbox.errors import SandboxError
- host_storage = (settings.sandbox.opensandbox_host_storage_path or "").strip().rstrip("/")
+ host_storage = settings.sandbox.opensandbox_host_storage_path.strip().rstrip("/")
if not host_storage:
- # No host storage → no unified dir to bind; mount the built-in source tree alone.
- legacy = _resolve_builtin_skills_host_path()
- logger.warning(
- "[opensandbox] HOST_STORAGE_PATH 未配置;/workspace/skills 仅挂内置源码树,"
- "DB 导入技能在沙盒内不可见"
- )
- if not legacy:
- return []
- return [
- Volume(
- name="skills",
- host=Host(path=legacy),
- mount_path="/workspace/skills",
- read_only=True,
- )
- ]
+ raise SandboxError("OpenSandbox skill mounts require HOST_STORAGE_PATH")
# get_sandbox_skills_dir() / get_user_skills_root() return the container-side
# dirs (the same storage volume as the host bind source) and guarantee they
diff --git a/src/backend/core/sandbox/providers/script_runner/provider.py b/src/backend/core/sandbox/providers/script_runner/provider.py
index 7448fc1c..0b317a17 100644
--- a/src/backend/core/sandbox/providers/script_runner/provider.py
+++ b/src/backend/core/sandbox/providers/script_runner/provider.py
@@ -7,6 +7,7 @@
import logging
import re
import time
+from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@@ -372,7 +373,6 @@ def admin_pool_stats(self) -> dict:
raise SandboxAdminNotSupported("script_runner 无连接池")
-
_SKILL_VIEW_TTL_S = 60.0
@@ -412,17 +412,13 @@ def _refresh_skill_view(user_id: Optional[str]) -> None:
logger.warning("[script_runner] 技能视图刷新失败 user=%s: %s", uid, exc)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class ProviderState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- SKILL_VIEW_SYNCED: dict[str, tuple[float, int]] = state_field(default_factory=lambda: {})
+ SKILL_VIEW_SYNCED: dict[str, tuple[float, int]] = field(default_factory=lambda: {})
-@state_cache(maxsize=1)
-def process_state() -> ProviderState:
- return ProviderState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = ProviderState()
+
+
+def process_state() -> ProviderState:
+ return _STATE
diff --git a/src/backend/core/sandbox/runner/native_group.py b/src/backend/core/sandbox/runner/native_group.py
index 335e0257..5f055d04 100644
--- a/src/backend/core/sandbox/runner/native_group.py
+++ b/src/backend/core/sandbox/runner/native_group.py
@@ -8,6 +8,7 @@
import subprocess
import sys
import threading
+from dataclasses import dataclass, field
from pathlib import Path
@@ -171,10 +172,6 @@ def close(self):
self._namespace_fd = None
-
-
-
-
def lifetime_command(cmd, trusted_prefix=()):
"""Preserve trusted existing confinement; otherwise add only PID lifetime isolation."""
if (
@@ -221,18 +218,14 @@ def lifetime_command(cmd, trusted_prefix=()):
return [*prefix, *cmd], bool(prefix)
-from dataclasses import dataclass as state_dataclass, field as state_field
-from functools import lru_cache as state_cache
-
-@state_dataclass
+@dataclass
class NativeGroupState:
- """Process-owned storage; domain operations retain locks and cleanup responsibility."""
- namespace_lock: object = state_field(default_factory=lambda: threading.Lock())
+ namespace_lock: object = field(default_factory=lambda: threading.Lock())
namespace_prefix: object = None
-@state_cache(maxsize=1)
-def process_state() -> NativeGroupState:
- return NativeGroupState()
-# Initialize under the import lock before concurrent callers share the owner.
-process_state()
+_STATE = NativeGroupState()
+
+
+def process_state() -> NativeGroupState:
+ return _STATE
diff --git a/src/backend/core/sandbox/runner/server.py b/src/backend/core/sandbox/runner/server.py
index 9d591c4f..398b638f 100644
--- a/src/backend/core/sandbox/runner/server.py
+++ b/src/backend/core/sandbox/runner/server.py
@@ -14,7 +14,6 @@
import os
import shutil
import signal
-import subprocess
import sys
import tempfile
from pathlib import Path
diff --git a/src/backend/core/sandbox/sessions/registry.py b/src/backend/core/sandbox/sessions/registry.py
index 0bdb12bf..53eb0e17 100644
--- a/src/backend/core/sandbox/sessions/registry.py
+++ b/src/backend/core/sandbox/sessions/registry.py
@@ -127,7 +127,9 @@ async def renew_lease() -> None:
while True:
await asyncio.sleep(min(_LOCK_RENEW_INTERVAL_S, ttl / 3))
try:
- retained = await state.hold(key, token, ttl=ttl)
+ retained = await asyncio.wait_for(
+ state.compare_exchange(key, token, token, ttl=ttl), timeout=ttl / 3
+ )
except Exception:
retained = False
if not retained:
@@ -312,7 +314,9 @@ async def renew_destruction():
while True:
await asyncio.sleep(min(_LOCK_RENEW_INTERVAL_S, ttl / 3))
try:
- retained = await state.hold(key, token, ttl=ttl)
+ retained = await asyncio.wait_for(
+ state.compare_exchange(key, token, token, ttl=ttl), timeout=ttl / 3
+ )
except Exception:
retained = False
if not retained:
@@ -378,3 +382,16 @@ async def creation_pending(token: str) -> bool:
return bool(await get_ephemeral_state().get(
_LOCK_KEY.format(session_id=f"creating:{token}")
))
+
+
+async def bindings():
+ """Snapshot shared bindings without extending activity or their lifetime."""
+ prefix = _KEY.split("{")[0]
+ state = get_ephemeral_state()
+ entries = []
+ for key in await state.keys(prefix):
+ session_id = key[len(prefix):]
+ entry = await _read(session_id)
+ if entry is not None:
+ entries.append((session_id, entry[0], entry[1]))
+ return entries
diff --git a/src/backend/core/sandbox/workspace/identity.py b/src/backend/core/sandbox/workspace/identity.py
index 9e83289c..975a604c 100644
--- a/src/backend/core/sandbox/workspace/identity.py
+++ b/src/backend/core/sandbox/workspace/identity.py
@@ -6,7 +6,7 @@
# 沙盒内运行用户代码的身份。与 backend 容器的 appuser 同为 UID 1000,这样 bind mount
# 进来的 myspace 目录两边同属主,沙盒写的文件 backend 能覆盖和删除。
-# 镜像里 /workspace、/home/ubuntu 也归这个 UID(见 docker/Dockerfile.opensandbox)。
+# 镜像里 /workspace、/home/ubuntu 也归这个 UID(见 docker/sandbox/Dockerfile.opensandbox)。
SANDBOX_RUN_UID = 1000
SANDBOX_RUN_GID = 1000
diff --git a/src/backend/extensions/mcp_servers/_serve.py b/src/backend/extensions/mcp_servers/_serve.py
index 039d4769..cbbf74d5 100644
--- a/src/backend/extensions/mcp_servers/_serve.py
+++ b/src/backend/extensions/mcp_servers/_serve.py
@@ -1,4 +1,4 @@
-"""Shared launcher for FastMCP servers.
+"""Shared launcher for MCPServer servers.
Every MCP server's ``main()`` calls ``run(mcp, default_port=NNNN)``.
Picks transport from ``--transport`` (``stdio`` for local debug, the
@@ -13,14 +13,14 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
- from mcp.server.fastmcp import FastMCP
+ from mcp.server.mcpserver import MCPServer
def _streamable_http_bind_host() -> str:
return os.getenv("MCP_BIND_HOST", "0.0.0.0").strip() or "0.0.0.0"
-def run(mcp: "FastMCP", default_port: int) -> None:
+def run(mcp: "MCPServer", default_port: int) -> None:
parser = argparse.ArgumentParser(description=mcp.name)
parser.add_argument(
"--transport",
@@ -31,22 +31,16 @@ def run(mcp: "FastMCP", default_port: int) -> None:
args = parser.parse_args()
if args.transport == "streamable-http":
- mcp.settings.port = args.port
- # Compose needs all-interface binding inside its private container
- # network. The no-Docker local/desktop launcher explicitly supplies
- # MCP_BIND_HOST=127.0.0.1 so these internal control-plane ports are not
- # exposed to the user's LAN (and do not trigger an avoidable Windows
- # firewall prompt).
- mcp.settings.host = _streamable_http_bind_host()
- # MCP's default DNS-rebinding allow-list is localhost only. Backend
- # reaches us via the docker DNS name (e.g. ``mcp:9108``); rebinding
- # protection isn't relevant on a private network with no browser
- # traffic.
from mcp.server.transport_security import TransportSecuritySettings
- mcp.settings.transport_security = TransportSecuritySettings(
- enable_dns_rebinding_protection=False,
+ host = _streamable_http_bind_host()
+ mcp.run(
+ "streamable-http", host=host, port=args.port,
+ transport_security=TransportSecuritySettings(
+ enable_dns_rebinding_protection=True,
+ allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*", "mcp:*", "hugagent-mcp:*"],
+ allowed_origins=["http://127.0.0.1:*", "http://localhost:*"],
+ ),
)
- mcp.run("streamable-http")
else:
asyncio.run(mcp.run_stdio_async())
diff --git a/src/backend/extensions/mcp_servers/agent_manager_mcp/server.py b/src/backend/extensions/mcp_servers/agent_manager_mcp/server.py
index dbf93650..a77565ba 100644
--- a/src/backend/extensions/mcp_servers/agent_manager_mcp/server.py
+++ b/src/backend/extensions/mcp_servers/agent_manager_mcp/server.py
@@ -13,11 +13,11 @@
from typing import Any, Dict, List, Optional
-from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.mcpserver import Context, MCPServer
from extensions.mcp_servers.agent_manager_mcp import impl
-mcp = FastMCP("hugagent-agent-manager")
+mcp = MCPServer("hugagent-agent-manager")
_HDR_USER = "x-current-user-id"
diff --git a/src/backend/extensions/mcp_servers/automation_task_mcp/server.py b/src/backend/extensions/mcp_servers/automation_task_mcp/server.py
index cb3c4d74..0b4696f7 100644
--- a/src/backend/extensions/mcp_servers/automation_task_mcp/server.py
+++ b/src/backend/extensions/mcp_servers/automation_task_mcp/server.py
@@ -13,11 +13,11 @@
from typing import Any, Dict, Optional
-from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.mcpserver import Context, MCPServer
from extensions.mcp_servers.automation_task_mcp import impl
-mcp = FastMCP("hugagent-automation-task")
+mcp = MCPServer("hugagent-automation-task")
_HDR_USER = "x-current-user-id"
_HDR_CHANNEL = "x-channel-id"
diff --git a/src/backend/extensions/mcp_servers/browser_runtime_mcp/server.py b/src/backend/extensions/mcp_servers/browser_runtime_mcp/server.py
index 5dc06af8..bd1f4b8a 100644
--- a/src/backend/extensions/mcp_servers/browser_runtime_mcp/server.py
+++ b/src/backend/extensions/mcp_servers/browser_runtime_mcp/server.py
@@ -3,14 +3,14 @@
import uuid
import base64
from core.infra.http_errors import response_error
-from mcp.server.fastmcp.utilities.types import Image
+from mcp.server.mcpserver.utilities.types import Image
from typing import Any
import httpx
-from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.mcpserver import Context, MCPServer
from extensions.mcp_servers._ports import PORTS
from extensions.mcp_servers._serve import run
-mcp = FastMCP("browser-automation")
+mcp = MCPServer("browser-automation")
async def call(ctx, action, resource_id=None, params=None, checkpoint_id=None):
headers = ctx.request_context.request.headers
diff --git a/src/backend/extensions/mcp_servers/internet_search_mcp/_selftest.py b/src/backend/extensions/mcp_servers/internet_search_mcp/_selftest.py
index f762d106..592c0d07 100755
--- a/src/backend/extensions/mcp_servers/internet_search_mcp/_selftest.py
+++ b/src/backend/extensions/mcp_servers/internet_search_mcp/_selftest.py
@@ -7,7 +7,7 @@
async def check():
tools = await mcp.list_tools()
- schema = tools[0].inputSchema
+ schema = tools[0].input_schema
assert "queries" in schema["properties"]
assert "cn_only" not in schema["properties"]
assert schema["properties"]["max_results"]["maximum"] == 8
diff --git a/src/backend/extensions/mcp_servers/internet_search_mcp/server.py b/src/backend/extensions/mcp_servers/internet_search_mcp/server.py
index 8e800893..055be6ef 100755
--- a/src/backend/extensions/mcp_servers/internet_search_mcp/server.py
+++ b/src/backend/extensions/mcp_servers/internet_search_mcp/server.py
@@ -5,44 +5,31 @@
import anyio
import httpx
-from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.mcpserver import Context, MCPServer
from pydantic import Field
from .impl import MAX_INFLIGHT, SearchService
from .models import Depth, Query, ResultLimit, SearchRequest, Topic
-# FastMCP enters lifespan per MCP session. Share one service across overlapping
-# sessions on this server's event loop; the last session closes its client.
-_service: SearchService | None = None
-_sessions = 0
-
-
@asynccontextmanager
-async def lifespan(server: FastMCP):
- global _service, _sessions
- if _service is None:
- _service = SearchService(
- httpx.AsyncClient(limits=httpx.Limits(max_connections=MAX_INFLIGHT))
- )
- service = _service
- _sessions += 1
+async def lifespan(server: MCPServer):
+ service = SearchService(
+ httpx.AsyncClient(limits=httpx.Limits(max_connections=MAX_INFLIGHT))
+ )
try:
yield service
finally:
- _sessions -= 1
- if _sessions == 0:
- _service = None
- with anyio.CancelScope(shield=True):
- await service.client.aclose()
+ with anyio.move_on_after(5, shield=True):
+ await service.client.aclose()
-class SearchMCP(FastMCP):
- """Validate the raw MCP boundary before FastMCP drops unknown arguments."""
+class SearchMCP(MCPServer):
+ """Validate the raw MCP boundary before MCPServer drops unknown arguments."""
- async def call_tool(self, name: str, arguments: dict[str, Any]):
+ async def call_tool(self, name: str, arguments: dict[str, Any], context=None):
if name == "internet_search":
SearchRequest.model_validate(arguments)
- return await super().call_tool(name, arguments)
+ return await super().call_tool(name, arguments, context)
mcp = SearchMCP("hugagent-internet-search", lifespan=lifespan)
diff --git a/src/backend/extensions/mcp_servers/management_registration.py b/src/backend/extensions/mcp_servers/management_registration.py
index 6aa9c7dd..a1a01742 100644
--- a/src/backend/extensions/mcp_servers/management_registration.py
+++ b/src/backend/extensions/mcp_servers/management_registration.py
@@ -2,7 +2,7 @@
import asyncio
from typing import Literal
from pydantic import BaseModel, ConfigDict
-from mcp.server.fastmcp import Context
+from mcp.server.mcpserver import Context
from mcp.types import ToolAnnotations
from core.capabilities.plugins.management.contract import MANAGERS, tools
from core.capabilities.plugins.management.errors import management_error
diff --git a/src/backend/extensions/mcp_servers/plugin_manager_mcp/server.py b/src/backend/extensions/mcp_servers/plugin_manager_mcp/server.py
index 23bbd74c..766ffe3c 100644
--- a/src/backend/extensions/mcp_servers/plugin_manager_mcp/server.py
+++ b/src/backend/extensions/mcp_servers/plugin_manager_mcp/server.py
@@ -11,11 +11,11 @@
from typing import Any, Dict, Optional
-from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.mcpserver import Context, MCPServer
from extensions.mcp_servers.plugin_manager_mcp import impl
-mcp = FastMCP("hugagent-plugin-manager")
+mcp = MCPServer("hugagent-plugin-manager")
_HDR_USER = "x-current-user-id"
diff --git a/src/backend/extensions/mcp_servers/retrieve_dataset_content_mcp/server.py b/src/backend/extensions/mcp_servers/retrieve_dataset_content_mcp/server.py
index 77b7e1a0..a2a1cc12 100755
--- a/src/backend/extensions/mcp_servers/retrieve_dataset_content_mcp/server.py
+++ b/src/backend/extensions/mcp_servers/retrieve_dataset_content_mcp/server.py
@@ -13,7 +13,7 @@
import logging
from typing import Any, Dict, Optional
-from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.mcpserver import Context, MCPServer
from .descriptions import (
_BASE_LOCAL_KB_TOOL_DESCRIPTION,
@@ -32,7 +32,19 @@
tool_timeout_payload,
)
-mcp = FastMCP("hugagent-retrieve-dataset-content")
+class RetrievalMCP(MCPServer):
+ async def list_tools(self):
+ tools = await super().list_tools()
+ try:
+ from extensions.mcp_servers.retrieve_dataset_content_mcp.wiki_impl import wiki_supported
+ if wiki_supported():
+ return tools
+ except Exception as exc:
+ _LOGGER.warning("wiki capability probe failed, hiding wiki tools: %s", exc)
+ return [tool for tool in tools if tool.name not in _WIKI_TOOL_NAMES]
+
+
+mcp = RetrievalMCP("hugagent-retrieve-dataset-content")
_LOGGER = logging.getLogger(__name__)
@@ -457,35 +469,6 @@ async def wiki_fetch_source(
return await _run_wiki("wiki_fetch_source", call, empty={"items": []})
-# ── 按后端动态暴露工具 ───────────────────────────────────────────────────────
-#
-# FastMCP 在 __init__ 里就把 self.list_tools 绑进了 lowlevel handler,所以事后
-# 改 mcp.list_tools 不生效——必须对 lowlevel server 重新注册一次(装饰器是
-# 覆盖写 request_handlers,不是追加)。
-
-
-_ORIGINAL_LIST_TOOLS = mcp.list_tools
-
-
-async def _list_tools_filtered():
- """平台没有 Wiki 源时隐藏 Wiki 工具;公有检索按配置选择后端。
-
- 每次 list_tools 都重新判定,所以切换知识库后端、或新建了一个开 Wiki 的自建库
- 之后,无需重启 mcp 容器。
- """
- tools = await _ORIGINAL_LIST_TOOLS()
- try:
- from extensions.mcp_servers.retrieve_dataset_content_mcp.wiki_impl import wiki_supported
-
- if wiki_supported():
- return tools
- except Exception as exc:
- _LOGGER.warning("wiki capability probe failed, hiding wiki tools: %s", exc)
- return [tool for tool in tools if tool.name not in _WIKI_TOOL_NAMES]
-
-
-mcp._mcp_server.list_tools()(_list_tools_filtered)
-
def main() -> None:
from extensions.mcp_servers import _serve
diff --git a/src/backend/extensions/mcp_servers/site_publish_mcp/server.py b/src/backend/extensions/mcp_servers/site_publish_mcp/server.py
index 97c7829f..dc2e7468 100644
--- a/src/backend/extensions/mcp_servers/site_publish_mcp/server.py
+++ b/src/backend/extensions/mcp_servers/site_publish_mcp/server.py
@@ -8,10 +8,10 @@
from features.hosted_apps.schema import ApplicationPayload, ToolDefinition
from pydantic import BaseModel
-from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.mcpserver import Context, MCPServer
from extensions.mcp_servers.site_publish_mcp import impl
-mcp = FastMCP("hugagent-site-publish")
+mcp = MCPServer("hugagent-site-publish")
_HDR_USER = "x-current-user-id"
_HDR_CHAT = "x-chat-id"
diff --git a/src/backend/extensions/mcp_servers/skill_manager_mcp/server.py b/src/backend/extensions/mcp_servers/skill_manager_mcp/server.py
index 5d7c3e87..9f36e588 100644
--- a/src/backend/extensions/mcp_servers/skill_manager_mcp/server.py
+++ b/src/backend/extensions/mcp_servers/skill_manager_mcp/server.py
@@ -11,11 +11,11 @@
from typing import Any, Dict, Optional
-from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.mcpserver import Context, MCPServer
from extensions.mcp_servers.skill_manager_mcp import impl
-mcp = FastMCP("hugagent-skill-manager")
+mcp = MCPServer("hugagent-skill-manager")
_HDR_USER = "x-current-user-id"
diff --git a/src/backend/extensions/mcp_servers/web_fetch_mcp/server.py b/src/backend/extensions/mcp_servers/web_fetch_mcp/server.py
index 0671867d..5799a100 100644
--- a/src/backend/extensions/mcp_servers/web_fetch_mcp/server.py
+++ b/src/backend/extensions/mcp_servers/web_fetch_mcp/server.py
@@ -4,13 +4,13 @@
from typing import Annotated, Any, Literal
import httpx
-from mcp.server import FastMCP
+from mcp.server.mcpserver import MCPServer
from pydantic import Field
from .impl import fetch_url
logger = logging.getLogger(__name__)
-mcp = FastMCP("hugagent-web-fetch")
+mcp = MCPServer("hugagent-web-fetch")
@mcp.tool()
diff --git a/src/backend/extensions/plugins/marketplace/agent-manager/mcp.json b/src/backend/extensions/plugins/marketplace/agent-manager/mcp.json
index 9c5680a7..bfcadab0 100644
--- a/src/backend/extensions/plugins/marketplace/agent-manager/mcp.json
+++ b/src/backend/extensions/plugins/marketplace/agent-manager/mcp.json
@@ -3,7 +3,7 @@
"mcpServers": {
"agent_manager": {
"type": "streamable-http",
- "url": "http://mcp:9115/mcp/"
+ "url": "http://mcp:9115/mcp"
}
}
}
diff --git a/src/backend/extensions/plugins/marketplace/automation/mcp.json b/src/backend/extensions/plugins/marketplace/automation/mcp.json
index 551c583c..73c1613b 100644
--- a/src/backend/extensions/plugins/marketplace/automation/mcp.json
+++ b/src/backend/extensions/plugins/marketplace/automation/mcp.json
@@ -3,7 +3,7 @@
"mcpServers": {
"automation_task": {
"type": "streamable-http",
- "url": "http://mcp:9108/mcp/"
+ "url": "http://mcp:9108/mcp"
}
}
}
diff --git a/src/backend/extensions/plugins/marketplace/plugin-manager/mcp.json b/src/backend/extensions/plugins/marketplace/plugin-manager/mcp.json
index 9987f5b2..fe9f3283 100644
--- a/src/backend/extensions/plugins/marketplace/plugin-manager/mcp.json
+++ b/src/backend/extensions/plugins/marketplace/plugin-manager/mcp.json
@@ -3,7 +3,7 @@
"mcpServers": {
"plugin_manager": {
"type": "streamable-http",
- "url": "http://mcp:9116/mcp/"
+ "url": "http://mcp:9116/mcp"
}
}
}
diff --git a/src/backend/extensions/plugins/marketplace/sites/mcp.json b/src/backend/extensions/plugins/marketplace/sites/mcp.json
index 4e51449e..cf976440 100644
--- a/src/backend/extensions/plugins/marketplace/sites/mcp.json
+++ b/src/backend/extensions/plugins/marketplace/sites/mcp.json
@@ -3,7 +3,7 @@
"mcpServers": {
"site_publish": {
"type": "streamable-http",
- "url": "http://mcp:9113/mcp/"
+ "url": "http://mcp:9113/mcp"
}
}
}
diff --git a/src/backend/extensions/plugins/marketplace/skill-manager/mcp.json b/src/backend/extensions/plugins/marketplace/skill-manager/mcp.json
index 177f6ee8..e246f89d 100644
--- a/src/backend/extensions/plugins/marketplace/skill-manager/mcp.json
+++ b/src/backend/extensions/plugins/marketplace/skill-manager/mcp.json
@@ -3,7 +3,7 @@
"mcpServers": {
"skill_manager": {
"type": "streamable-http",
- "url": "http://mcp:9112/mcp/"
+ "url": "http://mcp:9112/mcp"
}
}
}
diff --git a/src/backend/features/__init__.py b/src/backend/features/__init__.py
index 8ec4e33d..233a7460 100644
--- a/src/backend/features/__init__.py
+++ b/src/backend/features/__init__.py
@@ -115,7 +115,7 @@ def register_run_capabilities():
from core.harness.registry.host_runtime import register_remote_execution_policy
from features.desktop.auth import bridge as desktop_bridge
from core.harness.registry.authentication import register_host_authentication
- register_host_authentication(desktop_bridge.resolve_bridge_user)
+ register_host_authentication(desktop_bridge.claims_request, desktop_bridge.resolve_bridge_user)
register_remote_execution_policy(lambda: desktop_bridge.bridge_enabled())
from core.harness.registry.run_capabilities import register_run_capability_provider
from features.desktop.capabilities import paths
diff --git a/src/backend/features/automation/README.md b/src/backend/features/automation/README.md
index 59c4693b..f5553e21 100644
--- a/src/backend/features/automation/README.md
+++ b/src/backend/features/automation/README.md
@@ -2,7 +2,7 @@
管理计划任务、执行记录、展示摘要、执行位置、通知投递与任务循环。`service.AutomationService` 保留 CRUD 和原子状态变更;`scheduling.compute_next_run` 保留时区感知的 Cron 计算;其余模块按执行绑定、投递目标、通知与远端副作用拆分。
-对外接口包括原 `/v1/automations` 路由、MCP 任务服务调用的公开服务方法,以及 orchestration 调度器/恢复器使用的任务接口。路由顺序与权限键不变,调度器仍留在 orchestration。模型 `ScheduledTask`、`ScheduledTaskRun` 在 FULL 和 CE 均通过安装清单注册,表名、约束、默认值及关系不变。
+对外接口包括原 `/v1/automations` 路由、MCP 任务服务调用的公开服务方法,以及 orchestration 调度器/恢复器使用的任务接口。路由顺序与权限键不变,调度器仍留在 orchestration。模型 `ScheduledTask`、`ScheduledTaskRun` 在 FULL 和 CE 均通过安装清单注册,表名与关系不变;执行状态新增 pending,升级脚本与本机 schema hook 同步更新状态约束。
依赖内核数据库、运行账本、项目权限、沙箱和临时状态接口。显式功能依赖为 **automation → desktop**:`tool_routing` 核验当前设备与账号授权,再选择本机任务执行;cloud 路径不使用此能力。当前 core.auth.desktop_bridge/core.capabilities 的物理路径待 P5 按清单迁移。
@@ -10,4 +10,6 @@ P5 边界待办:桌面工具执行器当前反向调用本模块的路由与
模块安装不自动启动调度器;现有 orchestration 启停和环境配置保持原行为。浏览器自动化属于另一套能力,不归入本目录。
-执行记录的条件写入由 `runs.AutomationRunRepository` 管理。完成只接受 running 且 lease_token 匹配的记录。恢复先认领,再重读 chat_id。取消时由自动化模块提供事务内所有权 guard,内核账本不导入功能模型;认领与取消通过执行记录行锁串行化。
+执行记录的条件写入由 `runs.AutomationRunRepository` 管理。完成只接受 running 且 lease_token 匹配的记录。恢复先认领,再重读 chat_id。子执行通过事务内 on_accept 钩子关联并保存恢复参数,内核不导入功能模型。调度器取消只转交观察权,用户取消仍通过会话执行器处理。
+
+接纳由 admission.admit 管理:条件更新消费触发信号并插入 pending 记录,同事务提交;禁止恢复独立清空触发信号或提前推进排期的旧路径。
diff --git a/src/backend/features/automation/admission.py b/src/backend/features/automation/admission.py
new file mode 100644
index 00000000..0630c400
--- /dev/null
+++ b/src/backend/features/automation/admission.py
@@ -0,0 +1,80 @@
+"""Durable admission: consume a trigger and insert its run in one transaction."""
+import uuid
+
+from sqlalchemy import and_, or_
+
+from core.infra.time import utc_now
+from features.automation.models import ScheduledTask, ScheduledTaskRun
+from features.automation import scheduling
+
+LIVE = ("pending", "running")
+
+
+def admit(db, limit):
+ if limit <= 0:
+ return []
+ now = utc_now()
+ outstanding = db.query(ScheduledTaskRun.run_id).filter(
+ ScheduledTaskRun.task_id == ScheduledTask.task_id,
+ ScheduledTaskRun.status.in_(LIVE),
+ ).exists()
+ tasks = (
+ db.query(ScheduledTask)
+ .filter(
+ ScheduledTask.status.in_(("active", "paused", "completed")),
+ ~outstanding,
+ or_(
+ ScheduledTask.manual_trigger_at.isnot(None),
+ and_(ScheduledTask.status == "active",
+ ScheduledTask.schedule_type != "manual",
+ ScheduledTask.next_run_at <= now),
+ ),
+ )
+ .order_by(ScheduledTask.updated_at, ScheduledTask.task_id)
+ .with_for_update(skip_locked=True)
+ .limit(limit).all()
+ )
+ ids = []
+ for task in tasks:
+ manual = task.manual_trigger_at is not None
+ count = (task.run_count or 0) + 1
+ values = {"run_count": count, "updated_at": now}
+ if manual:
+ values["manual_trigger_at"] = None
+ elif (not task.recurring or task.schedule_type == "once"
+ or (task.max_runs and count >= task.max_runs)):
+ values["next_run_at"] = None
+ else:
+ values["next_run_at"] = scheduling.compute_next_run(task.cron_expression, task.timezone)
+ # Row locks are an optimization; the conditional write also fences
+ # SQLite and a contender whose initial SELECT observed an older row.
+ changed = db.query(ScheduledTask).filter(
+ ScheduledTask.task_id == task.task_id,
+ ScheduledTask.run_count == task.run_count,
+ ScheduledTask.manual_trigger_at == task.manual_trigger_at,
+ ScheduledTask.next_run_at == task.next_run_at,
+ ScheduledTask.updated_at == task.updated_at,
+ ~outstanding,
+ ).update(values, synchronize_session=False)
+ if changed != 1:
+ continue
+ run = ScheduledTaskRun(
+ run_id="run_" + uuid.uuid4().hex[:16],
+ task_id=task.task_id, status="pending", started_at=now,
+ )
+ db.add(run)
+ ids.append(run.run_id)
+ db.commit()
+ db.expire_all()
+ return ids
+
+
+def candidates(db, limit):
+ return [
+ task_id
+ for (task_id,) in db.query(ScheduledTask.task_id)
+ .join(ScheduledTaskRun)
+ .filter(ScheduledTaskRun.status.in_(LIVE))
+ .order_by(ScheduledTaskRun.started_at)
+ .limit(limit).all()
+ ]
diff --git a/src/backend/features/automation/api/automations.py b/src/backend/features/automation/api/automations.py
index 3356d8c6..1e70b1da 100644
--- a/src/backend/features/automation/api/automations.py
+++ b/src/backend/features/automation/api/automations.py
@@ -108,7 +108,8 @@ def create_automation(
if conn is None or conn.owner_user_id != str(user.user_id):
raise HTTPException(status_code=403, detail="无权投递到该渠道机器人")
task_metadata = {**(task_metadata or {}),
- "channel_id": req.channel_id, "conversation_id": req.conversation_id}
+ "delivery_targets": [{"type": "inapp"}, {"type": "channel",
+ "channel_id": req.channel_id, "conversation_id": req.conversation_id}]}
from features.automation.execution import execution_metadata
import pytz
@@ -201,9 +202,7 @@ def update_automation(
if not task0:
raise HTTPException(status_code=404, detail="任务不存在")
new_ed = dict(task0.extra_data or {})
- # First clear old delivery markers (flat ones + channel targets inside the list)
- new_ed.pop("channel_id", None)
- new_ed.pop("conversation_id", None)
+ # Replace channel targets while retaining the other delivery destinations.
if isinstance(new_ed.get("delivery_targets"), list):
new_ed["delivery_targets"] = [
t for t in new_ed["delivery_targets"]
@@ -217,8 +216,10 @@ def update_automation(
conn = ChannelConnectionRepository(db).get_by_id(req.channel_id)
if conn is None or conn.owner_user_id != str(user.user_id):
raise HTTPException(status_code=403, detail="无权投递到该渠道机器人")
- new_ed["channel_id"] = req.channel_id
- new_ed["conversation_id"] = req.conversation_id
+ new_ed.setdefault("delivery_targets", [{"type": "inapp"}]).append({
+ "type": "channel", "channel_id": req.channel_id,
+ "conversation_id": req.conversation_id,
+ })
updates["extra_data"] = new_ed
try:
diff --git a/src/backend/features/automation/delivery_targets.py b/src/backend/features/automation/delivery_targets.py
index 187b3a5a..5eaee4fc 100644
--- a/src/backend/features/automation/delivery_targets.py
+++ b/src/backend/features/automation/delivery_targets.py
@@ -7,10 +7,6 @@
{"type": "channel", "channel_id": "chan_…", "conversation_id": "oc_…"} # IM conversation (Feishu, etc.)
{"type": "email", "to": "a@b.com"} # reserved, delivery not yet implemented
-Backward compatibility: legacy tasks put flat ``channel_id`` / ``conversation_id`` only
-at the top level of extra_data (old #7 style); ``resolve_delivery_targets`` interprets
-this as ``[{inapp}, {channel…}]``. No markers at all → in-app only.
-
See internal design docs.
"""
@@ -22,12 +18,7 @@
def resolve_delivery_targets(extra_data: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
- """Resolve the final delivery-target list from a task's extra_data (with backward
- compatibility).
-
- Priority: explicit ``delivery_targets`` > legacy flat ``channel_id/conversation_id``
- > in-app only.
- """
+ """Read the canonical delivery-target list; omitted targets mean in-app."""
meta = extra_data if isinstance(extra_data, dict) else {}
targets = meta.get("delivery_targets")
if isinstance(targets, list) and targets:
@@ -38,13 +29,7 @@ def resolve_delivery_targets(extra_data: Optional[Dict[str, Any]]) -> List[Dict[
if out:
return out
- # Backward compatibility: legacy #7 channel tasks only put flat channel_id/conversation_id at the top level
- ch = meta.get("channel_id")
- conv = meta.get("conversation_id")
- legacy: List[Dict[str, Any]] = [{"type": "inapp"}]
- if ch and conv:
- legacy.append({"type": "channel", "channel_id": ch, "conversation_id": conv})
- return legacy
+ return [{"type": "inapp"}]
def build_delivery_targets(
diff --git a/src/backend/features/automation/extension.py b/src/backend/features/automation/extension.py
index 73258b57..ecc708de 100644
--- a/src/backend/features/automation/extension.py
+++ b/src/backend/features/automation/extension.py
@@ -2,7 +2,14 @@
NAME = "automation"
+def register_schema():
+ from core.harness.registry.schema_lifecycle import register_schema_lifecycle
+ from features.automation.schema import initialize_schema
+ register_schema_lifecycle(NAME, initialize_schema)
+
+
def setup():
+ register_schema()
return True
diff --git a/src/backend/features/automation/models.py b/src/backend/features/automation/models.py
index 6a3bf737..6a75b100 100644
--- a/src/backend/features/automation/models.py
+++ b/src/backend/features/automation/models.py
@@ -4,7 +4,7 @@
from datetime import datetime, timezone
-from core.db.engine import Base
+from core.db.models import Base, Plan, UserShadow
from sqlalchemy import (
JSON,
BigInteger,
@@ -79,8 +79,8 @@ class ScheduledTask(Base):
updated_at = Column(UTCDateTime(timezone=True), default=utc_now, onupdate=utc_now)
# Relationships
- user = relationship("UserShadow")
- plan = relationship("Plan")
+ user = relationship(UserShadow)
+ plan = relationship(Plan)
run_history = relationship(
"ScheduledTaskRun",
back_populates="task",
@@ -134,7 +134,7 @@ class ScheduledTaskRun(Base):
__table_args__ = (
CheckConstraint(
- "status IN ('running', 'success', 'failed')",
+ "status IN ('pending', 'running', 'success', 'failed')",
name="scheduled_task_runs_status_check",
),
Index("idx_scheduled_task_runs_task_id", "task_id"),
diff --git a/src/backend/features/automation/runs.py b/src/backend/features/automation/runs.py
index a2cc22d1..0b22d120 100644
--- a/src/backend/features/automation/runs.py
+++ b/src/backend/features/automation/runs.py
@@ -1,7 +1,5 @@
"""Conditional ownership writes for scheduled execution records."""
-import uuid
-
from core.infra.time import utc_now
from features.automation.models import ScheduledTaskRun
@@ -10,19 +8,6 @@ class AutomationRunRepository:
def __init__(self, db):
self.db = db
- def start(self, task_id, lease_token=None):
- run = ScheduledTaskRun(
- run_id=f"run_{uuid.uuid4().hex[:16]}",
- task_id=task_id,
- status="running",
- lease_token=lease_token,
- started_at=utc_now(),
- )
- self.db.add(run)
- self.db.commit()
- self.db.refresh(run)
- return run
-
def claim(self, run_id, lease_token, *, expected_token):
"""Only a scheduler holding Redis ownership may transfer a running row."""
if not lease_token:
@@ -31,10 +16,10 @@ def claim(self, run_id, lease_token, *, expected_token):
self.db.query(ScheduledTaskRun)
.filter(
ScheduledTaskRun.run_id == run_id,
- ScheduledTaskRun.status == "running",
+ ScheduledTaskRun.status.in_(("pending", "running")),
ScheduledTaskRun.lease_token == expected_token,
)
- .update({ScheduledTaskRun.lease_token: lease_token}, synchronize_session=False)
+ .update({ScheduledTaskRun.lease_token: lease_token, ScheduledTaskRun.status: "running"}, synchronize_session=False)
)
self.db.commit()
return count == 1
diff --git a/src/backend/features/automation/schema.py b/src/backend/features/automation/schema.py
new file mode 100644
index 00000000..9ac2ca52
--- /dev/null
+++ b/src/backend/features/automation/schema.py
@@ -0,0 +1,59 @@
+"""Schema upgrades for automation admission and canonical delivery targets."""
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy import inspect
+
+RUN_STATUSES = "status IN ('pending', 'running', 'success', 'failed')"
+
+
+def upgrade_queue(connection):
+ inspector = inspect(connection)
+ if not inspector.has_table("scheduled_task_runs"):
+ return
+ current = next((item for item in inspector.get_check_constraints("scheduled_task_runs")
+ if item.get("name") == "scheduled_task_runs_status_check"), None)
+ if current and "'pending'" in current["sqltext"]:
+ return
+ operations = Operations(MigrationContext.configure(connection))
+ with operations.batch_alter_table("scheduled_task_runs") as batch:
+ if current:
+ batch.drop_constraint("scheduled_task_runs_status_check", type_="check")
+ batch.create_check_constraint("scheduled_task_runs_status_check", RUN_STATUSES)
+
+
+def initialize_schema():
+ from core.db.engine import engine
+ with engine.begin() as connection:
+ upgrade_queue(connection)
+ upgrade_delivery_targets(connection)
+
+
+def upgrade_delivery_targets(connection):
+ """Migrate stored flat destinations once; request handling reads only the new form."""
+ from sqlalchemy import Column, JSON, MetaData, String, Table, select, text
+ if not inspect(connection).has_table("scheduled_tasks"):
+ return
+ tasks = Table("scheduled_tasks", MetaData(),
+ Column("task_id", String), Column("metadata", JSON))
+ predicate = (
+ "metadata::jsonb ?| array['channel_id', 'conversation_id']"
+ if connection.dialect.name == "postgresql" else
+ "json_type(metadata, '$.channel_id') IS NOT NULL OR "
+ "json_type(metadata, '$.conversation_id') IS NOT NULL"
+ )
+ query = select(tasks.c.task_id, tasks.c.metadata).where(text(predicate))
+ for task_id, data in connection.execute(query):
+ if not isinstance(data, dict) or not ({"channel_id", "conversation_id"} & data.keys()):
+ continue
+ data = dict(data)
+ channel = data.pop("channel_id", None)
+ conversation = data.pop("conversation_id", None)
+ targets = data.get("delivery_targets")
+ valid = [dict(item) for item in targets
+ if isinstance(item, dict) and item.get("type") in ("inapp", "channel", "email")] if isinstance(targets, list) else []
+ if not valid:
+ valid = [{"type": "inapp"}]
+ if channel and conversation:
+ valid.append({"type": "channel", "channel_id": channel, "conversation_id": conversation})
+ data["delivery_targets"] = valid
+ connection.execute(tasks.update().where(tasks.c.task_id == task_id).values(metadata=data))
diff --git a/src/backend/features/automation/service.py b/src/backend/features/automation/service.py
index d1e07bc2..b1117403 100644
--- a/src/backend/features/automation/service.py
+++ b/src/backend/features/automation/service.py
@@ -32,9 +32,8 @@ def _channel_target_fields(extra_data: Optional[Dict[str, Any]]) -> Dict[str, An
"""Parse the first channel delivery target from a task's extra_data, filling in
{channel_id, conversation_id}.
- Handles both the delivery_targets list and the old flat form (resolve_delivery_targets
- unifies them). No channel target → both are None (= site-internal only). Exposed via
- task_to_dict to backfill the frontend edit form.
+ Reads delivery_targets for the frontend edit form. Without a channel target,
+ both fields are None.
"""
try:
from features.automation.delivery_targets import resolve_delivery_targets
@@ -256,18 +255,6 @@ def resume_task(self, task_id: str, user_id: str) -> Optional[ScheduledTask]:
# ── Due tasks (for scheduler) ──────────────────────────────────
- def get_due_tasks(self, now: datetime) -> List[ScheduledTask]:
- # Manual-type tasks are never auto-fired; they can only be triggered via /trigger.
- return (
- self.db.query(ScheduledTask)
- .filter(
- ScheduledTask.status == "active",
- ScheduledTask.schedule_type != "manual",
- ScheduledTask.next_run_at <= now,
- )
- .all()
- )
-
@staticmethod
def _is_oneshot_done(task: ScheduledTask) -> bool:
"""True when no more runs are scheduled after the current one
@@ -281,30 +268,6 @@ def _is_oneshot_done(task: ScheduledTask) -> bool:
or bool(task.max_runs and (task.run_count or 0) >= task.max_runs)
)
- def advance_next_run(self, task_id: str) -> None:
- task = self.get_task_by_id(task_id)
- if not task:
- return
- task.run_count = (task.run_count or 0) + 1
-
- # Manual tasks: stay active, no next_run_at, run_count just grows.
- if task.schedule_type == "manual":
- task.next_run_at = None
- # One-shot / max_runs reached: clear next_run_at so the schedule stops
- # re-firing. Do NOT flip status to "completed" here — this runs as a
- # PRE-advance (before execute_task), and execute_task's guard refuses any
- # task whose status isn't active/paused. Marking it completed now would
- # make the executor skip the very run it just fired (never records a run,
- # never delivers to the channel). Terminal status is set AFTER the run by
- # finalize_after_run().
- elif self._is_oneshot_done(task):
- task.next_run_at = None
- else:
- task.next_run_at = scheduling.compute_next_run(task.cron_expression, task.timezone)
-
- task.updated_at = datetime.now(timezone.utc)
- self.db.commit()
-
def request_manual_trigger(self, task: ScheduledTask) -> None:
"""登记一次「立即执行」,留给调度器取走。
@@ -318,44 +281,9 @@ def request_manual_trigger(self, task: ScheduledTask) -> None:
task.updated_at = task.manual_trigger_at
self.db.commit()
- def take_manual_triggers(self) -> List[Tuple[str, str]]:
- """取走所有待执行的手动触发,返回 (task_id, user_id)。
-
- 一条语句取走并清空,取到即归本次调用所有,不必再加行锁。
- """
- taken = self.db.execute(
- update(ScheduledTask)
- .where(ScheduledTask.manual_trigger_at.isnot(None))
- .values(manual_trigger_at=None)
- .returning(ScheduledTask.task_id, ScheduledTask.user_id)
- ).all()
- self.db.commit()
- return [(row.task_id, row.user_id) for row in taken]
-
- def bump_run_count(self, task_id: str) -> None:
- """手动触发路径专用的计数递增。
-
- run_count 原本只在 advance_next_run 里 +1,而那是**调度器轮询**的前置步骤。
- 手动「立即执行」和 schedule_type='manual' 的任务根本不走调度器(get_due_tasks
- 把 manual 排除在外),于是这些执行只落了一条 run 记录、累计次数却纹丝不动——
- 详情页出现「有 5 条执行记录、累计执行 0 次」的自相矛盾。
-
- 这里不动调度器那条路径的语义(那边 +1 与 next_run_at 推进必须原子发生),
- 只补上手动触发这一路的计数。
- """
- self.db.execute(
- update(ScheduledTask)
- .where(ScheduledTask.task_id == task_id)
- .values(
- run_count=func.coalesce(ScheduledTask.run_count, 0) + 1,
- updated_at=datetime.now(timezone.utc),
- )
- )
- self.db.commit()
-
def finalize_after_run(self, task_id: str) -> None:
"""Mark one-shot / exhausted tasks 'completed' once their run has
- finished. Recurring tasks stay active (advance_next_run already moved
+ finished. Recurring tasks stay active (admission already moved
their next_run_at forward). Never overrides an auto-disabled task."""
task = self.get_task_by_id(task_id)
if not task or task.status == "disabled":
@@ -368,11 +296,6 @@ def finalize_after_run(self, task_id: str) -> None:
# ── Run history ───────────────────────────────────────────────
- def record_run_start(self, task_id: str, *, lease_token=None) -> ScheduledTaskRun:
- from .runs import AutomationRunRepository
-
- return AutomationRunRepository(self.db).start(task_id, lease_token)
-
def record_run_complete(
self,
run_id: str,
@@ -453,7 +376,7 @@ def task_to_dict(task: ScheduledTask) -> Dict[str, Any]:
"description": task.description,
"sidebar_activated": bool(task.sidebar_activated),
# Channel delivery target (for frontend edit backfill): parse the first channel
- # target from delivery_targets / the old flat channel_id. None if absent (= site-internal only).
+ # target from delivery_targets. None if absent (= site-internal only).
**_channel_target_fields(task.extra_data),
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
diff --git a/src/backend/features/automation/tool_routing.py b/src/backend/features/automation/tool_routing.py
index e7baaef3..5c62d479 100644
--- a/src/backend/features/automation/tool_routing.py
+++ b/src/backend/features/automation/tool_routing.py
@@ -33,14 +33,55 @@ def creation_arguments(arguments, user_id, chat_id):
return args
+def _local_target(db, tool_name, args, headers, effect):
+ """Resolve the local owner and arguments, or None when the call runs in the cloud."""
+ from core.db.models import ChatRun, ChatSession
+ from features.automation.models import ScheduledTask
+
+ run = db.get(ChatRun, effect.run_id) if effect else None
+ chat_id = next((v for k, v in headers.items() if k.lower() == "x-chat-id"), "")
+ chat = db.get(ChatSession, chat_id) if chat_id else None
+ user_id = run.user_id if run else chat.user_id if chat else None
+ if not user_id:
+ raise ValueError("无法确定本机任务的当前用户")
+ from features.desktop.capabilities.skill_account import current_local_user_id
+ # The same cloud account has a device-local shadow ID. Resolve its
+ # cloud-address-scoped mapping before comparing local resource owners.
+ if current_local_user_id() != user_id:
+ raise ValueError("本机任务所属账号与当前云端账号不匹配")
+ location = args.get("execution_location") or ""
+ if location not in {"", "local", "cloud"}:
+ raise ValueError("执行位置必须为本机或云端")
+ if tool_name == "create_scheduled_task":
+ args = creation_arguments(args, user_id, run.chat_id if run else chat_id)
+ location = args["execution_location"]
+ if location == "cloud" and args.get("project_id"):
+ from core.db.models import Project
+ project = db.get(Project, args["project_id"])
+ if project is not None and project.kind == "local":
+ raise ValueError("本机项目不能在云端执行,请选择本机")
+ elif not location and args.get("task_ref"):
+ local = db.get(ScheduledTask, args["task_ref"])
+ if local and local.user_id == user_id:
+ location = "local"
+ elif db.query(ScheduledTask).filter(
+ ScheduledTask.user_id == user_id,
+ ScheduledTask.name.ilike(f"%{args['task_ref']}%"),
+ ).first() is not None:
+ raise ValueError("本机存在同名任务,请明确 execution_location 或使用任务 ID")
+ merge_cloud = tool_name == "list_scheduled_tasks" and not location
+ if merge_cloud:
+ location = "local"
+ if location != "local":
+ return None
+ return user_id, args, merge_cloud
+
+
async def local_automation_result(source_plugin, tool_name, arguments, headers, *, authorize=None):
"""Return None for cloud execution; local dispatch requires the automation component."""
if source_plugin != "automation" or tool_name not in TOOLS:
return None
- from features.desktop.bridge import client as bridge
- from core.db.engine import SessionLocal
- from core.db.models import ChatRun, ChatSession
- from features.automation.models import ScheduledTask
+ from core.db.threads import run_in_session
from core.harness.tool_effect_context import CURRENT_TOOL_EFFECT
from features.automation.execution import instance_location
@@ -48,46 +89,13 @@ async def local_automation_result(source_plugin, tool_name, arguments, headers,
return None
identity_state = bridge_identity.get_state()
bridge_identity.ensure_current_authorization(identity_state)
- args = dict(arguments)
# User identity belongs to the durable local run, not model arguments.
- effect = CURRENT_TOOL_EFFECT.get()
- with SessionLocal() as db:
- run = db.get(ChatRun, effect.run_id) if effect else None
- chat_id = next((v for k, v in headers.items() if k.lower() == "x-chat-id"), "")
- chat = db.get(ChatSession, chat_id) if chat_id else None
- user_id = run.user_id if run else chat.user_id if chat else None
- if not user_id:
- raise ValueError("无法确定本机任务的当前用户")
- from features.desktop.capabilities.skill_account import current_local_user_id
- # The same cloud account has a device-local shadow ID. Resolve its
- # cloud-address-scoped mapping before comparing local resource owners.
- if current_local_user_id() != user_id:
- raise ValueError("本机任务所属账号与当前云端账号不匹配")
- location = args.get("execution_location") or ""
- if location not in {"", "local", "cloud"}:
- raise ValueError("执行位置必须为本机或云端")
- if tool_name == "create_scheduled_task":
- args = creation_arguments(args, user_id, run.chat_id if run else chat_id)
- location = args["execution_location"]
- if location == "cloud" and args.get("project_id"):
- from core.db.models import Project
- project = db.get(Project, args["project_id"])
- if project is not None and project.kind == "local":
- raise ValueError("本机项目不能在云端执行,请选择本机")
- elif not location and args.get("task_ref"):
- local = db.get(ScheduledTask, args["task_ref"])
- if local and local.user_id == user_id:
- location = "local"
- elif db.query(ScheduledTask).filter(
- ScheduledTask.user_id == user_id,
- ScheduledTask.name.ilike(f"%{args['task_ref']}%"),
- ).first() is not None:
- raise ValueError("本机存在同名任务,请明确 execution_location 或使用任务 ID")
- merge_cloud = tool_name == "list_scheduled_tasks" and not location
- if merge_cloud:
- location = "local"
- if location != "local":
- return None
+ target = await run_in_session(
+ _local_target, tool_name, dict(arguments), headers, CURRENT_TOOL_EFFECT.get()
+ )
+ if target is None:
+ return None
+ user_id, args, merge_cloud = target
if authorize is None:
raise ValueError("本机工具缺少当前授权校验")
await authorize()
diff --git a/src/backend/features/desktop/auth/bridge.py b/src/backend/features/desktop/auth/bridge.py
index a4c13f56..2abd09eb 100644
--- a/src/backend/features/desktop/auth/bridge.py
+++ b/src/backend/features/desktop/auth/bridge.py
@@ -85,7 +85,7 @@ def _ensure_bridge_capabilities(db: Session, shadow) -> None:
meta = dict(shadow.extra_data or {})
if meta.get(_BRIDGE_META_FLAG):
return
- from core.auth.local_users_state import CE_DEFAULT_ADMIN_CAPABILITIES
+ from core.auth.local_users_constants import CE_DEFAULT_ADMIN_CAPABILITIES
meta.update(CE_DEFAULT_ADMIN_CAPABILITIES)
meta["auth_source"] = "desktop_bridge"
@@ -103,6 +103,11 @@ def _ensure_bridge_capabilities(db: Session, shadow) -> None:
db.commit()
+def claims_request(request: Request) -> bool:
+ """Whether this request presents a bridge credential (header check, no I/O)."""
+ return bridge_enabled() and bool(request.headers.get(BRIDGE_SECRET_HEADER, ""))
+
+
def resolve_bridge_user(request: Request, db: Session):
"""尝试按桌面桥接头解析用户;未启用/未命中/校验失败返回 None(回落常规认证)。
diff --git a/src/backend/features/desktop/bridge/gateway.py b/src/backend/features/desktop/bridge/gateway.py
index 6df13858..565f0cf6 100644
--- a/src/backend/features/desktop/bridge/gateway.py
+++ b/src/backend/features/desktop/bridge/gateway.py
@@ -128,12 +128,10 @@ def cloud_gateway_mcp_configs(
invoke_url = f"{st['cloud_base']}/api/v1/desktop/capability/gateway/{sid}/call"
configs[sid] = {
"transport": "streamable_http",
- # HttpMCPConfig requires a URL, but ManifestMCPClient never performs
- # discovery against it. The same JSON endpoint is the only remote hop.
+ # This endpoint invokes the authorized manifest tool through JSON.
"url": invoke_url,
"headers": bridge_identity.cloud_headers(st),
"execution_timeout": 180,
- "is_stable": False,
"schema_source": "cloud_manifest",
"manifest_revision": manifest_revision,
"manifest_tools": s["tools"],
diff --git a/src/backend/features/desktop/bridge/resource_plugins.py b/src/backend/features/desktop/bridge/resource_plugins.py
index be38499f..c913f803 100644
--- a/src/backend/features/desktop/bridge/resource_plugins.py
+++ b/src/backend/features/desktop/bridge/resource_plugins.py
@@ -103,7 +103,6 @@ def local_config(server, user_id, profile):
"url": _mcp_http_url(audience),
"origin": "local_plugin",
"execution_scope": "local",
- "is_stable": False,
"owner_user_id": user_id,
"source_plugin": inst.key,
"plugin_install_id": inst.install_id,
diff --git a/src/backend/features/desktop/capabilities/device_plugin_catalog.py b/src/backend/features/desktop/capabilities/device_plugin_catalog.py
index 1e5d8d1e..d5b383ac 100644
--- a/src/backend/features/desktop/capabilities/device_plugin_catalog.py
+++ b/src/backend/features/desktop/capabilities/device_plugin_catalog.py
@@ -1,6 +1,5 @@
"""Plugin catalog views over authorized device installations."""
from typing import Any, Dict, List, Optional
-from features.desktop.capabilities import device_catalog as catalog
from features.desktop.capabilities.local_plugin_runtime import enabled_for
from core.capabilities.identity_rules import KIND_PLUGIN, KIND_SKILL
from features.desktop.capabilities.paths import capabilities_enabled
@@ -191,3 +190,8 @@ def resolve_resource_plugin(slug, user_id, *, install_id=None):
package,
)
return None
+
+
+# device_catalog re-exports this module's functions at import time, so it is
+# bound last: by then those names exist whichever module was imported first.
+from features.desktop.capabilities import device_catalog as catalog # noqa: E402
diff --git a/src/backend/features/desktop/capabilities/local_plugin_runtime.py b/src/backend/features/desktop/capabilities/local_plugin_runtime.py
index 48d33c08..ec7b5e80 100644
--- a/src/backend/features/desktop/capabilities/local_plugin_runtime.py
+++ b/src/backend/features/desktop/capabilities/local_plugin_runtime.py
@@ -3,6 +3,7 @@
from features.desktop.capabilities import registry, store
from core.capabilities.identity_rules import LOCAL_PROFILE
from features.desktop.capabilities.paths import capabilities_enabled
+from core.capabilities.mcp.service import rewrite_builtin_mcp_host
def enabled_for(inst, user_id):
@@ -27,7 +28,10 @@ def configs(user_id, enabled_only=True):
if enabled_only and not entry.get("enabled", True):
continue
config = json.loads(json.dumps(entry).replace("{plugin_root}", str(comp.path / "package").replace("\\", "\\\\")))
- config.update(source_plugin=inst.key, owner_user_id=user_id, origin="local_plugin", execution_scope="local", is_stable=False)
+ if config.get("url"):
+ # Packaged manifests name the compose host; local planes listen elsewhere.
+ config["url"] = rewrite_builtin_mcp_host(config["url"])
+ config.update(source_plugin=inst.key, owner_user_id=user_id, origin="local_plugin", execution_scope="local")
executor = definition.get("manager_executor")
if executor:
from core.capabilities.plugins.management.contract import tools, VERSION
diff --git a/src/backend/features/desktop/capabilities/mcp_json.py b/src/backend/features/desktop/capabilities/mcp_json.py
index 361ad8f9..721af3f2 100644
--- a/src/backend/features/desktop/capabilities/mcp_json.py
+++ b/src/backend/features/desktop/capabilities/mcp_json.py
@@ -343,7 +343,6 @@ def local_server_configs(doc: Optional[McpJson] = None) -> Dict[str, dict]:
continue
cfg: Dict[str, Any] = {
"transport": entry["transport"],
- "is_stable": False,
"source_plugin": None,
"owner_user_id": None,
"origin": "mcp_json_local",
diff --git a/src/backend/features/desktop/capability/mcp.py b/src/backend/features/desktop/capability/mcp.py
index 4180e4fb..27ba9cbc 100644
--- a/src/backend/features/desktop/capability/mcp.py
+++ b/src/backend/features/desktop/capability/mcp.py
@@ -147,7 +147,7 @@ async def invoke_gateway_tool(
embedded.interface().execute, resolved["user_id"], resolved["tool"]["name"], arguments,
)
import mcp.types
- from core.llm.mcp.pool import make_client
+ from core.llm.mcp.client import make_client
arguments = dict(arguments or {})
from core.harness.registry.tool_operations import receipt_operations
@@ -189,15 +189,13 @@ async def invoke_gateway_tool(
))
target["headers"] = upstream_headers
- client = make_client(str(resolved["server_id"]), target, is_stateful=False)
+ from core.llm.mcp.results import tool_result
+ from core.llm.mcp.client import _execution_timeout
raw_tool = mcp.types.Tool.model_validate(resolved["tool"])
- # Skip a second tools/list call in the cloud: the allowlisted schema was read
- # from the same DB row immediately above. get_tool then performs only the
- # real initialize + tools/call lifecycle against the private MCP target.
- client._cached_tools = [raw_tool] # noqa: SLF001 - AgentScope has no public preload API
- tool = await client.get_tool(raw_tool.name)
- timeout = max(1.0, float(client.execution_timeout or 120.0)) + 10.0
- chunk = await asyncio.wait_for(tool(**dict(arguments or {})), timeout=timeout)
+ async with make_client(str(resolved["server_id"]), target) as client:
+ result = await asyncio.wait_for(client.call_tool(raw_tool.name, dict(arguments or {})),
+ timeout=_execution_timeout(str(resolved["server_id"]), target))
+ chunk = tool_result(result, raw_tool)
chunk.metadata.setdefault("origin", "cloud")
chunk.metadata.setdefault("mcp_server_id", str(resolved["server_id"]))
return guard_capability_content(
diff --git a/src/backend/features/desktop/capability/workcopies.py b/src/backend/features/desktop/capability/workcopies.py
index c21f2998..ce621fea 100644
--- a/src/backend/features/desktop/capability/workcopies.py
+++ b/src/backend/features/desktop/capability/workcopies.py
@@ -223,7 +223,6 @@ def _activate(db, user, kind, key, files, row):
owner_user_id=str(user),
created_by=str(user),
display_name=spec.get("display_name") or key,
- is_stable=False,
)
db.add(row)
for field in ("display_name", "description", "transport", "url"):
diff --git a/src/backend/features/desktop/local/plugins/assets.py b/src/backend/features/desktop/local/plugins/assets.py
index 8669a002..4a19a6ae 100644
--- a/src/backend/features/desktop/local/plugins/assets.py
+++ b/src/backend/features/desktop/local/plugins/assets.py
@@ -21,9 +21,9 @@
def _bundled_site_template() -> Optional[Path]:
- """安装包里的 React 站点模板(``docker/site-template/``)。"""
+ """安装包里的 React 站点模板(``docker/sandbox/site-template/``)。"""
# src/backend/features/desktop/local/plugins/assets.py → parents[6] = 仓库根
- candidate = Path(__file__).resolve().parents[6] / "docker" / "site-template"
+ candidate = Path(__file__).resolve().parents[6] / "docker" / "sandbox" / "site-template"
return candidate if (candidate / "init-react-site.sh").is_file() else None
@@ -42,7 +42,7 @@ def provision_site_template() -> bool:
"""
source = _bundled_site_template()
if source is None:
- logger.warning("[device-assets] 安装包内未找到站点模板(docker/site-template/)")
+ logger.warning("[device-assets] 安装包内未找到站点模板(docker/sandbox/site-template/)")
return False
home = site_template_home()
try:
diff --git a/src/backend/features/desktop/run_capabilities/provider.py b/src/backend/features/desktop/run_capabilities/provider.py
index 04151819..7a40e13d 100644
--- a/src/backend/features/desktop/run_capabilities/provider.py
+++ b/src/backend/features/desktop/run_capabilities/provider.py
@@ -44,11 +44,10 @@ def preflight(self, **context):
from features.desktop.capabilities.runtime import preflight
return preflight(self, **context)
- def wrap_mcp_clients(self, clients, *, scope_id):
+ def tool_guard(self, *, scope_id):
from features.desktop.capabilities.runtime import get
- from core.llm.mcp.capability_guards import AvailableMCPClient
run = get(self.run_id, scope_id=scope_id) or self
- return [AvailableMCPClient(client, run) for client in clients]
+ return run.validate_tool_call
def validate_selected_skills(self, skill_ids):
from core.harness.capability_errors import PackageMissing
diff --git a/src/backend/features/evaluation/runtime/process_tools.py b/src/backend/features/evaluation/runtime/process_tools.py
index 24c43455..2f0b7adf 100644
--- a/src/backend/features/evaluation/runtime/process_tools.py
+++ b/src/backend/features/evaluation/runtime/process_tools.py
@@ -1,4 +1,5 @@
"""Process tools owned by an evaluation lease, without ordinary-chat state."""
+from core.llm.factory.tools.definitions import function_tool
from core.llm.tools._tool_helpers import resp_json as _resp_json
def register_evaluation_process_tools(toolkit, scope):
@@ -27,5 +28,5 @@ async def write_stdin(session_id: str, chars: str = "",
chars=chars, yield_time_ms=yield_time_ms)
return _resp_json(result)
- toolkit.register_tool_function(write_stdin, namesake_strategy="override")
- toolkit.register_tool_function(Bash, namesake_strategy="override")
+ toolkit.append(function_tool(write_stdin))
+ toolkit.append(function_tool(Bash))
diff --git a/src/backend/features/evaluation/runtime/tools.py b/src/backend/features/evaluation/runtime/tools.py
index 6a33b53a..6620b8fa 100644
--- a/src/backend/features/evaluation/runtime/tools.py
+++ b/src/backend/features/evaluation/runtime/tools.py
@@ -16,36 +16,31 @@
}
-class _EvaluationCollector:
- def __init__(self, target, scope):
- self.target = target
- self.scope = scope
+def scoped_tool(tool, scope):
+ from agentscope.tool import FunctionTool
+ from core.llm.factory.tools.definitions import function_tool
+ func = tool._func
+ original_signature = signature(func)
- def register_tool_function(self, func, **options):
- original_signature = signature(func)
+ @wraps(func)
+ async def scoped(*args, **kwargs):
+ from core.config.local_mode import local_mode_enabled
+ from core.llm.tools._common import resp_json
+ if local_mode_enabled():
+ return resp_json({"error": "Evaluation requires container execution, not local mode"})
+ bound = original_signature.bind(*args, **kwargs)
+ if bound.arguments.get("register_as_artifact"):
+ return resp_json({"error": "Evaluation outputs must remain in the task sandbox"})
+ token = CURRENT_EVALUATION_SCOPE.set(scope)
+ try:
+ return await func(*args, **kwargs)
+ finally:
+ CURRENT_EVALUATION_SCOPE.reset(token)
- @wraps(func)
- async def scoped(*args, **kwargs):
- from core.config.local_mode import local_mode_enabled
- from core.llm.tools._common import resp_json
- if local_mode_enabled():
- return resp_json({"error": "Evaluation requires container execution, not local mode"})
- bound = original_signature.bind(*args, **kwargs)
- if bound.arguments.get("register_as_artifact"):
- return resp_json({"error": "Evaluation outputs must remain in the task sandbox"})
- token = CURRENT_EVALUATION_SCOPE.set(self.scope)
- try:
- return await func(*args, **kwargs)
- finally:
- CURRENT_EVALUATION_SCOPE.reset(token)
-
- # Tool schemas retain the native parameters and behavioral contracts.
- scoped.__doc__ = (
- _DESCRIPTIONS.get(func.__name__, "") + " "
- f"Native {func.__name__} in the current evaluation sandbox only. "
- "No account workspace, historical artifacts, or host filesystem access."
- )
- self.target.register_tool_function(scoped, **options)
+ scoped.__doc__ = (_DESCRIPTIONS.get(tool.name, "") + " "
+ + f"Native {tool.name} in the current evaluation sandbox only. "
+ + "No account workspace, historical artifacts, or host filesystem access.")
+ return function_tool(scoped, dispatch_spec=tool.dispatch_spec)
def register_evaluation_tools(toolkit, scope, *, read_only=False, allow_bash=True, vision_mode="none"):
@@ -56,7 +51,7 @@ def register_evaluation_tools(toolkit, scope, *, read_only=False, allow_bash=Tru
)
if local_mode_enabled():
raise ValueError("Evaluation requires container execution, not local mode")
- collector = _EvaluationCollector(toolkit, scope)
+ collector = []
common = dict(
chat_id=scope.session_id, sandbox_session_id=scope.session_id,
user_id=scope.owner_user_id,
@@ -75,3 +70,5 @@ def register_evaluation_tools(toolkit, scope, *, read_only=False, allow_bash=Tru
register_edit(collector, **common, state=state, interactive=False)
finally:
CURRENT_EVALUATION_SCOPE.reset(token)
+
+ toolkit.extend(scoped_tool(tool, scope) for tool in collector)
diff --git a/src/backend/features/evaluation/sandboxes.py b/src/backend/features/evaluation/sandboxes.py
index d60796c9..adc07545 100644
--- a/src/backend/features/evaluation/sandboxes.py
+++ b/src/backend/features/evaluation/sandboxes.py
@@ -7,6 +7,7 @@
from core.config.settings import settings
from core.sandbox.errors import SandboxError
+from core.db.threads import run_in_session
MAX_TRANSFER = 64 * 1024 * 1024
MAX_OUTPUT = 1024 * 1024
@@ -84,7 +85,7 @@ async def create(owner, *, image, attempt_id="", ttl_seconds=7200, cpu_count=2,
resource={"cpu": str(cpu_count), "memory": f"{memory_mb}Mi"},
entrypoint=["bash", "-lc", "exec sleep infinity"],
metadata={"hugagent-evaluation": "true", "attempt-id": _safe_attempt_id(attempt_id)},
- volumes=None, env=None, skip_health_check=True,
+ volumes=None, env=None, skip_health_check=False,
)
try:
# All native tools use /workspace; Ageval uses /attempt/workspace.
@@ -167,18 +168,19 @@ async def download(lease_id, owner, path):
async def freeze(lease_id, owner):
import features.evaluation.sandbox_provider.binding as bindings
- from core.db.engine import SessionLocal
from core.db.models import ChatRun
from core.harness.registry.run_cancellation import cancel_run
from features.evaluation.sandbox_provider.provider import get_evaluation_provider
sid = session_id(lease_id)
await bindings.assert_owner(sid, owner)
await bindings.begin_freeze(sid, owner)
- with SessionLocal() as db:
+ def _active_runs(db):
runs = db.query(ChatRun.run_id).filter(
ChatRun.chat_id == sid, ChatRun.user_id == owner,
ChatRun.status.in_(("pending", "running", "needs_attention")),
).all()
+ return runs
+ runs = await run_in_session(_active_runs)
for (run_id,) in runs:
await cancel_run(run_id, user_id=owner)
await get_evaluation_provider().freeze(sid, owner)
diff --git a/src/backend/features/hosted_apps/api/application_transports.py b/src/backend/features/hosted_apps/api/application_transports.py
index 48d8c6af..08425906 100644
--- a/src/backend/features/hosted_apps/api/application_transports.py
+++ b/src/backend/features/hosted_apps/api/application_transports.py
@@ -2,16 +2,16 @@
import json
-from core.db.engine import get_db
from core.infra.responses import success_response
from features.hosted_apps.data import ApplicationDataService
from features.hosted_apps.operations import perform_operation
from features.hosted_apps.schema import InternalMCPPublish, InternalOperation, RecordBatch
-from fastapi import APIRouter, Depends, Header, HTTPException, Request
+from fastapi import APIRouter, Header, HTTPException, Request
from sqlalchemy import select
-from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
+from core.db.threads import run_in_session
+
internal_router = APIRouter(prefix="/v1/internal/applications", tags=["internal-applications"])
public_router = APIRouter(prefix="/site", tags=["application-data"])
mcp_router = APIRouter(prefix="/applications-mcp", tags=["hosted-mcp"])
@@ -21,7 +21,6 @@
async def internal_operation(
body: InternalOperation,
x_internal_token: str | None = Header(None),
- db: Session = Depends(get_db),
):
from features.sites.api.internal_site_auth import _check_internal_token
@@ -42,7 +41,7 @@ async def internal_operation(
chat_id=body.chat_id,
)
)
- result = await run_in_threadpool(perform_operation, body, db)
+ result = await run_in_session(lambda db: perform_operation(body, db))
return success_response(data=result)
@@ -50,7 +49,6 @@ async def internal_operation(
async def internal_publish_mcp(
body: InternalMCPPublish,
x_internal_token: str | None = Header(None),
- db: Session = Depends(get_db),
):
from features.sites.api.internal_site_auth import _check_internal_token
from core.harness.registry.host_runtime import requires_remote_execution as bridge_enabled
@@ -69,27 +67,27 @@ async def internal_publish_mcp(
from features.hosted_apps.projects import ApplicationProjectService
from features.hosted_apps.publication import ApplicationPublicationService
- publication = ApplicationPublicationService(db)
- result = await run_in_threadpool(
- publication.publish,
- body.app_id,
- body.user_id,
- body.tools,
- install_personal=True,
- chat_id=body.chat_id,
+ result = await run_in_session(
+ lambda db: ApplicationPublicationService(db).publish(
+ body.app_id,
+ body.user_id,
+ body.tools,
+ install_personal=True,
+ chat_id=body.chat_id,
+ )
)
return success_response(data=result)
@public_router.post("/{slug}/__api/data/{table}")
-async def visitor_insert(slug: str, table: str, request: Request, db: Session = Depends(get_db)):
+async def visitor_insert(slug: str, table: str, request: Request):
from features.sites.api.sites_serve import _api_json, _client_ip, _load_authorized_site
from features.hosted_apps.store import applications
from features.sites.rate_limit import count_attempt
await count_attempt(_client_ip(request), slug)
- site, gate = await _load_authorized_site(slug, request, db, require_unlock=True)
+ site, gate = await _load_authorized_site(slug, request, require_unlock=True)
if gate is not None:
return _api_json(
{"detail": "Site access authorization is required"},
@@ -130,14 +128,12 @@ def submit():
@public_router.api_route("/{slug}/__api/data/{table}", methods=["GET", "PUT"])
-async def visitor_collection(
- slug: str, table: str, request: Request, db: Session = Depends(get_db)
-):
+async def visitor_collection(slug: str, table: str, request: Request):
from features.sites.api.sites_serve import _api_json, _client_ip, _load_authorized_site
from features.hosted_apps.collections import read_collection, replace_collection
from features.hosted_apps.store import applications
- site, gate = await _load_authorized_site(slug, request, db, require_unlock=True)
+ site, gate = await _load_authorized_site(slug, request, require_unlock=True)
if gate is not None:
return _api_json(
{"detail": "Site access authorization is required"},
diff --git a/src/backend/features/hosted_apps/api/applications.py b/src/backend/features/hosted_apps/api/applications.py
index 2621e8d4..0ed5455f 100644
--- a/src/backend/features/hosted_apps/api/applications.py
+++ b/src/backend/features/hosted_apps/api/applications.py
@@ -14,7 +14,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy.orm import Session
-from starlette.concurrency import run_in_threadpool
router = APIRouter(prefix="/v1/applications", tags=["Applications"])
@@ -46,7 +45,7 @@ def detail(app_id: str, user: UserContext = Depends(get_current_user)):
@router.post("/{app_id}/editor")
-async def open_editor(
+def open_editor(
app_id: str,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -55,7 +54,7 @@ async def open_editor(
service = ApplicationDataService()
projects = ApplicationProjectService(db, service.engine)
- return success_response(data=await run_in_threadpool(projects.editor, app_id, user.user_id))
+ return success_response(data=projects.editor(app_id, user.user_id))
@router.post("/{app_id}/tables")
@@ -133,7 +132,7 @@ def export_records(
@router.post("/{app_id}/mcp")
-async def publish_mcp(
+def publish_mcp(
app_id: str,
body: MCPPublish,
user: UserContext = Depends(get_current_user),
@@ -142,12 +141,12 @@ async def publish_mcp(
from features.hosted_apps.projects import ApplicationProjectService
publication = ApplicationPublicationService(db)
- receipt = await run_in_threadpool(publication.publish, app_id, user.user_id, body.tools)
+ receipt = publication.publish(app_id, user.user_id, body.tools)
return success_response(data=receipt)
@router.post("/{app_id}/mcp/project")
-async def publish_project_mcp(
+def publish_project_mcp(
app_id: str,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -155,7 +154,7 @@ async def publish_project_mcp(
from features.hosted_apps.projects import ApplicationProjectService
publication = ApplicationPublicationService(db)
- receipt = await run_in_threadpool(publication.publish, app_id, user.user_id, from_project=True)
+ receipt = publication.publish(app_id, user.user_id, from_project=True)
return success_response(data=receipt)
@@ -167,7 +166,7 @@ def revoke_mcp(
@router.post("/{app_id}/mcp/rollback")
-async def rollback_mcp(
+def rollback_mcp(
app_id: str,
body: MCPRollback,
user: UserContext = Depends(get_current_user),
@@ -176,7 +175,7 @@ async def rollback_mcp(
from features.hosted_apps.projects import ApplicationProjectService
publication = ApplicationPublicationService(db)
- receipt = await run_in_threadpool(publication.rollback, app_id, user.user_id, body.version)
+ receipt = publication.rollback(app_id, user.user_id, body.version)
return success_response(data=receipt)
@router.delete("/{app_id}/tables/{table}/records/{record_id}")
diff --git a/src/backend/features/hosted_apps/mcp.py b/src/backend/features/hosted_apps/mcp.py
index 781272ba..a87e7ab6 100644
--- a/src/backend/features/hosted_apps/mcp.py
+++ b/src/backend/features/hosted_apps/mcp.py
@@ -13,7 +13,8 @@
from features.hosted_apps.schema import ToolDefinition
from features.hosted_apps.store import applications
from fastapi import HTTPException
-from mcp.server.fastmcp import FastMCP
+from mcp.server.mcpserver import MCPServer
+from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
from pydantic import Field
from sqlalchemy import select
@@ -58,7 +59,7 @@ def query(**arguments: Any) -> dict:
offset=arguments.get("offset", 0),
)
- # FastMCP needs an explicit typed signature rather than opaque **kwargs.
+ # MCPServer needs an explicit typed signature rather than opaque **kwargs.
from features.hosted_apps.relational import table_definition
fields = {c.name: c for c in table_definition(app, definition.table).columns}
@@ -88,6 +89,20 @@ async def invoke(**arguments) -> dict[str, Any]:
return invoke
+class QueryMCPServer(MCPServer):
+ async def call_tool(self, name, arguments, context=None):
+ import jsonschema
+
+ schemas = {tool.name: tool.input_schema for tool in await self.list_tools()}
+ if name in schemas:
+ schema = {**schemas[name], "additionalProperties": False}
+ try:
+ jsonschema.validate(arguments, schema)
+ except jsonschema.ValidationError:
+ raise ToolError("Invalid query arguments") from None
+ return await super().call_tool(name, arguments, context)
+
+
class ApplicationMCPGateway:
"""Each HTTP call reloads persisted authorization, so revocation works across workers."""
@@ -112,7 +127,7 @@ async def __call__(self, scope, receive, send):
{"detail": error.detail}, error.status_code, headers={"WWW-Authenticate": "Bearer"}
)(scope, receive, send)
return
- server = FastMCP(app["title"], host="0.0.0.0", stateless_http=True, json_response=True)
+ server = QueryMCPServer(app["title"])
for raw in app["tools"]:
definition = ToolDefinition.model_validate(raw)
server.add_tool(
@@ -126,10 +141,7 @@ async def __call__(self, scope, receive, send):
openWorldHint=False,
),
)
- tool = server._tool_manager.get_tool(definition.name)
- tool.fn_metadata.arg_model.model_config["extra"] = "forbid"
- tool.fn_metadata.arg_model.model_rebuild(force=True)
- transport = server.streamable_http_app()
+ transport = server.streamable_http_app(host="0.0.0.0", stateless_http=True, json_response=True)
inner_scope = {**scope, "path": "/mcp", "raw_path": b"/mcp", "root_path": ""}
async with server.session_manager.run():
await transport(inner_scope, receive, send)
diff --git a/src/backend/features/hosted_apps/publication.py b/src/backend/features/hosted_apps/publication.py
index 8278dfb0..6a003beb 100644
--- a/src/backend/features/hosted_apps/publication.py
+++ b/src/backend/features/hosted_apps/publication.py
@@ -140,7 +140,6 @@ def publish(
owner_user_id=owner,
created_by=owner,
created_at=utc_now(),
- is_stable=False,
)
self.db.add(row)
row.display_name = app["title"]
@@ -162,7 +161,7 @@ def publish(
self.db.commit()
management.refresh_mcp_caches()
ok, _ = asyncio.run(
- management.probe_mcp_connectivity(row, self.db, timeout_seconds=15)
+ management.probe_mcp_connectivity(row, validate=True, timeout_seconds=15)
)
if ok:
row.is_enabled = enable
diff --git a/src/backend/features/hosted_apps/resources/admission.py b/src/backend/features/hosted_apps/resources/admission.py
index 650ae884..3d82d2a5 100644
--- a/src/backend/features/hosted_apps/resources/admission.py
+++ b/src/backend/features/hosted_apps/resources/admission.py
@@ -1,6 +1,5 @@
"""Application resource admission uses the existing worker, quota and janitor."""
-import asyncio
import hashlib
import json
import time
@@ -10,56 +9,51 @@
from core.capabilities.plugins.resources import service, store
from core.capabilities.plugins.resources.owners import application_binding
from core.capabilities.plugins.resources.reservation import lock_owner
-
-
-async def reserve(db, selected, module_id, user_id, binding):
- deadline = time.monotonic() + 95
- while True:
+from core.db.threads import run_in_session
+
+
+def reserve(db, selected, module_id, user_id, binding):
+ """One idempotent admission attempt under the owner lock (see ``service._admit``)."""
+ lock_owner(db, user_id)
+ row = db.get(store.PluginResource, binding["resource_id"])
+ if row is None:
+ return "reserve", None # caller retains the quota/admission lock
+ if (
+ row.user_id != user_id
+ or row.request_digest != binding["request_digest"]
+ or row.application_key != binding["application_key"]
+ or row.instance_id != binding["instance_id"]
+ or row.install_id != selected.install_id
+ or row.revision != selected.revision
+ or row.module_id != module_id
+ ):
+ db.rollback()
+ raise HTTPException(409, "idempotency_conflict")
+ if row.status == "active":
+ service.authorized(db, row.resource_id, user_id)
db.commit()
- lock_owner(db, user_id)
- row = db.get(store.PluginResource, binding["resource_id"])
- if row is None:
- return None, False # caller retains the quota/admission lock
- if (
- row.user_id != user_id
- or row.request_digest != binding["request_digest"]
- or row.application_key != binding["application_key"]
- or row.instance_id != binding["instance_id"]
- or row.install_id != selected.install_id
- or row.revision != selected.revision
- or row.module_id != module_id
- ):
- db.rollback()
- raise HTTPException(409, "idempotency_conflict")
- if row.status == "active":
- service.authorized(db, row.resource_id, user_id)
- db.commit()
- return row, True
- if row.status != "starting" or row.expires_at <= time.time():
- db.commit()
- raise HTTPException(410, "resource_closed_no_replay")
+ return "adopted", row
+ if row.status != "starting" or row.expires_at <= time.time():
db.commit()
- if time.monotonic() >= deadline:
- raise HTTPException(503, "resource_start_pending")
- await asyncio.sleep(0.05)
- db.expire_all()
- application_binding(
- db,
- user_id,
- binding["application_key"],
- selected.install_id,
- selected.revision,
- binding["scope"],
- module_id,
- )
+ raise HTTPException(410, "resource_closed_no_replay")
+ db.commit()
+ return "wait", None
+
+
+def _record_quota_denied(db, user_id, item, instance_id):
+ from core.harness.registry.resource_owners import resource_owner
+
+ resource_owner().record(db, user_id, "resource", "quota_denied", item=item, instance_id=instance_id)
async def create(
- db, user_id, key, instance_id, install_id, revision, execution_scope, module_id, idempotency_key
+ user_id, key, instance_id, install_id, revision, execution_scope, module_id, idempotency_key
):
if not idempotency_key or len(idempotency_key) > 128:
raise HTTPException(400, "idempotency_key_required")
- item = application_binding(db, user_id, key, install_id, revision, execution_scope, module_id)
+ item = await run_in_session(
+ application_binding, user_id, key, install_id, revision, execution_scope, module_id
+ )
payload = [key, instance_id, install_id, revision, execution_scope, module_id]
digest = hashlib.sha256(json.dumps(payload).encode()).hexdigest()
resource_id = hashlib.sha256(
@@ -67,7 +61,6 @@ async def create(
).hexdigest()
try:
return await service.create(
- db,
module_id=module_id,
user_id=user_id,
chat_id=None,
@@ -83,12 +76,9 @@ async def create(
},
)
except HTTPException as error:
- db.rollback()
if error.status_code == 429:
- from core.harness.registry.resource_owners import resource_owner
-
try:
- resource_owner().record(db, user_id, "resource", "quota_denied", item=item, instance_id=instance_id)
+ await run_in_session(_record_quota_denied, user_id, item, instance_id)
except Exception:
pass # The original quota denial remains authoritative if telemetry storage fails.
raise
@@ -104,13 +94,13 @@ def owned(db, user_id, key, instance_id, resource_id, *, allow_closed=False):
return service.authorized(db, resource_id, user_id)
-async def renew(db, user_id, key, instance_id, resource_id):
- row = owned(db, user_id, key, instance_id, resource_id)
+def _extend_lease(db, user_id, key, instance_id, resource_id):
+ """Extend a foreground lease; returns True when the background lease owns it instead."""
from core.harness.registry.resource_owners import resource_owner
+ row = owned(db, user_id, key, instance_id, resource_id)
if resource_owner().authorize(db, row):
- await resource_owner().renew(db, user_id, key, instance_id, resource_id)
- return store.public(row)
+ return True
updated = (
db.query(store.PluginResource)
.filter_by(resource_id=row.resource_id, status="active")
@@ -120,16 +110,25 @@ async def renew(db, user_id, key, instance_id, resource_id):
db.commit()
if updated != 1:
raise HTTPException(410, "resource_expired")
- db.refresh(row)
+ return False
+
+
+def _public(db, user_id, resource_id):
+ return store.public(store.get(db, resource_id, user_id))
+
+
+async def renew(user_id, key, instance_id, resource_id):
from core.capabilities.plugins.resources.deadlines import arm
+ from core.harness.registry.resource_owners import resource_owner
- await arm(db, row)
- return store.public(row)
+ if await run_in_session(_extend_lease, user_id, key, instance_id, resource_id):
+ await resource_owner().renew(user_id, key, instance_id, resource_id)
+ else:
+ await arm(resource_id)
+ return await run_in_session(_public, user_id, resource_id)
-async def close_application_resources(
- db, user_id, *, application_key=None, instance_id=None, install_id=None
-):
+def _application_rows(db, user_id, application_key, instance_id, install_id):
query = db.query(store.PluginResource).filter(
store.PluginResource.user_id == user_id,
store.PluginResource.application_key.is_not(None),
@@ -142,11 +141,19 @@ async def close_application_resources(
):
if value is not None:
query = query.filter(getattr(store.PluginResource, name) == value)
+ rows = query.all()
+ db.expunge_all()
+ return rows
+
+
+async def close_application_resources(
+ user_id, *, application_key=None, instance_id=None, install_id=None
+):
+ rows = await run_in_session(_application_rows, user_id, application_key, instance_id, install_id)
failures = []
- for row in query.all():
+ for row in rows:
try:
- await service.close(db, row)
+ await service.close(row)
except Exception:
- db.rollback()
failures.append(row.resource_id)
return {"closed": not failures, "retry_resource_ids": failures}
diff --git a/src/backend/features/hosted_apps/resources/cleanup.py b/src/backend/features/hosted_apps/resources/cleanup.py
index ab68a622..50912f0a 100644
--- a/src/backend/features/hosted_apps/resources/cleanup.py
+++ b/src/backend/features/hosted_apps/resources/cleanup.py
@@ -6,32 +6,57 @@
from core.capabilities.plugins.resources import store
from core.capabilities.plugins.resources.owners import runtime_session
+from core.db.threads import run_in_session
-async def close(db, row):
- from core.capabilities.plugins.resources.service import request
+def _begin(db, resource_id):
+ """Revoke the worker's tickets; return a detached row and its target, or None."""
from core.harness.registry.resource_owners import resource_owner
- if row.status == "closed":
- return
+ row = db.get(store.PluginResource, resource_id)
+ if row is None or row.status == "closed":
+ return None
row.status = "closing"
- db.query(store.PluginResourceTicket).filter_by(resource_id=row.resource_id).delete()
- db.query(store.PluginResourceAssetTicket).filter_by(resource_id=row.resource_id).delete()
+ db.query(store.PluginResourceTicket).filter_by(resource_id=resource_id).delete()
+ db.query(store.PluginResourceAssetTicket).filter_by(resource_id=resource_id).delete()
db.commit()
try:
target = store.descriptor(row)
except HTTPException:
resource_owner().observe(db, row, "release", "release_failed")
raise HTTPException(503, "resource_cleanup_credentials_unavailable") from None
+ db.expunge(row)
+ return row, target
+
+
+def _finish(db, resource_id, outcome):
+ from core.harness.registry.resource_owners import resource_owner
+
+ row = db.get(store.PluginResource, resource_id)
+ if row is None:
+ return
+ if outcome == "ok":
+ row.status = "closed"
+ db.commit()
+ resource_owner().observe(db, row, "release", outcome)
+
+
+async def close(row):
+ """Stop an application worker. Database steps and network waits never overlap."""
+ from core.capabilities.plugins.resources.service import request
+ from core.sandbox.factory import get_sandbox_provider
+
+ begun = await run_in_session(_begin, row.resource_id)
+ if begun is None:
+ return
+ row, target = begun
if target.get("process_id"):
try:
await asyncio.wait_for(request(row, "/shutdown", {}), 5)
except (HTTPException, TimeoutError):
- from core.sandbox.factory import get_sandbox_provider
-
provider = get_sandbox_provider()
if target.get("provider") != provider.name:
- resource_owner().observe(db, row, "release", "release_failed")
+ await run_in_session(_finish, row.resource_id, "release_failed")
raise HTTPException(503, "resource_cleanup_scope_unavailable")
result = await provider.write_stdin(
target["process_id"],
@@ -41,8 +66,6 @@ async def close(db, row):
yield_time_ms=1000,
)
if not result or result.get("exit_code") is None:
- resource_owner().observe(db, row, "release", "release_failed")
+ await run_in_session(_finish, row.resource_id, "release_failed")
raise HTTPException(503, "resource_cleanup_pending")
- row.status = "closed"
- db.commit()
- resource_owner().observe(db, row, "release", "ok")
+ await run_in_session(_finish, row.resource_id, "ok")
diff --git a/src/backend/features/knowledge/api/kb_documents.py b/src/backend/features/knowledge/api/kb_documents.py
index 7bbbe89c..3711db87 100644
--- a/src/backend/features/knowledge/api/kb_documents.py
+++ b/src/backend/features/knowledge/api/kb_documents.py
@@ -15,7 +15,6 @@
from features.knowledge.service import KBService
from core.files.storage import generate_storage_key, get_storage
from fastapi import Depends, File, Form, Path, Query, UploadFile, status
-from fastapi.concurrency import run_in_threadpool
from sqlalchemy.orm import Session
import logging
logger = logging.getLogger(__name__)
@@ -23,7 +22,7 @@
from features.knowledge.api import kb_state
@router.post("/{kb_id}/documents", status_code=status.HTTP_201_CREATED, summary="上传文档到知识库")
-async def upload_document(
+def upload_document(
kb_id: str = Path(..., description="KB space ID"),
file: UploadFile = File(..., description="Document file to upload"),
title: Optional[str] = Form(None, description="Document title (defaults to filename)"),
@@ -48,7 +47,7 @@ async def upload_document(
if not file.filename:
raise BadRequestError(message="Filename is required", data={"field": "file"})
- content = await file.read()
+ content = file.file.read()
file_size = len(content)
if file_size > kb_state.MAX_FILE_SIZE:
@@ -68,7 +67,7 @@ async def upload_document(
storage = get_storage()
# 对象存储是阻塞的网络调用,直接在 async handler 里调会卡住整个事件循环——
# 批量上传时后端会连 SSE 和其它接口一起停摆。
- await run_in_threadpool(storage.upload_bytes, content, storage_key)
+ storage.upload_bytes(content, storage_key)
logger.info("Document uploaded to storage: %s", storage_key)
except StorageError as e:
logger.error("Failed to upload document to storage: %s", e)
diff --git a/src/backend/features/marketplace/api/mcp_marketplace.py b/src/backend/features/marketplace/api/mcp_marketplace.py
index aab79bc4..efeca18f 100644
--- a/src/backend/features/marketplace/api/mcp_marketplace.py
+++ b/src/backend/features/marketplace/api/mcp_marketplace.py
@@ -12,6 +12,7 @@
from core.auth.backend import UserContext, get_current_user
from core.auth.capabilities import resolve_user_capabilities
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.exceptions import AccessDeniedError
from core.infra.responses import created_response, success_response
@@ -82,19 +83,16 @@ async def start_oauth(
body: OAuthStartRequest,
request: Request,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
user_id = str(user.user_id)
- _require_can_add_mcp(user_id, db)
- mcp_listing.get_market_item(
- db,
- body.slug,
- owner_user_id=user_id,
- viewer_user_id=user_id,
- )
+
+ def admit(db: Session) -> None:
+ _require_can_add_mcp(user_id, db)
+ mcp_listing.get_market_item(db, body.slug, owner_user_id=user_id, viewer_user_id=user_id)
+
+ await run_in_session(admit)
return success_response(
data=await oauth_module.start_oauth_install(
- db,
slug=body.slug,
owner_user_id=user_id,
installed_by=user_id,
@@ -140,6 +138,7 @@ async def oauth_callback(
flow_id: str = Query(...),
code: str = Query(""),
state: Optional[str] = Query(None),
+ iss: Optional[str] = Query(None),
error: str = Query(""),
error_description: str = Query(""),
):
@@ -147,6 +146,7 @@ async def oauth_callback(
flow_id,
code=code,
state=state,
+ iss=iss,
error=error_description or error,
)
return HTMLResponse(
@@ -160,12 +160,10 @@ async def oauth_callback(
async def install_item(
body: InstallRequest,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
user_id = str(user.user_id)
- _require_can_add_mcp(user_id, db)
+ await run_in_session(lambda db: _require_can_add_mcp(user_id, db))
result = await mcp_installation.install_market_item(
- db,
body.slug,
owner_user_id=user_id,
installed_by=user_id,
@@ -188,12 +186,10 @@ class SubmissionRequest(BaseModel):
async def submit_item(
body: SubmissionRequest,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
user_id = str(user.user_id)
- _require_can_add_mcp(user_id, db)
+ await run_in_session(lambda db: _require_can_add_mcp(user_id, db))
result = await mcp_submissions.submit_to_marketplace(
- db,
body.source_server_id,
owner_user_id=user_id,
submitter_name=user.username or "",
diff --git a/src/backend/features/marketplace/core_catalog.py b/src/backend/features/marketplace/core_catalog.py
index 77bab062..7bca80f8 100644
--- a/src/backend/features/marketplace/core_catalog.py
+++ b/src/backend/features/marketplace/core_catalog.py
@@ -103,6 +103,6 @@ def installation_secrets(self, version, credentials, existing, *, auth_method):
from features.marketplace import mcp_credentials
return mcp_credentials.installation_secrets(version, credentials, existing, auth_method=auth_method)
- async def install_market_item(self, db, slug, **kwargs):
+ async def install_market_item(self, slug, **kwargs):
from features.marketplace import mcp_installation
- return await mcp_installation.install_market_item(db, slug, **kwargs)
+ return await mcp_installation.install_market_item(slug, **kwargs)
diff --git a/src/backend/features/marketplace/mcp_installation.py b/src/backend/features/marketplace/mcp_installation.py
index ceb898cb..d280e6d1 100644
--- a/src/backend/features/marketplace/mcp_installation.py
+++ b/src/backend/features/marketplace/mcp_installation.py
@@ -9,20 +9,21 @@
from features.marketplace import edition as ml
from core.capabilities.mcp.management import decrypt_mcp_headers, encrypt_mcp_headers, materialize_mcp_http_connection, probe_mcp_connectivity, refresh_mcp_caches, tool_snapshot_hash, validate_remote_mcp_url
from sqlalchemy.orm import Session
+from core.db.threads import run_in_session
from features.marketplace import mcp_credentials
from features.marketplace import mcp_validation
from features.marketplace import mcp_versions
-async def install_market_item(
+def _plan_installation(
db: Session,
slug: str,
- *,
owner_user_id: Optional[str],
installed_by: Optional[str],
- credentials: Optional[Dict[str, str]] = None,
- auth_method: Optional[str] = None,
- oauth_bundle: Optional[Dict[str, Any]] = None,
+ credentials: Optional[Dict[str, str]],
+ auth_method: Optional[str],
+ oauth_bundle: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
+ """Resolve the version, credentials and target server; returns a transient candidate row."""
item = (
db.query(McpMarketItem)
.filter(
@@ -89,12 +90,6 @@ async def install_market_item(
oauth_bundle=oauth_bundle,
)
runtime_url, _ = materialize_mcp_http_connection(version.url, headers)
- await validate_remote_mcp_url(
- runtime_url,
- allow_private_network=mcp_validation.installs_admin_published_endpoint(item, version, runtime_url),
- require_https=False,
- )
-
now = utc_now()
server_id = existing_server.server_id if existing_server else f"mmcp_{uuid.uuid4().hex[:20]}"
candidate = AdminMcpServer(
@@ -109,7 +104,6 @@ async def install_market_item(
env_vars={},
env_inherit=[],
headers=headers,
- is_stable=False,
is_enabled=True,
sort_order=0,
extra_config={
@@ -136,9 +130,70 @@ async def install_market_item(
updated_at=now,
)
- ok, error = await probe_mcp_connectivity(candidate, db)
+ return {
+ "candidate": candidate,
+ "headers": headers,
+ "runtime_url": runtime_url,
+ "allow_private_network": mcp_validation.installs_admin_published_endpoint(
+ item, version, runtime_url
+ ),
+ "item_id": item.slug,
+ "version_id": version.version_id,
+ "install_id": existing.install_id if existing else None,
+ "existing_server_id": existing_server.server_id if existing_server else None,
+ "now": now,
+ }
+
+
+async def install_market_item(
+ slug: str,
+ *,
+ owner_user_id: Optional[str],
+ installed_by: Optional[str],
+ credentials: Optional[Dict[str, str]] = None,
+ auth_method: Optional[str] = None,
+ oauth_bundle: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """Install a marketplace MCP: plan, probe the live server, then commit.
+
+ The URL check and probe run between two database steps, on a transient
+ candidate row, so nothing is held open while the remote server answers.
+ """
+ plan = await run_in_session(
+ _plan_installation, slug, owner_user_id, installed_by, credentials, auth_method, oauth_bundle
+ )
+ await validate_remote_mcp_url(
+ plan["runtime_url"],
+ allow_private_network=plan["allow_private_network"],
+ require_https=False,
+ )
+ ok, error = await probe_mcp_connectivity(plan["candidate"], validate=True)
if not ok:
raise BadRequestError(message=f"MCP 安装检测失败:{error}")
+ result = await run_in_session(_commit_installation, slug, owner_user_id, installed_by, plan)
+ refresh_mcp_caches()
+ return result
+
+
+def _commit_installation(
+ db: Session,
+ slug: str,
+ owner_user_id: Optional[str],
+ installed_by: Optional[str],
+ plan: Dict[str, Any],
+) -> Dict[str, Any]:
+ from features.marketplace.models import McpMarketVersion
+
+ candidate, headers, now = plan["candidate"], plan["headers"], plan["now"]
+ item = db.query(McpMarketItem).filter(McpMarketItem.slug == plan["item_id"]).one()
+ version = db.get(McpMarketVersion, plan["version_id"])
+ existing = db.get(McpMarketInstallation, plan["install_id"]) if plan["install_id"] else None
+ existing_server = (
+ db.query(AdminMcpServer).filter(AdminMcpServer.server_id == plan["existing_server_id"]).first()
+ if plan["existing_server_id"]
+ else None
+ )
+ server_id = candidate.server_id
discovery_mode = str((version.listing_notice or {}).get("discovery_mode") or "reviewed")
if discovery_mode == "per_install":
candidate.extra_config = {
@@ -165,7 +220,6 @@ async def install_market_item(
"url",
"env_vars",
"env_inherit",
- "is_stable",
"is_enabled",
"sort_order",
"extra_config",
@@ -204,7 +258,6 @@ async def install_market_item(
db.add(existing)
action = "installed"
db.commit()
- refresh_mcp_caches()
return {
"install_id": existing.install_id,
"server_id": server.server_id,
diff --git a/src/backend/features/marketplace/mcp_monitor.py b/src/backend/features/marketplace/mcp_monitor.py
index c5b84938..65579e60 100644
--- a/src/backend/features/marketplace/mcp_monitor.py
+++ b/src/backend/features/marketplace/mcp_monitor.py
@@ -7,7 +7,7 @@
from contextlib import suppress
from core.config.settings import settings
-from core.db.engine import SessionLocal
+from core.db.threads import run_in_session
from features.marketplace.models import McpMarketItem
from features.marketplace.mcp_revalidation import revalidate_market_item
@@ -19,29 +19,30 @@ def _interval_seconds() -> float:
return float(settings.server.mcp_market_revalidate_interval)
+def _monitored_slugs(db) -> list[str]:
+ return [
+ str(row[0])
+ for row in db.query(McpMarketItem.slug)
+ .filter(
+ McpMarketItem.deleted_at.is_(None),
+ McpMarketItem.status.in_(["active", "changed"]),
+ )
+ .all()
+ ]
+
+
async def _run() -> None:
interval = _interval_seconds()
await asyncio.sleep(min(60.0, interval))
while True:
try:
- with SessionLocal() as db:
- slugs = [
- str(row[0])
- for row in db.query(McpMarketItem.slug)
- .filter(
- McpMarketItem.deleted_at.is_(None),
- McpMarketItem.status.in_(["active", "changed"]),
+ for slug in await run_in_session(_monitored_slugs):
+ try:
+ await revalidate_market_item(slug)
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "mcp_market_revalidate_failed", extra={"slug": slug, "error": str(exc)}
)
- .all()
- ]
- for slug in slugs:
- try:
- await revalidate_market_item(db, slug)
- except Exception as exc: # noqa: BLE001
- db.rollback()
- logger.warning(
- "mcp_market_revalidate_failed", extra={"slug": slug, "error": str(exc)}
- )
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
diff --git a/src/backend/features/marketplace/mcp_publishing.py b/src/backend/features/marketplace/mcp_publishing.py
index 22343c37..42ff18fd 100644
--- a/src/backend/features/marketplace/mcp_publishing.py
+++ b/src/backend/features/marketplace/mcp_publishing.py
@@ -2,6 +2,7 @@
from __future__ import annotations
from core.infra.time import utc_now
import uuid
+from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from core.db.models import AdminMcpServer
from features.marketplace.models import McpMarketInstallation, McpMarketItem, McpMarketVersion
@@ -9,24 +10,27 @@
from features.marketplace import edition as ml
from core.capabilities.mcp.management import decrypt_mcp_headers, encrypt_mcp_headers, probe_mcp_connectivity, refresh_mcp_caches, validate_remote_mcp_url
from sqlalchemy.orm import Session
+from core.db.threads import run_in_session
from features.marketplace import mcp_listing
from features.marketplace import mcp_validation
from features.marketplace import mcp_versions
-async def publish_admin_server(
- db: Session,
- source_server_id: str,
- *,
- category: str,
- version: str,
- summary: str = "",
- tags: Optional[List[str]] = None,
- publisher_name: str = "管理员",
- link_as_installation: bool = True,
- auth_schema_override: Optional[List[Dict[str, Any]]] = None,
- auth_config_override: Optional[Dict[str, Any]] = None,
- allow_deferred_discovery: bool = False,
-) -> Dict[str, Any]:
+@dataclass(frozen=True)
+class Publication:
+ """Marketplace listing options for one admin MCP server."""
+
+ category: str
+ version: str
+ summary: str = ""
+ tags: Optional[List[str]] = None
+ publisher_name: str = "管理员"
+ link_as_installation: bool = True
+ auth_schema_override: Optional[List[Dict[str, Any]]] = None
+ auth_config_override: Optional[Dict[str, Any]] = None
+ allow_deferred_discovery: bool = False
+
+
+def _source_server(db: Session, source_server_id: str) -> AdminMcpServer:
row = (
db.query(AdminMcpServer)
.filter(
@@ -37,6 +41,17 @@ async def publish_admin_server(
)
if not row:
raise ResourceNotFoundError("admin_mcp_server", source_server_id)
+ return row
+
+
+def _detached_source(db: Session, source_server_id: str) -> AdminMcpServer:
+ row = _source_server(db, source_server_id)
+ db.expunge(row)
+ return row
+
+
+async def check_publishable(row: AdminMcpServer, publication: Publication) -> List[Dict[str, Any]]:
+ """Validate a server for listing (URL policy + live probe); returns its discovered tools."""
if row.transport not in ("streamable_http", "sse") or not row.url:
raise BadRequestError(message="MCP 市场仅支持远程 HTTP/SSE 服务;stdio 请通过插件市场分发")
if (row.extra_config or {}).get("market_slug"):
@@ -46,12 +61,39 @@ async def publish_admin_server(
allow_private_network=True,
require_https=False,
)
- ok, error = await probe_mcp_connectivity(row, db)
- if not ok and not allow_deferred_discovery:
+ ok, error = await probe_mcp_connectivity(row, validate=True)
+ if not ok and not publication.allow_deferred_discovery:
raise BadRequestError(message=f"MCP 发布检测失败:{error}")
tools = list(row.tools_json or [])
- if not tools and not allow_deferred_discovery:
+ if not tools and not publication.allow_deferred_discovery:
raise BadRequestError(message="MCP 未发现任何工具,不能发布")
+ return tools
+
+
+async def publish_admin_server(source_server_id: str, publication: Publication) -> Dict[str, Any]:
+ row = await run_in_session(_detached_source, source_server_id)
+ tools = await check_publishable(row, publication)
+ return await run_in_session(
+ lambda db: record_publication(db, _source_server(db, source_server_id), tools, publication)
+ )
+
+
+def record_publication(
+ db: Session, row: AdminMcpServer, tools: List[Dict[str, Any]], publication: Publication
+) -> Dict[str, Any]:
+ """List a checked server in the marketplace and commit (``row`` belongs to ``db``)."""
+ category = publication.category
+ version = publication.version
+ summary = publication.summary
+ tags = publication.tags
+ publisher_name = publication.publisher_name
+ link_as_installation = publication.link_as_installation
+ auth_schema_override = publication.auth_schema_override
+ auth_config_override = publication.auth_config_override
+ allow_deferred_discovery = publication.allow_deferred_discovery
+ if tools:
+ row.tools_json = tools
+ source_server_id = row.server_id
existing = (
db.query(McpMarketItem)
.join(McpMarketVersion, McpMarketVersion.version_id == McpMarketItem.latest_version_id)
diff --git a/src/backend/features/marketplace/mcp_revalidation.py b/src/backend/features/marketplace/mcp_revalidation.py
index 2baa21c2..b2b18809 100644
--- a/src/backend/features/marketplace/mcp_revalidation.py
+++ b/src/backend/features/marketplace/mcp_revalidation.py
@@ -1,23 +1,20 @@
"""mcp revalidation responsibilities."""
from __future__ import annotations
from core.infra.time import utc_now
-from typing import Any, Dict
+from typing import Any, Dict, Optional
from core.db.models import AdminMcpServer
from features.marketplace.models import McpMarketItem
from core.infra.exceptions import BadRequestError, ResourceNotFoundError
from core.capabilities.mcp.management import probe_mcp_connectivity, tool_snapshot_hash, validate_remote_mcp_url
+from core.db.threads import run_in_session
from sqlalchemy.orm import Session
from features.marketplace import mcp_administration
from features.marketplace import mcp_listing
from features.marketplace import mcp_validation
from features.marketplace import mcp_versions
-async def revalidate_market_item(
- db: Session,
- slug: str,
- *,
- accept_changes: bool = False,
-) -> Dict[str, Any]:
+
+def _listing(db: Session, slug: str):
item = (
db.query(McpMarketItem)
.filter(McpMarketItem.slug == slug, McpMarketItem.deleted_at.is_(None))
@@ -25,7 +22,21 @@ async def revalidate_market_item(
)
if not item:
raise ResourceNotFoundError("mcp_market_item", slug)
- version = mcp_versions.version_for_item(db, item)
+ return item, mcp_versions.version_for_item(db, item)
+
+
+def _settle(db: Session, item, version, status: str, reason: Optional[str]) -> Dict[str, Any]:
+ item.status = status
+ item.status_reason = reason
+ item.last_verified_at = utc_now()
+ item.updated_at = utc_now()
+ db.commit()
+ return mcp_listing.item_dict(db, item, version, installed=False)
+
+
+def _plan(db: Session, slug: str) -> Dict[str, Any]:
+ """Decide what has to be checked remotely; settles the cases that need no network."""
+ item, version = _listing(db, slug)
if str((version.listing_notice or {}).get("discovery_mode") or "") == "per_install":
# Curated templates have no shared provider credential, so their live
# tools are intentionally discovered with each installer's credential.
@@ -35,20 +46,8 @@ async def revalidate_market_item(
str(field.get("target") or "") == "url" for field in version.auth_schema or []
)
if not has_endpoint_override:
- try:
- await validate_remote_mcp_url(version.url, require_https=False)
- item.status = "active"
- item.status_reason = None
- except BadRequestError as exc:
- item.status = "suspended"
- item.status_reason = f"官方端点安全复检失败:{exc}"
- else:
- item.status = "active"
- item.status_reason = "个人端点将在安装时使用用户凭据验证"
- item.last_verified_at = utc_now()
- item.updated_at = utc_now()
- db.commit()
- return mcp_listing.item_dict(db, item, version, installed=False)
+ return {"kind": "endpoint", "url": version.url}
+ return {"kind": "done", "result": _settle(db, item, version, "active", "个人端点将在安装时使用用户凭据验证")}
source = (
db.query(AdminMcpServer)
.filter(AdminMcpServer.server_id == version.source_server_id)
@@ -62,57 +61,86 @@ async def revalidate_market_item(
source.transport != version.transport
or source_template_url.strip() != version.url.strip()
):
- item.status = "changed"
- item.status_reason = "原始 MCP 的连接地址或传输方式已变化,需发布新版本"
- item.last_verified_at = utc_now()
- item.updated_at = utc_now()
- db.commit()
- return mcp_listing.item_dict(db, item, version, installed=False)
+ return {"kind": "done", "result": _settle(
+ db, item, version, "changed", "原始 MCP 的连接地址或传输方式已变化,需发布新版本"
+ )}
auth_policy = mcp_validation.normalize_auth_config(version.auth_config, list(version.auth_schema or []))
if auth_policy["credential_mode"] == "auto" and source_auth_schema != list(
version.auth_schema or []
):
- item.status = "changed"
- item.status_reason = "原始 MCP 的认证参数已变化,需发布新版本"
- item.last_verified_at = utc_now()
- item.updated_at = utc_now()
- db.commit()
- return mcp_listing.item_dict(db, item, version, installed=False)
+ return {"kind": "done", "result": _settle(
+ db, item, version, "changed", "原始 MCP 的认证参数已变化,需发布新版本"
+ )}
+ db.expunge(source)
+ return {
+ "kind": "probe",
+ "url": version.url,
+ "allow_private_network": item.source == "admin",
+ "target": source or mcp_versions.version_probe_candidate(item, version),
+ "from_source": source is not None,
+ }
+
+
+def _record_endpoint(db: Session, slug: str, error: Optional[str]) -> Dict[str, Any]:
+ item, version = _listing(db, slug)
+ if error is None:
+ return _settle(db, item, version, "active", None)
+ return _settle(db, item, version, "suspended", f"官方端点安全复检失败:{error}")
+
+
+def _record_unsafe(db: Session, slug: str, error: str) -> Dict[str, Any]:
+ mcp_administration.set_suspended(db, slug, suspended=True, reason=f"远程地址安全复检失败:{error}")
+ item, version = _listing(db, slug)
+ return mcp_listing.item_dict(db, item, version, installed=False)
+
+
+def _record_probe(
+ db: Session, slug: str, ok: bool, error: str, discovered_tools, from_source: bool, accept_changes: bool
+) -> Dict[str, Any]:
+ item, version = _listing(db, slug)
+ if not ok:
+ prefix = "复检连接失败" if from_source else "原始 MCP 连接已不存在,版本端点复检失败"
+ return _settle(db, item, version, "changed", f"{prefix}:{error}")
+ if not discovered_tools:
+ return _settle(db, item, version, "changed", "复检连接成功,但远程 MCP 未返回任何工具,暂不接受新快照")
+ if tool_snapshot_hash(discovered_tools) == version.tool_hash:
+ return _settle(db, item, version, "active", None)
+ if accept_changes and item.source == "admin":
+ previous_version = version.version
+ accepted = mcp_versions.accept_revalidated_snapshot(db, item, version, discovered_tools)
+ result = mcp_listing.item_dict(db, item, accepted, installed=False)
+ result["snapshot_accepted"] = True
+ result["previous_version"] = previous_version
+ return result
+ return _settle(db, item, version, "changed", "远程工具或参数结构已变化,需发布新版本并重新审核")
+
+
+async def revalidate_market_item(slug: str, *, accept_changes: bool = False) -> Dict[str, Any]:
+ """Re-check a listing against its live endpoint and record the verdict.
+
+ Reading the listing, the remote checks and recording the result are three
+ separate steps; no session waits on the remote server.
+ """
+ plan = await run_in_session(_plan, slug)
+ if plan["kind"] == "done":
+ return plan["result"]
+ if plan["kind"] == "endpoint":
+ try:
+ await validate_remote_mcp_url(plan["url"], require_https=False)
+ error = None
+ except BadRequestError as exc:
+ error = str(exc)
+ return await run_in_session(_record_endpoint, slug, error)
try:
await validate_remote_mcp_url(
- version.url,
- allow_private_network=item.source == "admin",
+ plan["url"],
+ allow_private_network=plan["allow_private_network"],
require_https=False,
)
except BadRequestError as exc:
- mcp_administration.set_suspended(db, slug, suspended=True, reason=f"远程地址安全复检失败:{exc}")
- return mcp_listing.item_dict(db, item, version, installed=False)
- probe_target = source or mcp_versions.version_probe_candidate(item, version)
- ok, error = await probe_mcp_connectivity(probe_target, db if source else None)
- discovered_tools = list(probe_target.tools_json or [])
- if not ok:
- item.status = "changed"
- prefix = "复检连接失败" if source else "原始 MCP 连接已不存在,版本端点复检失败"
- item.status_reason = f"{prefix}:{error}"
- elif not discovered_tools:
- item.status = "changed"
- item.status_reason = "复检连接成功,但远程 MCP 未返回任何工具,暂不接受新快照"
- else:
- actual_hash = tool_snapshot_hash(discovered_tools)
- if actual_hash == version.tool_hash:
- item.status = "active"
- item.status_reason = None
- elif accept_changes and item.source == "admin":
- previous_version = version.version
- accepted = mcp_versions.accept_revalidated_snapshot(db, item, version, discovered_tools)
- result = mcp_listing.item_dict(db, item, accepted, installed=False)
- result["snapshot_accepted"] = True
- result["previous_version"] = previous_version
- return result
- else:
- item.status = "changed"
- item.status_reason = "远程工具或参数结构已变化,需发布新版本并重新审核"
- item.last_verified_at = utc_now()
- item.updated_at = utc_now()
- db.commit()
- return mcp_listing.item_dict(db, item, version, installed=False)
+ return await run_in_session(_record_unsafe, slug, str(exc))
+ target = plan["target"]
+ ok, error = await probe_mcp_connectivity(target, validate=plan["from_source"])
+ return await run_in_session(
+ _record_probe, slug, ok, error, list(target.tools_json or []), plan["from_source"], accept_changes
+ )
diff --git a/src/backend/features/marketplace/mcp_submissions.py b/src/backend/features/marketplace/mcp_submissions.py
index ecfd9757..77545fe8 100644
--- a/src/backend/features/marketplace/mcp_submissions.py
+++ b/src/backend/features/marketplace/mcp_submissions.py
@@ -9,21 +9,19 @@
from features.marketplace import edition as ml
from core.capabilities.mcp.management import probe_mcp_connectivity, tool_snapshot_hash, validate_remote_mcp_url
from sqlalchemy.orm import Session
+from core.db.threads import run_in_session
from features.marketplace import mcp_validation
from features.marketplace import mcp_versions
-async def submit_to_marketplace(
- db: Session,
- source_server_id: str,
- *,
- owner_user_id: str,
- submitter_name: str,
- category: str,
- version: str,
- summary: str = "",
- note: str = "",
- tags: Optional[List[str]] = None,
-) -> Dict[str, Any]:
+def _store_tools(db: Session, server_id: str, tools) -> None:
+ """Keep the probed tool list on the source server, as a live probe of it would."""
+ source = db.query(AdminMcpServer).filter(AdminMcpServer.server_id == server_id).first()
+ if source is not None:
+ source.tools_json = tools
+
+
+def _submittable(db: Session, source_server_id: str, owner_user_id: str) -> AdminMcpServer:
+ """The owner's remote MCP, detached, once it may be submitted for listing."""
row = (
db.query(AdminMcpServer)
.filter(
@@ -55,14 +53,42 @@ async def submit_to_marketplace(
)
if pending:
raise BadRequestError(message="该 MCP 已有待审核的上架申请")
+ db.expunge(row)
+ return row
+
+async def submit_to_marketplace(
+ source_server_id: str,
+ *,
+ owner_user_id: str,
+ submitter_name: str,
+ category: str,
+ version: str,
+ summary: str = "",
+ note: str = "",
+ tags: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+ row = await run_in_session(_submittable, source_server_id, owner_user_id)
await validate_remote_mcp_url(row.url, require_https=False)
- ok, error = await probe_mcp_connectivity(row, db)
+ ok, error = await probe_mcp_connectivity(row, validate=True)
if not ok:
raise BadRequestError(message=f"MCP 上架检测失败:{error}")
tools = list(row.tools_json or [])
if not tools:
raise BadRequestError(message="MCP 未发现任何工具,不能申请上架")
+
+ def record(db: Session) -> Dict[str, Any]:
+ return _record_submission(
+ db, row, tools, owner_user_id, submitter_name, category, version, summary, note, tags
+ )
+
+ return await run_in_session(record)
+
+
+def _record_submission(
+ db: Session, row, tools, owner_user_id, submitter_name, category, version, summary, note, tags
+) -> Dict[str, Any]:
+ _store_tools(db, row.server_id, tools)
template_url, auth_schema = mcp_validation.credential_free_connection(row)
now = utc_now()
submission = McpMarketSubmission(
@@ -185,15 +211,7 @@ def get_submission(db: Session, submission_id: str) -> Dict[str, Any]:
return submission_dict(row, detail=True)
-async def review_submission(
- db: Session,
- submission_id: str,
- *,
- approve: bool,
- review_note: str = "",
- category: str = "",
- reviewed_by: Optional[str] = None,
-) -> Dict[str, Any]:
+def _reviewable(db: Session, submission_id: str, approve: bool):
row = (
db.query(McpMarketSubmission)
.filter(
@@ -208,31 +226,60 @@ async def review_submission(
not approve and row.status not in ("pending", "approved")
):
raise BadRequestError(message="该申请当前状态不可审核")
- now = utc_now()
- if approve:
- source = (
- db.query(AdminMcpServer)
- .filter(
- AdminMcpServer.server_id == row.source_server_id,
- AdminMcpServer.owner_user_id == row.owner_user_id,
- )
- .first()
+ return row
+
+
+def _review_source(db: Session, submission_id: str):
+ """The applicant's MCP, detached, once it still matches the submission."""
+ row = _reviewable(db, submission_id, True)
+ source = (
+ db.query(AdminMcpServer)
+ .filter(
+ AdminMcpServer.server_id == row.source_server_id,
+ AdminMcpServer.owner_user_id == row.owner_user_id,
)
- if not source:
- raise BadRequestError(message="申请人的原始 MCP 已删除,不能通过审核")
- source_template_url, source_auth_schema = mcp_validation.credential_free_connection(source)
- if source.transport != row.transport or source_template_url.strip() != row.url.strip():
- raise BadRequestError(message="MCP 连接地址或传输方式已变化,请申请人重新提交")
- if source_auth_schema != list(row.auth_schema or []):
- raise BadRequestError(message="MCP 认证参数已变化,请申请人重新提交")
+ .first()
+ )
+ if not source:
+ raise BadRequestError(message="申请人的原始 MCP 已删除,不能通过审核")
+ source_template_url, source_auth_schema = mcp_validation.credential_free_connection(source)
+ if source.transport != row.transport or source_template_url.strip() != row.url.strip():
+ raise BadRequestError(message="MCP 连接地址或传输方式已变化,请申请人重新提交")
+ if source_auth_schema != list(row.auth_schema or []):
+ raise BadRequestError(message="MCP 认证参数已变化,请申请人重新提交")
+ db.expunge(source)
+ return source
+
+
+async def review_submission(
+ submission_id: str,
+ *,
+ approve: bool,
+ review_note: str = "",
+ category: str = "",
+ reviewed_by: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Approve (after a live re-check) or reject a listing request."""
+ tools = None
+ if approve:
+ source = await run_in_session(_review_source, submission_id)
await validate_remote_mcp_url(source.url or "", require_https=False)
- ok, error = await probe_mcp_connectivity(source, db)
+ ok, error = await probe_mcp_connectivity(source, validate=True)
if not ok:
- db.rollback()
raise BadRequestError(message=f"MCP 审核复检失败:{error}")
- if tool_snapshot_hash(source.tools_json or []) != row.tool_hash:
- db.rollback()
+ tools = list(source.tools_json or [])
+ return await run_in_session(
+ _record_review, submission_id, approve, tools, review_note, category, reviewed_by
+ )
+
+
+def _record_review(db: Session, submission_id, approve, tools, review_note, category, reviewed_by):
+ row = _reviewable(db, submission_id, approve)
+ now = utc_now()
+ if approve:
+ if tool_snapshot_hash(tools or []) != row.tool_hash:
raise BadRequestError(message="MCP 工具或参数结构已变化,请申请人重新提交")
+ _store_tools(db, row.source_server_id, tools)
mcp_versions.publish_snapshot(
db,
slug=row.slug,
diff --git a/src/backend/features/marketplace/mcp_versions.py b/src/backend/features/marketplace/mcp_versions.py
index d5f41f6f..a03be55b 100644
--- a/src/backend/features/marketplace/mcp_versions.py
+++ b/src/backend/features/marketplace/mcp_versions.py
@@ -158,7 +158,6 @@ def version_probe_candidate(item: McpMarketItem, version: McpMarketVersion) -> A
env_vars={},
env_inherit=[],
headers={},
- is_stable=False,
is_enabled=False,
sort_order=0,
extra_config={},
diff --git a/src/backend/features/sites/api/sites_serve.py b/src/backend/features/sites/api/sites_serve.py
index 0baa25f7..d2808ee5 100644
--- a/src/backend/features/sites/api/sites_serve.py
+++ b/src/backend/features/sites/api/sites_serve.py
@@ -25,15 +25,16 @@
from typing import Optional
from features.sites.api.site_gate import render_site_gate
-from core.db.engine import get_db
from features.sites.repository import SiteRepository
from features.sites.password import ACCESS_COOKIE_NAME, access_cookie_params, check_access_password, issue_access_token, site_base_path, verify_access_token
from features.sites.service import SiteService
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
+from core.db.threads import run_in_session
+
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/site", tags=["sites-serve"])
@@ -93,31 +94,48 @@ def _common_headers(content_type: str, *, sandbox: bool, cache: Optional[str] =
return headers
-async def _load_authorized_site(
- slug: str, request: Request, db: Session, *, require_unlock: bool = True
-):
- """取站点并鉴权,返回 ``(site, gate)``。
+def _site(db: Session, slug: str):
+ site = SiteRepository(db).get_by_slug(slug)
+ if site is not None:
+ db.expunge(site)
+ return site
+
+
+def _can_view(db: Session, site_id: str, user_id: Optional[str]) -> bool:
+ from features.sites.models import Site
+
+ return SiteService(db).authorize_view(db.get(Site, site_id), user_id)
+
+
+def _can_unlock(db: Session, site_id: str, user_id: str) -> bool:
+ from features.sites.models import Site
+
+ return SiteService(db).authorize_access(db.get(Site, site_id), user_id, None)
+
+
+async def _load_authorized_site(slug: str, request: Request, *, require_unlock: bool = True):
+ """取站点并鉴权,返回 ``(site, gate)``;``site`` 已脱离会话,只读不改。
两道闸:可见性(不通过一律 404,不泄露站点是否存在)与访问密码(不通过返回
``gate`` 响应,由调用方直接回给访客)。默认两道都过——``require_unlock=False``
是显式豁免,只给 ``__api/*`` 用:站内脚本跑在沙箱的不透明源上、请求不带凭据,
仅解锁入口 ``__api/access`` 可豁免;应用表单仍执行密码验证。
"""
- site = await run_in_threadpool(SiteRepository(db).get_by_slug, slug)
+ site = await run_in_session(_site, slug)
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if site.visibility != "public":
from api.deps import _resolve_session_user_id
user_id = await _resolve_session_user_id(request)
- if not await run_in_threadpool(SiteService(db).authorize_view, site, user_id):
+ if not await run_in_session(_can_view, site.site_id, user_id):
raise HTTPException(status_code=404, detail="Site not found")
if not require_unlock or not site.access_password_hash:
return site, None
- return site, await _unlock_gate(site, request, db)
+ return site, await _unlock_gate(site, request)
-async def _unlock_gate(site, request: Request, db: Session) -> Optional[Response]:
+async def _unlock_gate(site, request: Request) -> Optional[Response]:
"""已解锁返回 None;否则返回验证页。凭据校验是纯 HMAC,不值得丢进线程池。"""
if verify_access_token(site, request.cookies.get(ACCESS_COOKIE_NAME)):
return None
@@ -125,7 +143,7 @@ async def _unlock_gate(site, request: Request, db: Session) -> Optional[Response
from api.deps import _resolve_session_user_id
user_id = await _resolve_session_user_id(request)
- if user_id and await run_in_threadpool(SiteService(db).authorize_access, site, user_id, None):
+ if user_id and await run_in_session(_can_unlock, site.site_id, user_id):
# 给管理者也发一枚凭据,否则同一个页面的每个资源都要重跑一遍会话 + 权限查询。
request.state.site_access_grant = site
return None
@@ -146,12 +164,12 @@ async def site_api_preflight(slug: str, rest: str):
@router.post("/{slug}/__api/access", summary="站点访问密码校验")
-async def site_unlock(slug: str, request: Request, db: Session = Depends(get_db)):
+async def site_unlock(slug: str, request: Request):
"""校验访问密码并下发解锁凭据(统一验证页提交到这里)。这是解锁入口,必须免闸。"""
# 限流放在取站点之前:被挡下的尝试连一次 get_by_slug 都不该花。
from features.sites.rate_limit import count_attempt
await count_attempt(_client_ip(request), slug, unlock=True)
- site, _ = await _load_authorized_site(slug, request, db, require_unlock=False)
+ site, _ = await _load_authorized_site(slug, request, require_unlock=False)
if not site.access_password_hash:
return _api_json({"ok": True})
try:
@@ -180,14 +198,13 @@ async def serve_site_file(
slug: str,
path: str,
request: Request,
- db: Session = Depends(get_db),
):
if path == "__api" or path.startswith("__api/"):
raise HTTPException(404, "API route not found")
- site, gate = await _load_authorized_site(slug, request, db)
+ site, gate = await _load_authorized_site(slug, request)
if gate is not None:
return gate
- response = await run_in_threadpool(_site_file_response, db, site, path)
+ response = await run_in_session(_site_file_response, site.site_id, path)
granted = getattr(request.state, "site_access_grant", None)
if granted is not None:
response.set_cookie(value=issue_access_token(granted), **access_cookie_params(slug))
@@ -211,8 +228,11 @@ def _gate_response(site, request: Request) -> Response:
)
-def _site_file_response(db: Session, site, path: str) -> Response:
+def _site_file_response(db: Session, site_id: str, path: str) -> Response:
# Storage I/O, row updates and ORM refreshes all run off the event loop.
+ from features.sites.models import Site
+
+ site = db.get(Site, site_id)
resolved = SiteService(db).resolve_site_file(site, path)
if resolved is None:
raise HTTPException(status_code=404, detail="File not found")
diff --git a/src/backend/features/sites/desktop_publish.py b/src/backend/features/sites/desktop_publish.py
index c2200567..4b537f77 100644
--- a/src/backend/features/sites/desktop_publish.py
+++ b/src/backend/features/sites/desktop_publish.py
@@ -189,7 +189,7 @@ def publish_uploaded_site(user_id, data, options):
async def forward_local_site_tool(tool_name, arguments, *, user_id, chat_id=""):
"""Sites are cloud-hosted: a local site MCP's call goes to the cloud gateway plugin."""
from core.llm.factory.tools.mcp_config import inject_runtime_headers as _inject_runtime_headers
- from core.llm.mcp.pool import make_client
+ from core.llm.tools.cloud_manifest import manifest_tools
from features.desktop.bridge.gateway import cloud_gateway_mcp_configs
from features.desktop.bridge.identity import get_state, require_current_account
from core.harness.registry.transfer_channels import endpoint_plugin
@@ -210,23 +210,22 @@ async def forward_local_site_tool(tool_name, arguments, *, user_id, chat_id=""):
channel_origin=None,
reranker_enabled=False,
)
- client = make_client(sid, configs[sid], is_stateful=False)
- try:
- tool = await client.get_tool(tool_name)
- result = await tool(**arguments)
- require_current_account(state)
- if tool_name in {"manage_application", "publish_mcp"}:
- data = {"content": [block.model_dump() for block in result.content]}
- localize_application_result(data, state["cloud_base"])
- return json.loads(
- next(block["text"] for block in data["content"] if block["type"] == "text")
- )
- for block in result.content:
- if getattr(block, "type", None) == "text":
- return json.loads(block.text)
- return {"error": f"云端 {tool_name} 结果未知,请在云端站点列表核对"}
- finally:
- await client.close()
+ tools = manifest_tools(sid, configs[sid])
+ tool = next((t for t in tools if t.name == tool_name), None)
+ if tool is None:
+ return {"error": "云端站点工具不可用"}
+ result = await tool(**arguments)
+ require_current_account(state)
+ if tool_name in {"manage_application", "publish_mcp"}:
+ data = {"content": [block.model_dump() for block in result.content]}
+ localize_application_result(data, state["cloud_base"])
+ return json.loads(
+ next(block["text"] for block in data["content"] if block["type"] == "text")
+ )
+ for block in result.content:
+ if getattr(block, "type", None) == "text":
+ return json.loads(block.text)
+ return {"error": f"云端 {tool_name} 结果未知,请在云端站点列表核对"}
return {"error": "云端站点能力不可用,请启用云端站点插件后重试"}
diff --git a/src/backend/features/sites/tools/design_picker.py b/src/backend/features/sites/tools/design_picker.py
index 990b32f3..e4519a83 100644
--- a/src/backend/features/sites/tools/design_picker.py
+++ b/src/backend/features/sites/tools/design_picker.py
@@ -5,12 +5,13 @@
resumes in place once chosen. Conditionally registered by agent_factory only in
site-building chats (site-builder skill enabled).
"""
+from core.llm.factory.tools.definitions import function_tool
import logging
from typing import Any, Dict, List, Optional
from agentscope.tool import Toolkit
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
from core.llm.tools import confirmation as _mc
from core.llm.tools._common import resp_json
@@ -40,7 +41,7 @@ def skill_uses_choose_design(skill_id: str) -> bool:
)
-def _err(msg: str) -> ToolResponse:
+def _err(msg: str) -> ToolChunk:
return resp_json({"ok": False, "error": msg})
@@ -61,7 +62,7 @@ def register_choose_design(
instead of erroring out on a missing tool.
"""
- async def choose_design(question: str, options: List[Dict[str, Any]]) -> ToolResponse:
+ async def choose_design(question: str, options: List[Dict[str, Any]]) -> ToolChunk:
"""让用户从多个设计方案预览图中选择一个(**会挂起等待用户点选**)。
仅在建站流程中使用:先为同一站点做出 2-4 个(推荐 3 个)风格迥异的
@@ -218,10 +219,7 @@ def resolve_image_paths(args, runtime):
))
return intents
- toolkit.register_tool_function(
- choose_design, namesake_strategy="override",
- permission=ToolPermissionSpec("design-preview-paths", resolve_image_paths),
- )
+ toolkit.append(function_tool(choose_design, dispatch_spec=ToolPermissionSpec("design-preview-paths", resolve_image_paths)))
logger.info("[factory] Registered choose_design tool (site-builder session)")
diff --git a/src/backend/features/workbench/api/plugin_ui.py b/src/backend/features/workbench/api/plugin_ui.py
index f5bb2af5..fe25415c 100644
--- a/src/backend/features/workbench/api/plugin_ui.py
+++ b/src/backend/features/workbench/api/plugin_ui.py
@@ -25,6 +25,7 @@
from core.auth.backend import UserContext, get_current_user
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.exceptions import ResourceNotFoundError
from core.infra.responses import success_response
from features.workbench.ui import data_proxy as proxy
@@ -87,14 +88,13 @@ async def call_plugin_data_source(
slug: str = Path(..., min_length=1, max_length=100),
source_id: str = Path(..., min_length=1, max_length=64),
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
"""Run one manifest-declared upstream call on the caller's behalf.
Params are validated against the declaration's ``params_schema`` and
credentials are interpolated server-side, so the browser never sees them.
"""
- ui = _require_ui(db, slug, str(user.user_id))
+ ui = await run_in_session(_require_ui, slug, str(user.user_id))
source = find_data_source(ui, source_id)
if source is None:
raise ResourceNotFoundError("plugin_data_source", f"{slug}/{source_id}")
diff --git a/src/backend/features/workbench/api/workbench.py b/src/backend/features/workbench/api/workbench.py
index 5bf50fdf..59ece49c 100644
--- a/src/backend/features/workbench/api/workbench.py
+++ b/src/backend/features/workbench/api/workbench.py
@@ -5,7 +5,7 @@
from typing import Any
-from api.deps import _resolve_session_user_id, require_config
+from api.deps import console_actor, require_config
from features.workbench.api.workbench_auth import browser_write
from features.workbench.api.workbench_auth import router as auth_router
from features.workbench.diagnostics import observed
@@ -106,11 +106,14 @@ def admin_audit(response: Response, db: Session = Depends(get_db)):
"/admin/external-applications",
dependencies=[Depends(require_config), Depends(require_json_write)],
)
-async def create_external(
- body: ExternalWrite, request: Request, response: Response, db: Session = Depends(get_db)
+def create_external(
+ body: ExternalWrite,
+ request: Request,
+ response: Response,
+ actor: str = Depends(console_actor),
+ db: Session = Depends(get_db),
):
response.headers["Cache-Control"] = "no-store"
- actor = await _resolve_session_user_id(request) or "config_token"
external.validate_definition(db, body.declaration)
embedding = validate_policy(
body.declaration["target"],
@@ -128,15 +131,15 @@ async def create_external(
"/admin/external-applications/{key}",
dependencies=[Depends(require_config), Depends(require_json_write)],
)
-async def update_external(
+def update_external(
key: str,
body: ExternalUpdate,
request: Request,
response: Response,
+ actor: str = Depends(console_actor),
db: Session = Depends(get_db),
):
response.headers["Cache-Control"] = "no-store"
- actor = await _resolve_session_user_id(request) or "config_token"
external.validate_definition(db, body.declaration)
embedding = validate_policy(
body.declaration["target"],
@@ -281,11 +284,13 @@ def admin_policy(response: Response, db: Session = Depends(get_db)):
@router.put("/admin/policy", dependencies=[Depends(require_config), Depends(require_json_write)])
-async def update_policy(
- body: PolicyWrite, request: Request, response: Response, db: Session = Depends(get_db)
+def update_policy(
+ body: PolicyWrite,
+ response: Response,
+ actor: str = Depends(console_actor),
+ db: Session = Depends(get_db),
):
response.headers["Cache-Control"] = "no-store"
- actor = await _resolve_session_user_id(request) or "config_token"
return success_response(data=policy.save(db, body.model_dump(), actor, trace_id_var.get()))
diff --git a/src/backend/features/workbench/api/workbench_activity.py b/src/backend/features/workbench/api/workbench_activity.py
index 8c6df14a..1547ea06 100644
--- a/src/backend/features/workbench/api/workbench_activity.py
+++ b/src/backend/features/workbench/api/workbench_activity.py
@@ -9,6 +9,7 @@
from features.workbench import activity, background
from core.auth.backend import UserContext, require_auth
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import success_response
@@ -44,6 +45,14 @@ def private(response: Response):
response.headers["Cache-Control"] = "private, no-store"
+def _check_declared(db, user_id, key, instance_id, capabilities):
+ _, row, item = activity.authorize(db, user_id, key, instance_id)
+ if set(capabilities) - activity.declared(item, db, user_id, row.get("moduleId")):
+ from features.workbench.external import fail
+
+ fail(403, "capability_not_declared")
+
+
def routes(route_class, json_write):
router = APIRouter(
route_class=route_class, dependencies=[Depends(require_auth()), Depends(private)]
@@ -54,11 +63,10 @@ def routes(route_class, json_write):
async def projection(
response: Response,
user: UserContext = Depends(require_auth()),
- db: Session = Depends(get_db),
):
response.headers["Cache-Control"] = "private, no-store"
- await background.reconcile(db, user.user_id)
- return success_response(data=activity.projection(db, user.user_id))
+ await background.reconcile(user.user_id)
+ return success_response(data=await run_in_session(activity.projection, user.user_id))
@router.post(path, dependencies=[Depends(json_write)])
def register(
@@ -97,17 +105,14 @@ async def consent(
instance_id: str,
body: Consent,
user: UserContext = Depends(require_auth()),
- db: Session = Depends(get_db),
):
- _, row, item = activity.authorize(db, user.user_id, key, instance_id)
- if set(body.capabilities) - activity.declared(item, db, user.user_id, row.get("moduleId")):
- from features.workbench.external import fail
-
- fail(403, "capability_not_declared")
+ await run_in_session(_check_declared, user.user_id, key, instance_id, body.capabilities)
if "background" not in body.capabilities:
- await background.stop_all(db, user.user_id, application_key=key)
+ await background.stop_all(user.user_id, application_key=key)
return success_response(
- data=activity.consent(db, user.user_id, key, instance_id, body.capabilities)
+ data=await run_in_session(
+ activity.consent, user.user_id, key, instance_id, body.capabilities
+ )
)
@router.put(path + "/badge", dependencies=[Depends(json_write)])
@@ -161,10 +166,9 @@ async def start(
instance_id: str,
body: Background,
user: UserContext = Depends(require_auth()),
- db: Session = Depends(get_db),
):
return success_response(
- data=await background.start(db, user.user_id, key, instance_id, body.resourceId)
+ data=await background.start(user.user_id, key, instance_id, body.resourceId)
)
@router.post(path + "/background/{resource_id}/lease", dependencies=[Depends(json_write)])
@@ -173,10 +177,9 @@ async def renew(
instance_id: str,
resource_id: str,
user: UserContext = Depends(require_auth()),
- db: Session = Depends(get_db),
):
return success_response(
- data=await background.renew(db, user.user_id, key, instance_id, resource_id)
+ data=await background.renew(user.user_id, key, instance_id, resource_id)
)
@router.delete(path + "/background/{resource_id}")
@@ -185,9 +188,8 @@ async def stop(
instance_id: str,
resource_id: str,
user: UserContext = Depends(require_auth()),
- db: Session = Depends(get_db),
):
- await background.stop(db, user.user_id, key, instance_id, resource_id)
+ await background.stop(user.user_id, key, instance_id, resource_id)
return success_response(data={})
return router
diff --git a/src/backend/features/workbench/api/workbench_application_config.py b/src/backend/features/workbench/api/workbench_application_config.py
index a0a3f1e6..2e2499f4 100644
--- a/src/backend/features/workbench/api/workbench_application_config.py
+++ b/src/backend/features/workbench/api/workbench_application_config.py
@@ -15,14 +15,14 @@
tags=["workbench installed configuration"])
-async def configuration_admin(request: Request, db: Session = Depends(get_db),
+async def configuration_admin(request: Request,
user: UserContext | None = Depends(require_auth(False))):
if user is not None and user.api_key_id:
fail(403, "configuration_session_required")
try:
- return await require_system_settings(request, db, user)
+ return await require_system_settings(request, user)
except HTTPException:
- await require_admin(request, request.headers.get("authorization"), db)
+ await require_admin(request, request.headers.get("authorization"))
return user.user_id if user is not None else "admin_token"
diff --git a/src/backend/features/workbench/api/workbench_auth.py b/src/backend/features/workbench/api/workbench_auth.py
index a9068209..66a47b3b 100644
--- a/src/backend/features/workbench/api/workbench_auth.py
+++ b/src/backend/features/workbench/api/workbench_auth.py
@@ -4,7 +4,8 @@
from typing import Literal
from urllib.parse import urlencode
-from api.deps import _resolve_session_user_id, require_config
+from api.deps import console_actor, require_config
+from core.db.threads import run_in_session
from features.workbench import external_auth
from features.workbench.external_boundary import validate_cookie_scope
from core.auth.backend import UserContext, require_auth
@@ -18,7 +19,6 @@
from fastapi.routing import APIRoute
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy.orm import Session
-from starlette.concurrency import run_in_threadpool
class PrivateRoute(APIRoute):
@@ -107,19 +107,18 @@ def auth_config(key: str, db: Session = Depends(get_db)):
@router.put(
"/admin/external-auth/{key}", dependencies=[Depends(require_config), Depends(browser_write)]
)
-async def update_auth(key: str, body: AuthWrite, request: Request, db: Session = Depends(get_db)):
- actor = await _resolve_session_user_id(request) or "config_token"
- data = await run_in_threadpool(
- external_auth.save, db, key, body.model_dump(), actor, trace_id_var.get()
- )
+def update_auth(
+ key: str, body: AuthWrite, actor: str = Depends(console_actor), db: Session = Depends(get_db)
+):
+ data = external_auth.save(db, key, body.model_dump(), actor, trace_id_var.get())
return success_response(data=data)
@router.post("/external-auth/exchange", dependencies=[Depends(browser_write)])
-async def exchange(body: Exchange, request: Request, db: Session = Depends(get_db)):
+async def exchange(body: Exchange, request: Request):
auth = request.headers.get("authorization", "")
credential = auth[len("Bearer ") :] if auth.startswith("Bearer ") else ""
- data = await run_in_threadpool(external_auth.redeem, db, body.model_dump(), credential)
+ data = await run_in_session(external_auth.redeem, body.model_dump(), credential)
session_hash = data.pop("_sessionHash", None)
if session_hash:
session = await find_session_by_hash(session_hash)
diff --git a/src/backend/features/workbench/api/workbench_capabilities.py b/src/backend/features/workbench/api/workbench_capabilities.py
index 7399ead7..1b384353 100644
--- a/src/backend/features/workbench/api/workbench_capabilities.py
+++ b/src/backend/features/workbench/api/workbench_capabilities.py
@@ -12,6 +12,7 @@
from features.workbench import external, application_config
from core.auth.backend import UserContext, require_auth
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import success_response
from features.workbench.ui.contract import find_data_source
from features.workbench.ui.data_proxy import call_data_source
@@ -165,47 +166,58 @@ def revoke(
return success_response(data={"revoked": True})
+def _reserve_query(db, user_id, key, body):
+ lease, selected, effective = grants.reserve(
+ db, user_id, key, body.revision, body.approvalId, "data.query", body.instanceId
+ )
+ try:
+ if "data_source:" + body.sourceId not in effective:
+ external.fail(403, "data_source_denied")
+ source = find_data_source(selected.ui, body.sourceId)
+ if source is None:
+ external.fail(404, "data_source_not_found")
+ return lease, selected, source, {
+ "config_values": application_config.data_values(db, key, selected),
+ "configured_origins": application_config.trusted_origins(db, key, selected),
+ }
+ except BaseException:
+ grants.release(db, body.approvalId, lease)
+ raise
+
+
+def _confirm(db, user_id, key, revision, approval_id, capability, instance_id):
+ grants.authorized(db, user_id, key, revision, approval_id, capability, instance_id)
+
+
@router.post("/data/query")
async def query(
key: str,
body: Query,
response: Response,
user: UserContext = Depends(host_identity),
- db: Session = Depends(get_db),
):
if len(json.dumps(body.params).encode()) > 64 * 1024:
external.fail(413, "request_too_large")
- lease, selected, effective = grants.reserve(
- db, user.user_id, key, body.revision, body.approvalId, "data.query", body.instanceId
+ lease, selected, source, configuration = await run_in_session(
+ _reserve_query, user.user_id, key, body
)
try:
- if "data_source:" + body.sourceId not in effective:
- external.fail(403, "data_source_denied")
- source = find_data_source(selected.ui, body.sourceId)
- if source is None:
- external.fail(404, "data_source_not_found")
try:
result = await asyncio.wait_for(
- call_data_source(
- source,
- body.params,
- slug=selected.slug,
- config_values=application_config.data_values(db, key, selected),
- configured_origins=application_config.trusted_origins(db, key, selected),
- ),
+ call_data_source(source, body.params, slug=selected.slug, **configuration),
timeout=30,
)
except TimeoutError:
external.fail(504, "capability_timeout")
- grants.authorized(
- db, user.user_id, key, body.revision, body.approvalId, "data.query", body.instanceId
+ await run_in_session(
+ _confirm, user.user_id, key, body.revision, body.approvalId, "data.query", body.instanceId
)
if len(json.dumps(result).encode()) > 256 * 1024:
external.fail(413, "response_too_large")
response.headers["Cache-Control"] = "no-store"
return success_response(data=result)
finally:
- grants.release(db, body.approvalId, lease)
+ await run_in_session(grants.release, body.approvalId, lease)
def file_info(artifact):
@@ -343,6 +355,27 @@ def download(
)
+def _reserve_save(db, user_id, key, revision, approval_id, instance_id):
+ lease, _, _ = grants.reserve(db, user_id, key, revision, approval_id, "file.save", instance_id)
+ return lease
+
+
+def _store_saved(db, user_id, key, revision, approval_id, instance_id, content, filename, mime_type):
+ runtime(db, user_id)
+ grants.authorized(db, user_id, key, revision, approval_id, "file.save", instance_id)
+ from core.files.artifacts.service import store_bytes_as_artifact
+
+ artifact = store_bytes_as_artifact(
+ db,
+ user_id=user_id,
+ content=content,
+ filename=filename,
+ mime_type=mime_type,
+ source="workbench",
+ )
+ return file_info(artifact)
+
+
@router.post("/files/save")
async def save(
key: str,
@@ -351,11 +384,8 @@ async def save(
instanceId: str | None = Form(None),
file: UploadFile = File(...),
user: UserContext = Depends(host_identity),
- db: Session = Depends(get_db),
):
- lease, _, _ = grants.reserve(
- db, user.user_id, key, revision, approvalId, "file.save", instanceId
- )
+ lease = await run_in_session(_reserve_save, user.user_id, key, revision, approvalId, instanceId)
try:
content = await asyncio.wait_for(file.read(50 * 1024 * 1024 + 1), timeout=30)
if not content or len(content) > 50 * 1024 * 1024:
@@ -363,18 +393,10 @@ async def save(
filename = file.filename or ""
if not filename or len(filename) > 200 or "/" in filename or "\\" in filename:
external.fail(422, "invalid_filename")
- runtime(db, user.user_id)
- grants.authorized(db, user.user_id, key, revision, approvalId, "file.save", instanceId)
- from core.files.artifacts.service import store_bytes_as_artifact
-
- artifact = store_bytes_as_artifact(
- db,
- user_id=user.user_id,
- content=content,
- filename=filename,
- mime_type=file.content_type,
- source="workbench",
+ info = await run_in_session(
+ _store_saved, user.user_id, key, revision, approvalId, instanceId,
+ content, filename, file.content_type,
)
- return success_response(data=file_info(artifact))
+ return success_response(data=info)
finally:
- grants.release(db, approvalId, lease)
+ await run_in_session(grants.release, approvalId, lease)
diff --git a/src/backend/features/workbench/api/workbench_events.py b/src/backend/features/workbench/api/workbench_events.py
index 939063df..7ba5c0f8 100644
--- a/src/backend/features/workbench/api/workbench_events.py
+++ b/src/backend/features/workbench/api/workbench_events.py
@@ -29,10 +29,9 @@ async def directory_events(
while True:
# No request-long transaction or cached user capabilities. Recheck session and
# projection even if Last-Event-ID/revision comes from an authenticated client.
- with SessionLocal() as db:
- current = await get_current_user(request, credentials=None, db=db)
- if current.user_id != user.user_id:
- raise HTTPException(401, detail={"code": "session_revoked"})
+ current = await get_current_user(request, None)
+ if current.user_id != user.user_id:
+ raise HTTPException(401, detail={"code": "session_revoked"})
data, scope = await run_in_threadpool(current_snapshot, current.user_id)
if data["revision"] != revision:
response.headers["Cache-Control"] = "private, no-store"
diff --git a/src/backend/features/workbench/api/workbench_resources.py b/src/backend/features/workbench/api/workbench_resources.py
index c8dd1739..f1ee2311 100644
--- a/src/backend/features/workbench/api/workbench_resources.py
+++ b/src/backend/features/workbench/api/workbench_resources.py
@@ -1,13 +1,12 @@
"""Authenticated application resource routes; shared tickets/stream remain reusable."""
from core.auth.backend import UserContext, require_auth
-from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import success_response
from core.harness.registry.resource_owners import resource_owner
from core.capabilities.plugins.resources import service
from fastapi import APIRouter, Depends, Header, HTTPException, Response
from pydantic import BaseModel, ConfigDict, Field
-from sqlalchemy.orm import Session
class ResourceOpen(BaseModel):
@@ -25,6 +24,23 @@ class ResourceCommand(BaseModel):
params: dict = Field(default_factory=dict)
+def _owned(db, user_id, key, instance_id, resource_id, *, allow_closed=False):
+ row = resource_owner().owned(db, user_id, key, instance_id, resource_id, allow_closed=allow_closed)
+ return service.detached(db, row)
+
+
+def _commandable(db, user_id, key, instance_id, resource_id):
+ from core.capabilities.plugins.resources.owners import resolve_installation
+ from features.workbench.ui.contract import find_module
+
+ row = resource_owner().owned(db, user_id, key, instance_id, resource_id)
+ selected = resolve_installation(db, row, row.user_id)
+ module = find_module(selected.ui, row.module_id)
+ if not module or "resource.command" not in module.get("grants", []):
+ raise HTTPException(403, "resource_command_not_granted")
+ return service.detached(db, row)
+
+
def routes(route_class, json_write):
router = APIRouter(
prefix="/applications/{key}/instances/{instance_id}/resources",
@@ -40,7 +56,6 @@ async def create(
response: Response,
idempotency_key: str = Header(alias="Idempotency-Key"),
user: UserContext = Depends(require_auth()),
- db: Session = Depends(get_db),
):
from fastapi import HTTPException
@@ -48,7 +63,6 @@ async def create(
raise HTTPException(422, "invalid_instance_id")
response.headers["Cache-Control"] = "no-store"
value = await resource_owner().create(
- db,
str(user.user_id),
key,
instance_id,
@@ -64,12 +78,11 @@ async def lease(
resource_id: str,
response: Response,
user: UserContext = Depends(require_auth()),
- db: Session = Depends(get_db),
):
response.headers["Cache-Control"] = "no-store"
return success_response(
data={
- "resource": await resource_owner().lease(db, str(user.user_id), key, instance_id, resource_id)
+ "resource": await resource_owner().lease(str(user.user_id), key, instance_id, resource_id)
}
)
@@ -80,14 +93,13 @@ async def close(
resource_id: str,
response: Response,
user: UserContext = Depends(require_auth()),
- db: Session = Depends(get_db),
):
response.headers["Cache-Control"] = "no-store"
- row = resource_owner().owned(
- db, str(user.user_id), key, instance_id, resource_id, allow_closed=True
+ row = await run_in_session(
+ _owned, str(user.user_id), key, instance_id, resource_id, allow_closed=True
)
if row.status != "closed":
- await service.close(db, row)
+ await service.close(row)
return success_response(data={"closed": True})
@router.post("/{resource_id}/command")
@@ -98,18 +110,10 @@ async def command(
body: ResourceCommand,
response: Response,
user: UserContext = Depends(require_auth()),
- db: Session = Depends(get_db),
):
import json
- from core.capabilities.plugins.resources.owners import resolve_installation
- from features.workbench.ui.contract import find_module
-
- row = resource_owner().owned(db, str(user.user_id), key, instance_id, resource_id)
- selected = resolve_installation(db, row, row.user_id)
- module = find_module(selected.ui, row.module_id)
- if not module or "resource.command" not in module.get("grants", []):
- raise HTTPException(403, "resource_command_not_granted")
+ row = await run_in_session(_commandable, str(user.user_id), key, instance_id, resource_id)
if len(json.dumps(body.model_dump()).encode()) > 256 * 1024:
raise HTTPException(413, "resource_command_too_large")
response.headers["Cache-Control"] = "no-store"
diff --git a/src/backend/features/workbench/background.py b/src/backend/features/workbench/background.py
index 3f9d792f..02495bcd 100644
--- a/src/backend/features/workbench/background.py
+++ b/src/backend/features/workbench/background.py
@@ -7,6 +7,7 @@
import logging
from features.workbench import activity, external
from core.capabilities.plugins.resources import service, store
+from core.db.threads import run_in_session
MAX_PER_USER = 2
MAX_PER_APP = 1
@@ -51,13 +52,15 @@ def admission(db, user_id, key, instance_id, resource_id):
return value, instance, row
-async def start(db, user_id, key, instance_id, resource_id):
- from core.capabilities.plugins.resources.deadlines import arm, provider_for
+def _probe(db, user_id, key, instance_id, resource_id):
+ from core.capabilities.plugins.resources.deadlines import provider_for
- # Probe the runner without holding a database write/coordination lock.
_, _, probe = admission(db, user_id, key, instance_id, resource_id)
provider_for(probe)
- await arm(db, probe, require_background=True)
+
+
+def _reserve(db, user_id, key, instance_id, resource_id):
+ """Charge a background reservation under the owner lock; None when one exists."""
runtime(db, user_id)
db.expire_all()
# Authorization and quotas are evaluated again after the probe and owner lock.
@@ -67,7 +70,8 @@ async def start(db, user_id, key, instance_id, resource_id):
if existing:
if existing["expiresAt"] <= now or existing["deadline"] <= now:
external.fail(410, "background_expired")
- return {"resourceId": resource_id, "expiresAt": existing["expiresAt"]}
+ db.commit()
+ return existing["expiresAt"], False
# Expired reservations remain charged until actual stop/reconcile.
if (
len(value["background"]) >= MAX_PER_USER
@@ -84,7 +88,18 @@ async def start(db, user_id, key, instance_id, resource_id):
}
row.expires_at = min(row.expires_at, expires)
activity.save(db, user_id, value)
- await arm(db, row, require_background=True)
+ return expires, True
+
+
+async def start(user_id, key, instance_id, resource_id):
+ from core.capabilities.plugins.resources.deadlines import arm
+
+ # Probe the runner without holding a database write/coordination lock.
+ await run_in_session(_probe, user_id, key, instance_id, resource_id)
+ await arm(resource_id, require_background=True)
+ expires, reserved = await run_in_session(_reserve, user_id, key, instance_id, resource_id)
+ if reserved:
+ await arm(resource_id, require_background=True)
return {"resourceId": resource_id, "expiresAt": expires}
@@ -115,9 +130,7 @@ def authorized_resource(db, row):
return True
-async def renew(db, user_id, key, instance_id, resource_id):
- from core.capabilities.plugins.resources.deadlines import arm
-
+def _extend(db, user_id, key, instance_id, resource_id):
runtime(db, user_id)
db.expire_all()
value = activity.read(db, user_id)
@@ -130,11 +143,18 @@ async def renew(db, user_id, key, instance_id, resource_id):
record["expiresAt"] = expires
row.expires_at = expires
activity.save(db, user_id, value)
- await arm(db, row, require_background=True)
+ return expires
+
+
+async def renew(user_id, key, instance_id, resource_id):
+ from core.capabilities.plugins.resources.deadlines import arm
+
+ expires = await run_in_session(_extend, user_id, key, instance_id, resource_id)
+ await arm(resource_id, require_background=True)
return {"resourceId": resource_id, "expiresAt": expires}
-async def stop(db, user_id, key, instance_id, resource_id):
+def _owned_row(db, user_id, key, instance_id, resource_id):
# Ownership, not availability, gates cleanup so revoked users can still stop.
row = store.get(db, resource_id, user_id)
if (
@@ -142,7 +162,11 @@ async def stop(db, user_id, key, instance_id, resource_id):
or getattr(row, "instance_id", None) != instance_id
):
external.fail(404, "background_not_found")
- await service.close(db, row)
+ db.expunge(row)
+ return row
+
+
+def _release(db, user_id, resource_id):
runtime(db, user_id)
db.expire_all()
value = activity.read(db, user_id)
@@ -150,37 +174,53 @@ async def stop(db, user_id, key, instance_id, resource_id):
activity.save(db, user_id, value)
-async def stop_all(db, user_id, application_key=None, install_id=None):
- value = activity.read(db, user_id)
- for rid, record in list(value["background"].items()):
+async def stop(user_id, key, instance_id, resource_id):
+ row = await run_in_session(_owned_row, user_id, key, instance_id, resource_id)
+ await service.close(row)
+ await run_in_session(_release, user_id, resource_id)
+
+
+def _records(db, user_id):
+ return list(activity.read(db, user_id)["background"].items())
+
+
+async def stop_all(user_id, application_key=None, install_id=None):
+ for rid, record in await run_in_session(_records, user_id):
if (application_key is None or record["applicationKey"] == application_key) and (
install_id is None or record["binding"].get("installId") == install_id
):
- await stop(db, user_id, record["applicationKey"], record["instanceId"], rid)
+ await stop(user_id, record["applicationKey"], record["instanceId"], rid)
-async def reconcile(db, user_id):
+def _revoked_row(db, user_id, rid, record):
+ """None while the reservation is still authorized; else the row to stop (or False if gone)."""
from fastapi import HTTPException
- value = activity.read(db, user_id)
- for rid, record in list(value["background"].items()):
- try:
- row = resource(db, rid, user_id, record["applicationKey"], record["instanceId"])
- authorized_resource(db, row)
- except HTTPException:
- # Metadata is retained until real shutdown succeeds.
- row = db.get(store.PluginResource, rid)
- if row is not None and row.user_id == user_id:
- try:
- await service.close(db, row)
- except Exception as error:
- db.rollback()
- logging.getLogger(__name__).warning(
- "Background cleanup deferred: %s", type(error).__name__
- )
- continue
- runtime(db, user_id)
- db.expire_all()
- current = activity.read(db, user_id)
- current["background"].pop(rid, None)
- activity.save(db, user_id, current)
+ try:
+ row = resource(db, rid, user_id, record["applicationKey"], record["instanceId"])
+ authorized_resource(db, row)
+ return None
+ except HTTPException:
+ db.rollback()
+ # Metadata is retained until real shutdown succeeds.
+ row = db.get(store.PluginResource, rid)
+ if row is None or row.user_id != user_id:
+ return False
+ db.expunge(row)
+ return row
+
+
+async def reconcile(user_id):
+ for rid, record in await run_in_session(_records, user_id):
+ row = await run_in_session(_revoked_row, user_id, rid, record)
+ if row is None:
+ continue
+ if row is not False:
+ try:
+ await service.close(row)
+ except Exception as error:
+ logging.getLogger(__name__).warning(
+ "Background cleanup deferred: %s", type(error).__name__
+ )
+ continue
+ await run_in_session(_release, user_id, rid)
diff --git a/src/backend/features/workbench/migration_compat.py b/src/backend/features/workbench/migration_compat.py
index 27d6e412..e9b3a094 100644
--- a/src/backend/features/workbench/migration_compat.py
+++ b/src/backend/features/workbench/migration_compat.py
@@ -18,7 +18,7 @@ def active(db):
def app_config(db):
if not active(db):
- from core.files.content.content_blocks_state import DEFAULT_APP_CONFIG
+ from core.files.content.content_blocks_constants import DEFAULT_APP_CONFIG
from core.files.content.branding import normalize_app_config
row = db.get(ContentBlock, "app_config")
@@ -155,7 +155,7 @@ def build_docs_snapshot(db):
def import_docs_snapshot(db, snapshot, *, overwrite=True, default_updated_by=None):
- from core.files.content.content_blocks_state import DOCS_BLOCK_MAP
+ from core.files.content.content_blocks_constants import DOCS_BLOCK_MAP
from core.files.content.content_blocks import import_docs_snapshot as original
from core.files.content.content_blocks import normalize_docs_snapshot
diff --git a/src/backend/features/workbench/migration_plan.py b/src/backend/features/workbench/migration_plan.py
index f8a4c7f2..61251481 100644
--- a/src/backend/features/workbench/migration_plan.py
+++ b/src/backend/features/workbench/migration_plan.py
@@ -8,7 +8,7 @@
from features.workbench.catalog import BUILTINS
from features.workbench.migration_snapshot import digest
from features.workbench.protocol import validate_application
-from core.files.content.content_blocks_state import DEFAULT_PAGE_CONFIG
+from core.files.content.content_blocks_constants import DEFAULT_PAGE_CONFIG
from fastapi import HTTPException
FIXED = {"chat", "settings", "docs", "plan_mode"}
diff --git a/src/backend/features/workbench/resource_deadlines.py b/src/backend/features/workbench/resource_deadlines.py
index f202c399..08888695 100644
--- a/src/backend/features/workbench/resource_deadlines.py
+++ b/src/backend/features/workbench/resource_deadlines.py
@@ -25,9 +25,13 @@ def invalidate(db, row):
activity.save(db, user, current) # Retain the exact charged cleanup reservation.
-async def arm(db, row, *, require_background=False):
- if row.application_key is None or row.chat_id is not None:
- return # Ordinary chat resources retain their existing lifetime.
+def _arming(db, resource_id, require_background):
+ """Validate the lease record and resolve the runner timer; None when nothing to arm."""
+ from core.capabilities.plugins.resources import store
+
+ row = db.get(store.PluginResource, resource_id)
+ if row is None or row.application_key is None or row.chat_id is not None:
+ return None # Ordinary chat resources retain their existing lifetime.
value = activity.read(db, row.user_id)
record = value["background"].get(row.resource_id)
source = origin.current(db)
@@ -48,7 +52,7 @@ async def arm(db, row, *, require_background=False):
method, process_id = provider_for(row)
except HTTPException as error:
if not (require_background or record) and error.status_code == 403:
- return # Remote providers have no independent persistent deadline contract.
+ return None # Remote providers have no independent persistent deadline contract.
raise
digest = hashlib.sha256(
json.dumps(
@@ -65,25 +69,54 @@ async def arm(db, row, *, require_background=False):
sort_keys=True,
).encode()
).hexdigest()
+ return {
+ "method": method,
+ "process_id": process_id,
+ "session": runtime_session(row),
+ "user_id": row.user_id,
+ "digest": digest,
+ "expires": expires,
+ "deadline": record["deadline"] if record else None,
+ "background": bool(record),
+ }
+
+
+def _invalidate(db, resource_id):
+ from core.capabilities.plugins.resources import store
+
+ invalidate(db, db.get(store.PluginResource, resource_id))
+
+
+async def arm(resource_id, *, require_background=False):
+ """Hand the lease deadline to the runner; revoke the lease if delivery is uncertain.
+
+ The lease is read and, on failure, invalidated on sessions of their own; the
+ runner call in between holds no connection.
+ """
+ from core.db.threads import run_in_session
+
+ timer = await run_in_session(_arming, resource_id, require_background)
+ if timer is None:
+ return
try:
- result = await method(
- process_id,
- sandbox_session_id=runtime_session(row),
- user_id=row.user_id,
- binding=digest,
- expires_at=expires,
- absolute_deadline=record["deadline"] if record else None,
+ result = await timer["method"](
+ timer["process_id"],
+ sandbox_session_id=timer["session"],
+ user_id=timer["user_id"],
+ binding=timer["digest"],
+ expires_at=timer["expires"],
+ absolute_deadline=timer["deadline"],
)
except Exception:
# Do not leave an authorized extended lease after uncertain timer delivery.
- invalidate(db, row)
+ await run_in_session(_invalidate, resource_id)
raise HTTPException(503, detail={"code": "release_failed"}) from None
if result.get("supported") is False:
- if record:
- invalidate(db, row)
- if require_background or record:
+ if timer["background"]:
+ await run_in_session(_invalidate, resource_id)
+ if require_background or timer["background"]:
raise HTTPException(403, detail={"code": "unsupported_capability"})
return
if result.get("armed") is not True:
- invalidate(db, row)
+ await run_in_session(_invalidate, resource_id)
raise HTTPException(503, detail={"code": "release_failed"})
diff --git a/src/backend/features/workbench/revocation.py b/src/backend/features/workbench/revocation.py
index be2466e0..d2f60785 100644
--- a/src/backend/features/workbench/revocation.py
+++ b/src/backend/features/workbench/revocation.py
@@ -3,6 +3,7 @@
from features.workbench.coordination import runtime
+from core.db.threads import run_in_session
import asyncio
import logging
from copy import deepcopy
@@ -106,13 +107,8 @@ def _external_tickets(db, user_id):
)
-async def revoke_user_workbench(db, user_id, session_hash=None):
- """Revoke all user-owned credentials atomically; then attempt every worker.
-
- Resources and grants are user-owned, not browser-session-owned. Logout
- therefore revokes all matching host tickets, including other tab tickets.
- Third-party sessions remain outside this host boundary.
- """
+def _revoke_credentials(db, user_id):
+ """Revoke every user-owned credential in one transaction; return the live rows."""
_lock(db, user_id)
rows = (
db.query(PluginResource)
@@ -128,7 +124,20 @@ async def revoke_user_workbench(db, user_id, session_hash=None):
_revoke_worker_credentials(db, user_id, rows)
# No external shutdown or independently committing helper before this barrier.
db.commit()
- return await _close_workers(db, rows)
+ for row in rows:
+ db.refresh(row)
+ db.expunge(row)
+ return rows
+
+
+async def revoke_user_workbench(user_id, session_hash=None):
+ """Revoke all user-owned credentials atomically; then attempt every worker.
+
+ Resources and grants are user-owned, not browser-session-owned. Logout
+ therefore revokes all matching host tickets, including other tab tickets.
+ Third-party sessions remain outside this host boundary.
+ """
+ return await _close_workers(await run_in_session(_revoke_credentials, user_id))
def stage_unavailable(db, user_id):
@@ -231,19 +240,18 @@ def _release_background_reservation(db, row):
db.commit()
-async def _close_workers(db, rows):
+async def _close_workers(rows):
+ """Stop each worker, then release its reservation; no session spans a shutdown."""
closed = 0
failures = []
- # Never share a database session across concurrent close operations.
for row in rows:
try:
- await asyncio.wait_for(service.close(db, row), timeout=10)
- _release_background_reservation(db, row)
+ await asyncio.wait_for(service.close(row), timeout=10)
+ await run_in_session(_release_background_reservation, row)
closed += 1
except Exception as exc:
- db.rollback()
failures.append(type(exc).__name__)
if failures:
logging.getLogger(__name__).warning("Workbench worker cleanup deferred: %s", len(failures))
# Authorization is already revoked; deferred does not mean OS-process gone.
- return {"closed": closed, "deferred": len(failures)}
+ return {"closed": closed, "deferred": len(failures)}
\ No newline at end of file
diff --git a/src/backend/features/workbench/revocation_sweep.py b/src/backend/features/workbench/revocation_sweep.py
index 8ff5ddd0..dd1bf337 100644
--- a/src/backend/features/workbench/revocation_sweep.py
+++ b/src/backend/features/workbench/revocation_sweep.py
@@ -7,6 +7,7 @@
from features.workbench.revocation import EXTERNAL_PREFIXES, stage_unavailable, _close_workers
from core.db.engine import SessionLocal
+from core.db.threads import run_in_session
from core.db.models import ContentBlock
from core.db.models.plugin_resource import PluginResource
@@ -46,19 +47,24 @@ def stage(user_id):
return stage_unavailable(db, user_id)
+def _owner_rows(db, user_id, resource_ids):
+ rows = (
+ db.query(PluginResource)
+ .filter(
+ PluginResource.user_id == user_id,
+ PluginResource.resource_id.in_(resource_ids),
+ )
+ .order_by(PluginResource.resource_id)
+ .all()
+ )
+ for row in rows:
+ db.expunge(row)
+ return rows
+
+
async def reconcile_owner(user_id):
resource_ids = await run_in_threadpool(stage, user_id)
- with SessionLocal() as db:
- rows = (
- db.query(PluginResource)
- .filter(
- PluginResource.user_id == user_id,
- PluginResource.resource_id.in_(resource_ids),
- )
- .order_by(PluginResource.resource_id)
- .all()
- )
- return await _close_workers(db, rows)
+ return await _close_workers(await run_in_session(_owner_rows, user_id, resource_ids))
async def sweep(after="", limit=100):
diff --git a/src/backend/integrations/api/integrations.py b/src/backend/integrations/api/integrations.py
index 3ec0a8e3..51724b67 100644
--- a/src/backend/integrations/api/integrations.py
+++ b/src/backend/integrations/api/integrations.py
@@ -13,10 +13,8 @@
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
-from sqlalchemy.orm import Session
from core.auth.backend import UserContext, get_current_user
-from core.db.engine import get_db
from core.infra.responses import success_response
from integrations.dingtalk.service import DingTalkService
from integrations.email.service import EmailService
@@ -30,19 +28,17 @@
async def dingtalk_status(
probe: bool = Query(False, description="true 时到沙箱实时核对登录态并对账"),
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = DingTalkService(db)
- data = await svc.probe_status(str(user.user_id)) if probe else svc.get_status(str(user.user_id))
+ svc = DingTalkService()
+ data = await svc.probe_status(str(user.user_id)) if probe else await svc.get_status(str(user.user_id))
return success_response(data=data)
@router.post("/dingtalk/login", summary="发起钉钉设备流登录")
async def dingtalk_login(
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = DingTalkService(db)
+ svc = DingTalkService()
data = await svc.start_device_login(str(user.user_id))
return success_response(data=data)
@@ -50,9 +46,8 @@ async def dingtalk_login(
@router.post("/dingtalk/login/poll", summary="轮询钉钉登录是否完成")
async def dingtalk_login_poll(
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = DingTalkService(db)
+ svc = DingTalkService()
data = await svc.poll_login(str(user.user_id))
return success_response(data=data)
@@ -60,9 +55,8 @@ async def dingtalk_login_poll(
@router.post("/dingtalk/disconnect", summary="断开钉钉连接并清除凭据")
async def dingtalk_disconnect(
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = DingTalkService(db)
+ svc = DingTalkService()
data = await svc.disconnect(str(user.user_id))
return success_response(data=data)
@@ -74,19 +68,17 @@ async def dingtalk_disconnect(
async def lark_status(
probe: bool = Query(False, description="true 时真实 API 探活并对账"),
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = LarkService(db)
- data = await svc.probe_status(str(user.user_id)) if probe else svc.get_status(str(user.user_id))
+ svc = LarkService()
+ data = await svc.probe_status(str(user.user_id)) if probe else await svc.get_status(str(user.user_id))
return success_response(data=data)
@router.post("/lark/login", summary="发起飞书设备流登录(扫码)")
async def lark_login(
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = LarkService(db)
+ svc = LarkService()
data = await svc.start_device_login(str(user.user_id))
return success_response(data=data)
@@ -94,9 +86,8 @@ async def lark_login(
@router.post("/lark/login/poll", summary="轮询飞书扫码登录是否完成")
async def lark_login_poll(
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = LarkService(db)
+ svc = LarkService()
data = await svc.poll_login(str(user.user_id))
return success_response(data=data)
@@ -104,9 +95,8 @@ async def lark_login_poll(
@router.post("/lark/disconnect", summary="断开飞书连接并清除凭据")
async def lark_disconnect(
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = LarkService(db)
+ svc = LarkService()
data = await svc.disconnect(str(user.user_id))
return success_response(data=data)
@@ -188,10 +178,9 @@ class _EmailConnectRequest(BaseModel):
async def email_status(
probe: bool = Query(False, description="true 时真实连通性探活并对账"),
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = EmailService(db)
- data = await svc.probe_status(str(user.user_id)) if probe else svc.get_status(str(user.user_id))
+ svc = EmailService()
+ data = await svc.probe_status(str(user.user_id)) if probe else await svc.get_status(str(user.user_id))
return success_response(data=data)
@@ -199,9 +188,8 @@ async def email_status(
async def email_connect(
body: _EmailConnectRequest,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = EmailService(db)
+ svc = EmailService()
data = await svc.connect(
str(user.user_id),
email_address=body.email_address,
@@ -215,8 +203,7 @@ async def email_connect(
@router.post("/email/disconnect", summary="断开电子邮箱连接并清除凭据")
async def email_disconnect(
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = EmailService(db)
+ svc = EmailService()
data = await svc.disconnect(str(user.user_id))
return success_response(data=data)
diff --git a/src/backend/integrations/channels/api/channel_desktop.py b/src/backend/integrations/channels/api/channel_desktop.py
index f4e8782c..498c16d3 100644
--- a/src/backend/integrations/channels/api/channel_desktop.py
+++ b/src/backend/integrations/channels/api/channel_desktop.py
@@ -3,6 +3,7 @@
from features.desktop.api.capability_dependencies import require_capability_user as _require_capability_user
from core.auth.backend import UserContext, get_current_user
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import success_response
from integrations.channels.relay import ChannelRelayService
from fastapi import APIRouter, Depends, Request
@@ -77,24 +78,30 @@ def renew(
return success_response(data={"ok": True})
+def _complete(db, owner, device_id, delivery_id, body):
+ svc = ChannelRelayService(db)
+ row, conn = svc.authorized(owner, device_id, delivery_id, body.lease)
+ payload = row.payload
+ db.expunge(conn)
+ svc.complete(owner, device_id, delivery_id, body.lease, body.error)
+ return payload, conn
+
+
@router.post("/{delivery_id}/complete")
async def complete(
delivery_id: str,
body: Lease,
request: Request,
owner: str = Depends(_require_capability_user),
- db: Session = Depends(get_db),
):
- svc = ChannelRelayService(db)
- row, conn = svc.authorized(owner, device(request), delivery_id, body.lease)
- svc.complete(owner, device(request), delivery_id, body.lease, body.error)
+ payload, conn = await run_in_session(_complete, owner, device(request), delivery_id, body)
if body.error:
import logging
from integrations.channels.protocol import InboundMsg
from integrations.channels.registry import get_adapter
- msg = InboundMsg(**row.payload["message"])
+ msg = InboundMsg(**payload["message"])
if msg.addressed_to_bot is not False:
try:
await get_adapter(conn.channel_type).send_text(
@@ -113,12 +120,10 @@ async def operation(
body: Operation,
request: Request,
owner: str = Depends(_require_capability_user),
- db: Session = Depends(get_db),
):
from integrations.channels.relay_rpc import channel_operation
data = await channel_operation(
- db,
owner,
device(request),
delivery_id,
@@ -131,9 +136,7 @@ async def operation(
@router.post("/local-binding")
-async def local_binding(
- user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)
-):
+async def local_binding(user: UserContext = Depends(get_current_user)):
from integrations.channels.desktop_worker import prepare_binding
- return success_response(data=await prepare_binding(db, str(user.user_id)))
+ return success_response(data=await prepare_binding(str(user.user_id)))
diff --git a/src/backend/integrations/channels/api/channels.py b/src/backend/integrations/channels/api/channels.py
index 7422d28d..c65bedf8 100644
--- a/src/backend/integrations/channels/api/channels.py
+++ b/src/backend/integrations/channels/api/channels.py
@@ -21,6 +21,7 @@
from integrations.channels.protocol import ChannelCaps
from integrations.channels.registry import get_adapter, list_adapters
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import created_response, success_response
from integrations.channels.service import ChannelService, bot_to_dict
@@ -130,13 +131,13 @@ def list_my_conversations(
async def create_bot(
body: CreateBotRequest,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
from core.infra.exceptions import BadRequestError
+ from integrations.channels.service import create_bot as register
+
if (body.execution_location == "local") != bool(body.local_binding_id):
raise BadRequestError("选择本机需要当前桌面设备授权")
- svc = ChannelService(db)
- conn = await svc.create_bot(
+ conn = await register(
str(user.user_id),
channel_type=body.channel_type,
app_id=body.app_id,
@@ -195,10 +196,10 @@ def delete_bot(
async def test_bot(
channel_id: str,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = ChannelService(db)
- data = await svc.test_bot(str(user.user_id), channel_id)
+ from integrations.channels.service import test_bot as check
+
+ data = await check(str(user.user_id), channel_id)
return success_response(data=data)
@@ -209,13 +210,13 @@ async def weixin_bind_start(
execution_location: Literal["cloud", "local"] = "cloud",
local_binding_id: Optional[str] = None,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = ChannelService(db)
from core.infra.exceptions import BadRequestError
+ from integrations.channels.service import start_weixin_bind
+
if (execution_location == "local") != bool(local_binding_id):
raise BadRequestError("选择本机需要当前桌面设备授权")
- data = await svc.start_weixin_bind(str(user.user_id), agent_id=agent_id, local_binding_id=local_binding_id)
+ data = await start_weixin_bind(str(user.user_id), agent_id=agent_id, local_binding_id=local_binding_id)
return success_response(data=data)
@@ -223,10 +224,10 @@ async def weixin_bind_start(
async def weixin_bind_status(
bind_id: str,
user: UserContext = Depends(get_current_user),
- db: Session = Depends(get_db),
):
- svc = ChannelService(db)
- data = await svc.poll_weixin_bind(str(user.user_id), bind_id)
+ from integrations.channels.service import poll_weixin_bind
+
+ data = await poll_weixin_bind(str(user.user_id), bind_id)
return success_response(data=data)
@@ -248,7 +249,6 @@ def channel_webhook_verify(
async def channel_webhook(
channel_id: str,
request: Request,
- db: Session = Depends(get_db),
):
body = await request.body()
# Signature params: Lark uses headers (x-lark-*), WeCom uses the query string
@@ -257,8 +257,14 @@ async def channel_webhook(
headers = {k.lower(): v for k, v in request.headers.items()}
for k, v in request.query_params.items():
headers[k.lower()] = v
- svc = ChannelService(db)
- result = svc.handle_webhook(channel_id, headers, body)
+ result, inbound = await run_in_session(
+ lambda db: ChannelService(db).handle_webhook(channel_id, headers, body)
+ )
+ if inbound is not None:
+ from core.infra.background import spawn
+ from integrations.channels.inbound import handle_inbound
+
+ spawn(handle_inbound(inbound), name="channel.handle_inbound")
# The webhook response must be the bare JSON the channel expects (e.g. Lark url_verification's
# challenge), not wrapped in an envelope.
return JSONResponse(content=result)
diff --git a/src/backend/integrations/channels/desktop_worker.py b/src/backend/integrations/channels/desktop_worker.py
index 6acaab97..d2f26306 100644
--- a/src/backend/integrations/channels/desktop_worker.py
+++ b/src/backend/integrations/channels/desktop_worker.py
@@ -12,6 +12,7 @@
from integrations.channels.protocol import ChannelCaps, HistoryItem, InboundMsg, SendResult
from integrations.channels.models import ChannelConnection
from core.db.models import ContentBlock, UserShadow
+from core.db.threads import run_db, run_in_session
from core.infra.exceptions import AccessDeniedError, BadRequestError
from features.desktop.auth import bridge as bridge_auth
from integrations.channels.relay_rpc import pack_bytes, unpack_bytes
@@ -67,8 +68,18 @@ def identity_owner(db):
return identity, owner
-async def prepare_binding(db, owner):
- identity, actual_owner = identity_owner(db)
+def _store_grant(db, captured, grant, identity, owner):
+ with bridge_identity.account_scope(captured):
+ db.add(
+ ContentBlock(
+ id=GRANT_PREFIX + grant["binding_id"], payload={**identity, "local_owner": owner}
+ )
+ )
+ db.commit()
+
+
+async def prepare_binding(owner):
+ identity, actual_owner = await run_in_session(identity_owner)
if owner != actual_owner:
raise AccessDeniedError("本机账号不匹配")
captured = bridge_identity.get_state()
@@ -81,13 +92,7 @@ async def prepare_binding(db, owner):
or not isinstance(grant.get("device_name"), str)
):
raise BadRequestError("本机授权响应格式无效,请重试")
- with bridge_identity.account_scope(captured):
- db.add(
- ContentBlock(
- id=GRANT_PREFIX + grant["binding_id"], payload={**identity, "local_owner": owner}
- )
- )
- db.commit()
+ await run_in_session(_store_grant, captured, grant, identity, owner)
return grant
@@ -197,13 +202,9 @@ async def recall_message(self, conn, msg, message_id):
return SendResult(**await self.call("recall_message", {"message_id": message_id}))
-async def run_delivery(factory, captured, identity, owner, item):
- from integrations.channels.inbound import handle_inbound
- from integrations.channels.registry import scoped_adapter
+def _accept_delivery(factory, identity, owner, item, receipt_id):
from core.capabilities.agents.edition import UserAgentService
- bridge_identity.require_current_account(captured)
- receipt_id = RECEIPT_PREFIX + item["delivery_id"]
with factory() as db:
if item["binding_id"] not in local_grants(db, identity, owner):
raise AccessDeniedError("机器人尚未在这台电脑授权")
@@ -245,6 +246,23 @@ async def run_delivery(factory, captured, identity, owner, item):
)
)
db.commit()
+ return local_id
+
+
+def _finish_receipt(factory, receipt_id, binding_id, error):
+ with factory() as db:
+ receipt = db.get(ContentBlock, receipt_id)
+ receipt.payload = {"state": "failed" if error else "completed", "binding_id": binding_id}
+ db.commit()
+
+
+async def run_delivery(factory, captured, identity, owner, item):
+ from integrations.channels.inbound import handle_inbound
+ from integrations.channels.registry import scoped_adapter
+
+ bridge_identity.require_current_account(captured)
+ receipt_id = RECEIPT_PREFIX + item["delivery_id"]
+ local_id = await run_db(_accept_delivery, factory, identity, owner, item, receipt_id)
msg = InboundMsg(**{**item["message"], "channel_id": local_id})
adapter = DesktopChannelAdapter(captured, item, local_id)
with scoped_adapter(adapter):
@@ -262,14 +280,23 @@ async def run_delivery(factory, captured, identity, owner, item):
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await adapter.cancel()
+ await run_db(_finish_receipt, factory, receipt_id, item["binding_id"], adapter.error)
+ return adapter.error
+
+
+def _claim_inputs(factory):
with factory() as db:
- receipt = db.get(ContentBlock, receipt_id)
- receipt.payload = {
- "state": "failed" if adapter.error else "completed",
- "binding_id": item["binding_id"],
- }
+ identity, owner = identity_owner(db)
+ return identity, owner, local_grants(db, identity, owner)
+
+
+def _forget_grants(factory, captured, binding_ids):
+ with bridge_identity.account_scope(captured), factory() as db:
+ for binding_id in binding_ids:
+ row = db.get(ContentBlock, GRANT_PREFIX + binding_id)
+ if row:
+ db.delete(row)
db.commit()
- return adapter.error
class ChannelDesktopWorker:
@@ -290,20 +317,13 @@ async def loop(self):
captured = None
item = None
try:
- with self.factory() as db:
- identity, owner = identity_owner(db)
- bindings = local_grants(db, identity, owner)
+ identity, owner, bindings = await run_db(_claim_inputs, self.factory)
captured = bridge_identity.get_state()
for offset in range(0, len(bindings), 200):
batch = bindings[offset : offset + 200]
reply = await cloud_post(captured, "claim", {"binding_ids": batch})
retained = set(reply["retained_binding_ids"])
- with bridge_identity.account_scope(captured), self.factory() as db:
- for binding_id in set(batch) - retained:
- row = db.get(ContentBlock, GRANT_PREFIX + binding_id)
- if row:
- db.delete(row)
- db.commit()
+ await run_db(_forget_grants, self.factory, captured, set(batch) - retained)
item = reply["delivery"]
if item:
error = await run_delivery(self.factory, captured, identity, owner, item)
diff --git a/src/backend/integrations/channels/inbound.py b/src/backend/integrations/channels/inbound.py
index f57f0887..60e9bf1e 100644
--- a/src/backend/integrations/channels/inbound.py
+++ b/src/backend/integrations/channels/inbound.py
@@ -8,6 +8,7 @@
from integrations.channels.models import ChannelConnection
from integrations.channels.repository import ChannelConnectionRepository
import logging
+from core.db.threads import run_db, run_in_session
logger = logging.getLogger(__name__)
from integrations.channels import inbound_attachments
from integrations.channels import inbound_delivery
@@ -43,32 +44,152 @@ async def handle_inbound(msg: InboundMsg) -> None:
await process_inbound(msg)
-async def process_inbound(msg: InboundMsg) -> None:
+def _inbound_target(db, channel_id: str) -> Optional[ChannelConnection]:
+ conn = ChannelConnectionRepository(db).get_by_id(channel_id)
+ if conn is None or not conn.enabled:
+ return None
+ _ = conn.config # force-load the credential column so it remains usable after detaching
+ db.expunge(conn)
+ return conn
+
+
+def _observe(db, channel_id: str, msg: InboundMsg) -> None:
+ repo = ChannelConnectionRepository(db)
+ conn = repo.get_by_id(channel_id)
+ inbound_observation.observe_message(db, conn, msg)
+ repo.touch_event(conn.channel_id) # bystander traffic still proves the connection is alive
+
+
+def _reset(db, channel_id: str, msg: InboundMsg) -> None:
+ conn = ChannelConnectionRepository(db).get_by_id(channel_id)
+ inbound_sessions.reset_session(db, conn, msg)
+
+
+def _session_for(db, channel_id: str, msg: InboundMsg) -> str:
+ conn = ChannelConnectionRepository(db).get_by_id(channel_id)
+ return inbound_sessions.find_or_create_session(db, conn, msg).chat_id
+
+
+def _drain_observed(db, chat_id: str):
+ from core.db.models import ChatSession
+
+ return inbound_observation.drain_observed(db, db.get(ChatSession, chat_id))
+
+
+def _admit_run(db, msg: InboundMsg, chat_id: str, user_text: str, uploaded_files):
+ """Build the run context and accept the run; returns everything the launch needs, detached."""
from core.chat.context import build_runtime_context, collect_historical_attachments
- pass
+ from core.chat.sequencer import ChatSequencer
+ from core.ontology.runtime_policy import build_user_ontology_runtime
+
+ repo = ChannelConnectionRepository(db)
+ conn = repo.get_by_id(msg.channel_id)
+ owner_id = conn.owner_user_id
+ enabled = inbound_sessions.resolve_enabled(db, conn, owner_id)
+ # Enable thinking: same as the web client's default in non-fast mode. With thinking off,
+ # models (Qwen family especially) tend to emit shallow filler like "I'll get right on X"
+ # without actually landing on tool calls, idling repeatedly across turns. Thinking never
+ # leaks into the channel reply: structured-reasoning models emit separate thinking events
+ # (ignored by _collect_reply, which only accumulates content/meta), and inline-thinking
+ # models' … spans inside content are stripped by _collect_reply.
+ context = build_runtime_context(
+ model_name=None, user_id=owner_id, chat_id=chat_id, enable_thinking=True,
+ uploaded_files=uploaded_files,
+ enabled_skills=enabled["enabled_skills"], enabled_agents=enabled["enabled_agents"],
+ enabled_mcps=enabled["enabled_mcps"], enabled_kbs=enabled["enabled_kbs"],
+ )
+ ontology_enabled, ontology_runtime = build_user_ontology_runtime(
+ user_id=owner_id,
+ task=user_text,
+ db=db,
+ )
+ context["ontology_enabled"] = ontology_enabled
+ context["ontology_runtime"] = ontology_runtime
+ # Cross-turn file readability: inject files uploaded/generated earlier in this session
+ # (including last turn's Feishu attachments) as a summary block so the model gets real
+ # file_ids to call read_artifact with. Exclude this turn's attachments (already injected
+ # in full via uploaded_files) to avoid duplication. build_runtime_context doesn't include
+ # this field, so we add it here (aligned with the web client's historical_files injection
+ # in chats.py).
+ current_file_ids = {f.get("file_id") for f in uploaded_files if f.get("file_id")}
+ context["historical_files"] = collect_historical_attachments(
+ chat_id, owner_id, exclude_file_ids=current_file_ids,
+ )
+ # Bind to a specific sub-agent: pin the whole run to that sub-agent (running with its own
+ # prompt/tools/model/knowledge bases), via the workflow's direct sub-agent mode. NULL →
+ # unset, run with the owner's default capabilities (main agent).
+ # Note: the sub-agent carries its own capability bindings, which override the whitelist
+ # narrowing from _resolve_enabled above — this is intended behavior.
+ direct_agent_id = conn.agent_id or (conn.config or {}).get("desktop_relay_agent_id")
+ if direct_agent_id:
+ context["agent_id"] = direct_agent_id
+ # #7 Trigger A: let the agent self-create scheduled delivery tasks within this conversation
+ context["channel_origin"] = {
+ "channel_id": conn.channel_id,
+ "conversation_id": msg.external_conversation_id,
+ "chat_type": msg.chat_type,
+ # channel_type lets the agent be told *which* platform it is in, so it can reach
+ # for that platform's CLI to pull group history the bot never received (anything
+ # from before it joined). Delivery targets never needed it; the prompt does.
+ "channel_type": conn.channel_type,
+ }
+ accepted = ChatSequencer(db).accept_main_run(
+ chat_id=chat_id,
+ user_id=owner_id,
+ user_content=user_text,
+ user_extra_data={
+ "channel_sender_open_id": msg.sender_id,
+ "channel_sender_name": msg.sender_name,
+ "channel_message_id": msg.message_id,
+ "channel_attachments": [f.get("file_id") for f in uploaded_files],
+ "attachments": [
+ {"file_id": f.get("file_id"), "name": f.get("name")} for f in uploaded_files
+ ],
+ },
+ request_payload={
+ "channel_id": msg.channel_id,
+ "source": "desktop_channel" if (conn.config or {}).get("desktop_relay") else "channel",
+ "kind": "chat",
+ },
+ )
+ run_id = accepted.run.run_id
+ try:
+ session_messages = inbound_sessions.load_history(db, chat_id, owner_id)
+ repo.touch_event(conn.channel_id)
+ # The commits above expire the rows' columns; refresh before detaching so the
+ # launch reads them without a session.
+ db.refresh(accepted.run)
+ db.expunge(accepted.run)
+ except Exception:
+ db.rollback()
+ ChatSequencer(db).abandon_pending_run(run_id, reason="channel preparation failed")
+ raise
+ return accepted.run, context, session_messages
+
+def _abandon(db, run_id: str, reason: str) -> None:
+ from core.chat.sequencer import ChatSequencer
+
+ ChatSequencer(db).abandon_pending_run(run_id, reason=reason)
+
+
+async def process_inbound(msg: InboundMsg) -> None:
placeholder_id: Optional[str] = None
- accepted_run_id: Optional[str] = None
- db = SessionLocal()
try:
- repo = ChannelConnectionRepository(db)
- conn = repo.get_by_id(msg.channel_id)
- if conn is None or not conn.enabled:
+ conn = await run_in_session(_inbound_target, msg.channel_id)
+ if conn is None:
logger.warning("[channels] inbound 无对应启用连接 channel_id=%s", msg.channel_id)
- db.close()
return
owner_id = conn.owner_user_id
adapter = get_adapter(conn.channel_type)
- _ = conn.config # force-load the credential column so it remains usable after detaching
# Group listening gate. Only reached at all when the platform actually delivers non-@
# group messages (needs the channel app's "read all group messages" permission);
# otherwise every inbound group message is an @ and this is a no-op.
if msg.chat_type == "group" and not await inbound_observation.resolve_addressed(adapter, conn, msg):
if conn.group_listen_mode == "observe_all":
- inbound_observation.observe_message(db, conn, msg)
- repo.touch_event(conn.channel_id) # bystander traffic still proves the connection is alive
+ await run_in_session(_observe, conn.channel_id, msg)
logger.info(
"[channels] 旁听群消息 channel_id=%s type=%s conv=%s",
conn.channel_id, conn.channel_type, msg.external_conversation_id,
@@ -85,13 +206,10 @@ async def process_inbound(msg: InboundMsg) -> None:
conn.channel_id, conn.channel_type,
)
# Either way: no reset-command handling, no run, no placeholder, no reply.
- db.close()
return
- admit = module_route(db, conn)
+ admit = await run_in_session(module_route, conn)
if admit is not None:
- db.expunge(conn)
- db.close()
await admit(conn, msg, adapter)
return
@@ -101,18 +219,14 @@ async def process_inbound(msg: InboundMsg) -> None:
# otherwise the command would be fed to the agent as plain text and the history would keep
# being reused, impossible to clear.
if inbound_sessions.is_reset_command(msg.text):
- inbound_sessions.reset_session(db, conn, msg)
- db.refresh(conn)
- db.expunge(conn)
- db.close()
+ await run_in_session(_reset, conn.channel_id, msg)
try:
await adapter.send_text(conn, msg, "✅ 已开启新对话,之前的上下文已清除。")
except Exception: # noqa: BLE001
logger.debug("[channels] 清空回执发送失败", exc_info=True)
return
- session = inbound_sessions.find_or_create_session(db, conn, msg)
- chat_id = session.chat_id
+ chat_id = await run_in_session(_session_for, conn.channel_id, msg)
# #4 Placeholder first. Everything below this line does real network work —
# downloading attachments, pulling group history — and the placeholder exists
@@ -131,14 +245,13 @@ async def process_inbound(msg: InboundMsg) -> None:
logger.debug("[channels] 占位消息发送失败", exc_info=True)
# Inbound attachments: download → store as Artifact → uploaded_files
- uploaded_files = await inbound_attachments.ingest_attachments(db, adapter, conn, owner_id, chat_id, msg)
+ uploaded_files = await inbound_attachments.ingest_attachments(adapter, conn, owner_id, chat_id, msg)
# File-only message with no text → synthesize a one-line user prompt
if (msg.text or "").strip():
user_text = msg.text
elif uploaded_files:
user_text = "[收到文件] " + "、".join(f.get("name", "") for f in uploaded_files)
else:
- db.close()
return
# #5 Label the speaker in group scenarios so the agent can tell multi-person conversations apart
if msg.chat_type == "group":
@@ -146,114 +259,24 @@ async def process_inbound(msg: InboundMsg) -> None:
# Deterministic incremental pull, before draining: whatever the platform pushed
# (possibly nothing) plus whatever we can pull ends up in the same buffer, and the
# drain below sees both. Failure is absorbed inside — it must not block the reply.
- await inbound_history.pull_history(db, adapter, conn, msg, session)
+ await inbound_history.pull_history(adapter, conn, msg, chat_id)
# observe_all: prepend everything the group said since the last @ as background.
# Prepending into user_text (rather than a separate context field) means it is
# persisted with the turn, so later turns still see the group log through normal
# history loading and compaction — no second retention mechanism to maintain.
- observed = inbound_observation.drain_observed(db, session)
+ observed = await run_in_session(_drain_observed, chat_id)
if observed:
user_text = inbound_observation.render_observed_block(observed) + user_text
- enabled = inbound_sessions.resolve_enabled(db, conn, owner_id)
- # Enable thinking: same as the web client's default in non-fast mode. With thinking off,
- # models (Qwen family especially) tend to emit shallow filler like "I'll get right on X"
- # without actually landing on tool calls, idling repeatedly across turns. Thinking never
- # leaks into the channel reply: structured-reasoning models emit separate thinking events
- # (ignored by _collect_reply, which only accumulates content/meta), and inline-thinking
- # models' … spans inside content are stripped by _collect_reply.
- context = build_runtime_context(
- model_name=None, user_id=owner_id, chat_id=chat_id, enable_thinking=True,
- uploaded_files=uploaded_files,
- enabled_skills=enabled["enabled_skills"], enabled_agents=enabled["enabled_agents"],
- enabled_mcps=enabled["enabled_mcps"], enabled_kbs=enabled["enabled_kbs"],
- )
- from core.ontology.runtime_policy import build_user_ontology_runtime
-
- ontology_enabled, ontology_runtime = build_user_ontology_runtime(
- user_id=owner_id,
- task=user_text,
- db=db,
- )
- context["ontology_enabled"] = ontology_enabled
- context["ontology_runtime"] = ontology_runtime
- # Cross-turn file readability: inject files uploaded/generated earlier in this session
- # (including last turn's Feishu attachments) as a summary block so the model gets real
- # file_ids to call read_artifact with. Exclude this turn's attachments (already injected
- # in full via uploaded_files) to avoid duplication. build_runtime_context doesn't include
- # this field, so we add it here (aligned with the web client's historical_files injection
- # in chats.py).
- current_file_ids = {f.get("file_id") for f in uploaded_files if f.get("file_id")}
- context["historical_files"] = collect_historical_attachments(
- chat_id, owner_id, exclude_file_ids=current_file_ids,
+ accepted_run, context, session_messages = await run_in_session(
+ _admit_run, msg, chat_id, user_text, uploaded_files
)
- # Bind to a specific sub-agent: pin the whole run to that sub-agent (running with its own
- # prompt/tools/model/knowledge bases), via the workflow's direct sub-agent mode. NULL →
- # unset, run with the owner's default capabilities (main agent).
- # Note: the sub-agent carries its own capability bindings, which override the whitelist
- # narrowing from _resolve_enabled above — this is intended behavior.
- direct_agent_id = conn.agent_id or (conn.config or {}).get("desktop_relay_agent_id")
- if direct_agent_id:
- context["agent_id"] = direct_agent_id
- # #7 Trigger A: let the agent self-create scheduled delivery tasks within this conversation
- context["channel_origin"] = {
- "channel_id": conn.channel_id,
- "conversation_id": msg.external_conversation_id,
- "chat_type": msg.chat_type,
- # channel_type lets the agent be told *which* platform it is in, so it can reach
- # for that platform's CLI to pull group history the bot never received (anything
- # from before it joined). Delivery targets never needed it; the prompt does.
- "channel_type": conn.channel_type,
- }
- from core.chat.sequencer import ChatSequencer
-
- accepted = ChatSequencer(db).accept_main_run(
- chat_id=chat_id,
- user_id=owner_id,
- user_content=user_text,
- user_extra_data={
- "channel_sender_open_id": msg.sender_id,
- "channel_sender_name": msg.sender_name,
- "channel_message_id": msg.message_id,
- "channel_attachments": [f.get("file_id") for f in uploaded_files],
- "attachments": [
- {"file_id": f.get("file_id"), "name": f.get("name")} for f in uploaded_files
- ],
- },
- request_payload={
- "channel_id": msg.channel_id,
- "source": "desktop_channel" if (conn.config or {}).get("desktop_relay") else "channel",
- "kind": "chat",
- },
- )
- accepted_run_id = accepted.run.run_id
- session_messages = inbound_sessions.load_history(db, chat_id, owner_id)
- repo.touch_event(conn.channel_id)
- # The commits in create_session / add_message / touch_event expire conn's column
- # attributes (expire_on_commit defaults to True). If we expunged as-is, reading app_id/
- # config during push after detaching would trigger a refresh and raise
- # DetachedInstanceError ("is not bound to a Session").
- # Refresh first to reload all columns, then expunge — afterwards attribute reads are
- # pure in-memory and no session is needed.
- db.refresh(accepted.run)
- db.refresh(conn)
- db.expunge(accepted.run)
- db.expunge(conn) # app_id/config remain readable after leaving the session, for push
except Exception:
logger.exception("[channels] inbound 准备阶段失败 channel_id=%s", msg.channel_id)
from integrations.channels.registry import current_relay_adapter
if current_relay_adapter():
current_relay_adapter().error = "本机机器人准备失败"
- db.close()
- if accepted_run_id:
- with SessionLocal() as cleanup_db:
- ChatSequencer(cleanup_db).abandon_pending_run(
- accepted_run_id,
- reason="channel preparation failed",
- )
return
- finally:
- db.close()
# (The placeholder is sent inside the prep stage above, before attachment download and
# history pull — those are the slow parts it needs to cover.)
@@ -274,7 +297,7 @@ async def process_inbound(msg: InboundMsg) -> None:
try:
run = await admission.start_run(
- accepted_run=accepted.run,
+ accepted_run=accepted_run,
chat_id=chat_id,
user_id=owner_id,
session_messages=session_messages,
@@ -284,11 +307,7 @@ async def process_inbound(msg: InboundMsg) -> None:
model_name=None,
)
except Exception as exc:
- with SessionLocal() as cleanup_db:
- ChatSequencer(cleanup_db).abandon_pending_run(
- accepted_run_id,
- reason=str(exc),
- )
+ await run_in_session(_abandon, accepted_run.run_id, str(exc))
logger.exception("[channels] inbound run 启动失败 channel_id=%s", msg.channel_id)
from integrations.channels.registry import current_relay_adapter
if current_relay_adapter():
@@ -337,7 +356,9 @@ async def process_inbound(msg: InboundMsg) -> None:
note = "已为你生成文件。" if gen_artifacts else "(本次无文本回复)"
await inbound_delivery.replace_placeholder(adapter, conn, msg, placeholder_id, note)
if getattr(adapter, "push_file", None):
- for content, name, mime in inbound_attachments.load_generated_files(gen_artifacts):
+ for content, name, mime in await run_db(
+ inbound_attachments.load_generated_files, gen_artifacts
+ ):
fr = await adapter.push_file(conn, msg, content, name, mime)
if not fr.success:
logger.warning("[channels] 文件回传失败 name=%s kind=%s", name, fr.error_kind)
diff --git a/src/backend/integrations/channels/inbound_attachments.py b/src/backend/integrations/channels/inbound_attachments.py
index e2183363..66e83eeb 100644
--- a/src/backend/integrations/channels/inbound_attachments.py
+++ b/src/backend/integrations/channels/inbound_attachments.py
@@ -5,13 +5,25 @@
from integrations.channels.protocol import InboundMsg
from core.db.engine import SessionLocal
from core.db.models import Artifact
+from core.db.threads import run_in_session
from integrations.channels.models import ChannelConnection
import logging
logger = logging.getLogger(__name__)
from integrations.channels import inbound_state
+def _store(db, owner_id, content, name, mime, chat_id, channel_id) -> str:
+ from core.files.artifacts.service import store_bytes_as_artifact
+
+ art = store_bytes_as_artifact(
+ db, user_id=owner_id, content=content, filename=name, mime_type=mime,
+ chat_id=chat_id, source="channel_upload",
+ extra={"channel_id": channel_id},
+ )
+ return art.artifact_id
+
+
async def ingest_attachments(
- db, adapter, conn: ChannelConnection, owner_id: str, chat_id: str, msg: InboundMsg
+ adapter, conn: ChannelConnection, owner_id: str, chat_id: str, msg: InboundMsg
) -> List[Dict[str, Any]]:
"""Download inbound attachments → store as Artifacts (mirrors /v1/file/upload) → return uploaded_files items.
@@ -21,7 +33,6 @@ async def ingest_attachments(
download = getattr(adapter, "download_resource", None)
if download is None or not msg.attachments:
return []
- from core.files.artifacts.service import store_bytes_as_artifact
out: List[Dict[str, Any]] = []
for att in msg.attachments:
@@ -31,14 +42,12 @@ async def ingest_attachments(
continue
name = att.get("name") or "file.bin"
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
- art = store_bytes_as_artifact(
- db, user_id=owner_id, content=content, filename=name, mime_type=mime,
- chat_id=chat_id, source="channel_upload",
- extra={"channel_id": conn.channel_id},
+ artifact_id = await run_in_session(
+ _store, owner_id, content, name, mime, chat_id, conn.channel_id
)
out.append({
- "file_id": art.artifact_id,
- "download_url": f"/files/{art.artifact_id}",
+ "file_id": artifact_id,
+ "download_url": f"/files/{artifact_id}",
"name": name,
"mime_type": mime,
})
diff --git a/src/backend/integrations/channels/inbound_history.py b/src/backend/integrations/channels/inbound_history.py
index cdd1b5ca..3f73aee5 100644
--- a/src/backend/integrations/channels/inbound_history.py
+++ b/src/backend/integrations/channels/inbound_history.py
@@ -5,6 +5,7 @@
from typing import Any, Dict, List
from integrations.channels.protocol import InboundMsg
from core.db.models import ChatSession
+from core.db.threads import run_in_session
from integrations.channels.models import ChannelConnection
import logging
logger = logging.getLogger(__name__)
@@ -24,9 +25,20 @@ def save_history_state(db, session: ChatSession, state: Dict[str, Any]) -> None:
db.commit()
-async def pull_history(
- db, adapter, conn: ChannelConnection, msg: InboundMsg, session: ChatSession
-) -> int:
+def _pull_state(db, chat_id: str) -> Dict[str, Any]:
+ return history_state(db.get(ChatSession, chat_id))
+
+
+def _store_pull(
+ db, chat_id: str, state: Dict[str, Any], records: List[Dict[str, Any]], file_index: Dict[str, Any]
+) -> None:
+ session = db.get(ChatSession, chat_id)
+ if records:
+ inbound_observation.record_observed_batch(db, session, records, file_index)
+ save_history_state(db, session, state)
+
+
+async def pull_history(adapter, conn: ChannelConnection, msg: InboundMsg, chat_id: str) -> int:
"""Deterministically pull this group's new messages and fold them into the buffer.
Runs on every @ in a group, driven by code rather than by the model choosing to call a
@@ -43,7 +55,7 @@ async def pull_history(
fetch = getattr(adapter, "fetch_history", None)
if fetch is None:
return 0
- state = history_state(session)
+ state = await run_in_session(_pull_state, chat_id)
if int(state.get("failures") or 0) >= inbound_state.HISTORY_MAX_FAILURES:
return 0
@@ -79,7 +91,7 @@ async def pull_history(
conn.channel_id, msg.external_conversation_id, exc_info=True,
)
state["failures"] = int(state.get("failures") or 0) + 1
- save_history_state(db, session, state)
+ await run_in_session(_store_pull, chat_id, state, [], {})
return 0
records: List[Dict[str, Any]] = []
@@ -128,8 +140,6 @@ async def pull_history(
if cold_start:
# Collected newest→oldest above; the buffer is read as a transcript, so flip it back.
records.reverse()
- if records:
- inbound_observation.record_observed_batch(db, session, records, file_index)
# Advance the cursor even when nothing was recorded: the window was still covered, and
# not advancing would re-request it on every message forever.
state.update({
@@ -138,7 +148,7 @@ async def pull_history(
# Bounded id memory purely for boundary dedup; the cursor does the real work.
"seen_ids": list(seen_ids)[-200:],
})
- save_history_state(db, session, state)
+ await run_in_session(_store_pull, chat_id, state, records, file_index)
if records:
logger.info(
"[channels] 群历史拉取 %d 条 channel_id=%s conv=%s",
diff --git a/src/backend/integrations/channels/manager.py b/src/backend/integrations/channels/manager.py
index 8cd8b4a0..a76d9622 100644
--- a/src/backend/integrations/channels/manager.py
+++ b/src/backend/integrations/channels/manager.py
@@ -19,6 +19,7 @@
import logging
import threading
from typing import Dict, Optional
+from core.db.threads import off_loop
logger = logging.getLogger(__name__)
@@ -54,7 +55,8 @@ async def start_all(self) -> None:
await self._reconcile_connections()
self._reconcile_task = asyncio.create_task(self._reconcile_loop())
- async def _reconcile_connections(self) -> None:
+ @off_loop
+ def _reconcile_connections(self) -> None:
# The elected process alone runs this loop. HTTP workers persist desired
# state; they must not start competing receivers or rely on process memory.
from core.db.engine import SessionLocal
diff --git a/src/backend/integrations/channels/outbound.py b/src/backend/integrations/channels/outbound.py
index c7d663df..fa2ffd56 100644
--- a/src/backend/integrations/channels/outbound.py
+++ b/src/backend/integrations/channels/outbound.py
@@ -17,7 +17,7 @@
from integrations.channels.protocol import InboundMsg
from integrations.channels.registry import get_adapter
-from core.db.engine import SessionLocal
+from core.db.threads import run_in_session
from integrations.channels.repository import ChannelConnectionRepository
logger = logging.getLogger(__name__)
@@ -46,6 +46,36 @@ def _synthetic_msg(
)
+def _delivery_target(db, channel_id: str, conversation_id: str):
+ """The enabled connection (detached) and the conversation profile, or None."""
+ chat_type, peer_id = "group", ""
+ conn = ChannelConnectionRepository(db).get_by_id(channel_id)
+ if conn is None or not conn.enabled:
+ return None
+ _ = conn.config # Force-load the credentials column so it stays usable after detach
+ db.expunge(conn)
+ # Recover the conversation profile (group/one-on-one + p2p peer id)
+ try:
+ from core.db.models import ChatSession
+
+ sess = (
+ db.query(ChatSession)
+ .filter(
+ ChatSession.channel_id == channel_id,
+ ChatSession.external_conversation_id == conversation_id,
+ ChatSession.deleted_at.is_(None),
+ )
+ .first()
+ )
+ if sess is not None:
+ meta = sess.extra_data or {}
+ chat_type = meta.get("channel_chat_type") or "group"
+ peer_id = meta.get("channel_peer_id") or ""
+ except Exception: # noqa: BLE001
+ logger.debug("[channels] 会话画像恢复失败 conv=%s", conversation_id, exc_info=True)
+ return conn, chat_type, peer_id
+
+
async def deliver_to_conversation(
channel_id: str,
conversation_id: str,
@@ -57,33 +87,11 @@ async def deliver_to_conversation(
files: an iterable of (content_bytes, filename, mime_type).
Any step's failure is logged as a warning — delivery failures must be observable, silent drops are not allowed.
"""
- chat_type, peer_id = "group", ""
- with SessionLocal() as db:
- conn = ChannelConnectionRepository(db).get_by_id(channel_id)
- if conn is None or not conn.enabled:
- logger.warning("[channels] 投递目标连接不存在/未启用 channel_id=%s", channel_id)
- return False
- _ = conn.config # Force-load the credentials column so it stays usable after detach
- db.expunge(conn)
- # Recover the conversation profile (group/one-on-one + p2p peer id)
- try:
- from core.db.models import ChatSession
-
- sess = (
- db.query(ChatSession)
- .filter(
- ChatSession.channel_id == channel_id,
- ChatSession.external_conversation_id == conversation_id,
- ChatSession.deleted_at.is_(None),
- )
- .first()
- )
- if sess is not None:
- meta = sess.extra_data or {}
- chat_type = meta.get("channel_chat_type") or "group"
- peer_id = meta.get("channel_peer_id") or ""
- except Exception: # noqa: BLE001
- logger.debug("[channels] 会话画像恢复失败 conv=%s", conversation_id, exc_info=True)
+ target = await run_in_session(_delivery_target, channel_id, conversation_id)
+ if target is None:
+ logger.warning("[channels] 投递目标连接不存在/未启用 channel_id=%s", channel_id)
+ return False
+ conn, chat_type, peer_id = target
adapter = get_adapter(conn.channel_type)
msg = _synthetic_msg(channel_id, conn.channel_type, conversation_id, chat_type, peer_id)
diff --git a/src/backend/integrations/channels/relay_dispatch.py b/src/backend/integrations/channels/relay_dispatch.py
index 5cf7a1e6..11be8984 100644
--- a/src/backend/integrations/channels/relay_dispatch.py
+++ b/src/backend/integrations/channels/relay_dispatch.py
@@ -7,7 +7,7 @@
from features.desktop.auth.bridge import bridge_enabled
from integrations.channels.protocol import InboundMsg
from integrations.channels.registry import get_adapter
-from core.db.engine import SessionLocal
+from core.db.threads import run_db
from integrations.channels.models import ChannelConnection
from integrations.channels.models import ChannelRelayDelivery, ChannelRelayOperation
from integrations.channels.relay import ChannelRelayService, execution
@@ -16,34 +16,51 @@
logger = logging.getLogger(__name__)
-async def dispatch_to_desktop(msg, factory):
- if bridge_enabled():
- return False
+def _relay_target(factory, channel_id):
with factory() as db:
- conn = db.get(ChannelConnection, msg.channel_id)
+ conn = db.get(ChannelConnection, channel_id)
if not conn or not execution(conn):
- return False
- if not conn.enabled:
- return True
- from integrations.channels.inbound_observation import resolve_addressed as _resolve_addressed
+ return None
+ db.expunge(conn)
+ return conn
+
- adapter = get_adapter(conn.channel_type)
- if msg.chat_type == "group":
- msg.addressed_to_bot = await _resolve_addressed(adapter, conn, msg)
- if not msg.addressed_to_bot and conn.group_listen_mode != "observe_all":
- return True
+def _enqueue(factory, channel_id, msg) -> bool:
+ with factory() as db:
try:
- ChannelRelayService(db).enqueue(conn, msg)
+ ChannelRelayService(db).enqueue(db.get(ChannelConnection, channel_id), msg)
except Exception:
db.rollback()
- if msg.addressed_to_bot is not False:
- await adapter.send_text(
- conn, msg, "本机机器人暂不可用,请确认电脑在线、桌面端已登录后重发消息。"
- )
+ return False
return True
-async def expire_deliveries(factory):
+async def dispatch_to_desktop(msg, factory):
+ if bridge_enabled():
+ return False
+ conn = await run_db(_relay_target, factory, msg.channel_id)
+ if conn is None:
+ return False
+ if not conn.enabled:
+ return True
+ from integrations.channels.inbound_observation import resolve_addressed as _resolve_addressed
+
+ adapter = get_adapter(conn.channel_type)
+ if msg.chat_type == "group":
+ msg.addressed_to_bot = await _resolve_addressed(adapter, conn, msg)
+ if not msg.addressed_to_bot and conn.group_listen_mode != "observe_all":
+ return True
+ if not await run_db(_enqueue, factory, msg.channel_id, msg):
+ if msg.addressed_to_bot is not False:
+ await adapter.send_text(
+ conn, msg, "本机机器人暂不可用,请确认电脑在线、桌面端已登录后重发消息。"
+ )
+ return True
+
+
+def _expire_overdue(factory):
+ """Fail overdue deliveries; returns (connection, message) pairs whose sender should be told."""
+ notices = []
with factory() as db:
expired = db.scalars(
select(ChannelRelayDelivery)
@@ -69,14 +86,14 @@ async def expire_deliveries(factory):
conn = db.get(ChannelConnection, item.channel_id)
msg = InboundMsg(**item.payload["message"])
if conn and conn.enabled and msg.addressed_to_bot is not False:
- try:
- await get_adapter(conn.channel_type).send_text(
- conn,
- msg,
- "本机执行已超时或中断,结果可能尚未发送。请检查桌面端执行记录后再决定是否重发,系统不会自动重复执行。",
- )
- except Exception:
- logger.warning("desktop channel expiry notification failed")
+ db.refresh(conn)
+ db.expunge(conn)
+ notices.append((conn, msg))
+ return notices
+
+
+def _purge_finished(factory) -> None:
+ with factory() as db:
old = db.scalars(
select(ChannelRelayDelivery)
.where(
@@ -95,6 +112,19 @@ async def expire_deliveries(factory):
db.commit()
+async def expire_deliveries(factory):
+ for conn, msg in await run_db(_expire_overdue, factory):
+ try:
+ await get_adapter(conn.channel_type).send_text(
+ conn,
+ msg,
+ "本机执行已超时或中断,结果可能尚未发送。请检查桌面端执行记录后再决定是否重发,系统不会自动重复执行。",
+ )
+ except Exception:
+ logger.warning("desktop channel expiry notification failed")
+ await run_db(_purge_finished, factory)
+
+
class ChannelRelayReaper:
def __init__(self, factory):
self.factory, self.task = factory, None
diff --git a/src/backend/integrations/channels/relay_rpc.py b/src/backend/integrations/channels/relay_rpc.py
index 8edc0cc8..1c44791a 100644
--- a/src/backend/integrations/channels/relay_rpc.py
+++ b/src/backend/integrations/channels/relay_rpc.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from core.db.threads import run_in_session
+
import base64
from dataclasses import asdict
@@ -44,18 +46,15 @@ def unpack_bytes(data):
return out
-async def channel_operation(db, owner, device, delivery_id, lease, op_id, method, args):
- if method not in READ_OPS | SEND_OPS:
- raise BadRequestError("不支持的渠道操作")
+def _begin(db, owner, device, delivery_id, lease, opkey, method):
+ """Authorize the delivery and claim a send once; returns (conn, payload, earlier result)."""
task, conn = ChannelRelayService(db).authorized(owner, device, delivery_id, lease)
- msg = InboundMsg(**task.payload["message"])
- adapter = get_adapter(conn.channel_type)
- opkey = f"{delivery_id}:{op_id}"
+ payload = dict(task.payload)
if method in SEND_OPS:
previous = db.get(ChannelRelayOperation, opkey)
if previous:
if previous.status == "completed":
- return previous.result
+ return conn, payload, previous.result
raise BadRequestError("消息发送结果不确定,已阻止重复发送")
db.add(ChannelRelayOperation(operation_id=opkey, status="started"))
try:
@@ -63,6 +62,65 @@ async def channel_operation(db, owner, device, delivery_id, lease, op_id, method
except IntegrityError:
db.rollback()
raise BadRequestError("消息操作正在处理,已阻止重复发送")
+ db.refresh(conn)
+ db.expunge(conn)
+ return conn, payload, None
+
+
+def _remember_resources(db, owner, device, delivery_id, lease, items):
+ task, _ = ChannelRelayService(db).authorized(owner, device, delivery_id, lease)
+ payload = dict(task.payload)
+ resources = dict(payload.get("resources") or {})
+ for item in items:
+ for attachment in item.attachments:
+ resources[str(attachment.get("key"))] = {
+ "attachment": attachment,
+ "message_id": item.message_id,
+ "raw": item.raw,
+ }
+ payload["resources"] = dict(list(resources.items())[-200:])
+ task.payload = payload
+ db.commit()
+
+
+def _sent_message_ids(db, delivery_id):
+ rows = (
+ db.query(ChannelRelayOperation)
+ .filter(
+ ChannelRelayOperation.operation_id.like(delivery_id + ":%"),
+ ChannelRelayOperation.status == "completed",
+ )
+ .all()
+ )
+ return {
+ row.result["message_id"]
+ for row in rows
+ if isinstance(row.result, dict) and row.result.get("message_id")
+ }
+
+
+def _finish(db, opkey, result):
+ row = db.get(ChannelRelayOperation, opkey)
+ row.result, row.status = result, "completed"
+ db.commit()
+
+
+async def channel_operation(owner, device, delivery_id, lease, op_id, method, args):
+ """Run one channel call for a desktop-claimed delivery.
+
+ The delivery is authorized and each send claimed in its own short session;
+ the channel API call in between holds no connection.
+ """
+ if method not in READ_OPS | SEND_OPS:
+ raise BadRequestError("不支持的渠道操作")
+ opkey = f"{delivery_id}:{op_id}"
+ conn, task_payload, earlier = await run_in_session(
+ _begin, owner, device, delivery_id, lease, opkey, method
+ )
+ if earlier is not None:
+ return earlier
+ msg = InboundMsg(**task_payload["message"])
+ adapter = get_adapter(conn.channel_type)
callback = getattr(adapter, method, None)
result = None
if method == "resolve_addressed":
@@ -80,25 +138,14 @@ async def channel_operation(db, owner, device, delivery_id, lease, op_id, method
else []
)
result = [asdict(item) for item in items]
- payload = dict(task.payload)
- resources = dict(payload.get("resources") or {})
- for item in items:
- for attachment in item.attachments:
- resources[str(attachment.get("key"))] = {
- "attachment": attachment,
- "message_id": item.message_id,
- "raw": item.raw,
- }
- payload["resources"] = dict(list(resources.items())[-200:])
- task.payload = payload
- db.commit()
+ await run_in_session(_remember_resources, owner, device, delivery_id, lease, items)
elif method == "download_resource":
requested = args.get("attachment") or {}
known = {
str(a.get("key")): {"attachment": a, "message_id": msg.message_id, "raw": msg.raw}
for a in msg.attachments
}
- known.update(task.payload.get("resources") or {})
+ known.update(task_payload.get("resources") or {})
entry = known.get(str(requested.get("key")))
if not entry:
raise BadRequestError("附件不属于当前渠道任务")
@@ -106,19 +153,7 @@ async def channel_operation(db, owner, device, delivery_id, lease, op_id, method
result = pack_bytes(await callback(conn, msg, entry["attachment"])) if callback else None
elif method in {"edit_message", "recall_message"}:
mid = str(args.get("message_id") or "")
- known_ids = set()
- rows = (
- db.query(ChannelRelayOperation)
- .filter(
- ChannelRelayOperation.operation_id.like(delivery_id + ":%"),
- ChannelRelayOperation.status == "completed",
- )
- .all()
- )
- for row in rows:
- if isinstance(row.result, dict) and row.result.get("message_id"):
- known_ids.add(row.result["message_id"])
- if mid not in known_ids:
+ if mid not in await run_in_session(_sent_message_ids, delivery_id):
raise BadRequestError("消息不属于当前渠道任务")
if not callback:
result = asdict(SendResult.fail("bad_format", "渠道不支持此操作"))
@@ -147,7 +182,5 @@ async def channel_operation(db, owner, device, delivery_id, lease, op_id, method
text = adapter.prepare_markdown(text)
result = asdict(await callback(conn, msg, text))
if method in SEND_OPS:
- row = db.get(ChannelRelayOperation, opkey)
- row.result, row.status = result, "completed"
- db.commit()
+ await run_in_session(_finish, opkey, result)
return result
diff --git a/src/backend/integrations/channels/service.py b/src/backend/integrations/channels/service.py
index c9e7095b..6acda05c 100644
--- a/src/backend/integrations/channels/service.py
+++ b/src/backend/integrations/channels/service.py
@@ -9,6 +9,7 @@
from __future__ import annotations
+from core.db.threads import run_in_session
import hashlib
import json
import logging
@@ -21,7 +22,6 @@
from integrations.channels.registry import get_adapter, list_adapters
from integrations.channels.models import ChannelConnection
from integrations.channels.repository import ChannelConnectionRepository
-from core.infra.background import spawn
from core.infra.crypto import encrypt_secret
from core.infra.exceptions import (
AccessDeniedError,
@@ -139,7 +139,7 @@ def _validate_agent(self, owner_id: str, agent_id: Optional[str]) -> Optional[st
return agent_id
# ── Create / bind ───────────────────────────────────────────────────
- async def create_bot(
+ def register_bot(
self,
owner_id: str,
*,
@@ -156,6 +156,7 @@ async def create_bot(
group_listen_mode: str = "mention_only",
local_binding_id: Optional[str] = None,
) -> ChannelConnection:
+ """Validate and store a pending bot (credentials encrypted); returns it detached."""
# 1. Capability bit
caps = resolve_user_capabilities(self.db, owner_id)
if not caps.get("can_create_channel_bot"):
@@ -213,21 +214,19 @@ async def create_bot(
"enabled": True,
})
- # 5. Validate credentials (exchange for a token). Failure → mark error but keep the row (user can fix credentials and retry)
- adapter = get_adapter(channel_type)
- try:
- await adapter.validate_credentials(conn)
- except Exception as exc: # noqa: BLE001
- self.repo.set_status(channel_id, "error", last_error=str(exc)[:480])
- raise BadRequestError(f"凭据校验失败:{exc}")
+ self.db.expunge(conn)
+ return conn
- # 6. Start the long connection (not needed in webhook mode)
- if transport == "long_conn":
- self._start_long_conn(channel_id, channel_type)
- else:
- self.repo.set_status(channel_id, "connected")
+ def mark_connected(self, channel_id: str) -> ChannelConnection:
+ self.repo.set_status(channel_id, "connected")
+ return self.detached(channel_id)
- self.db.refresh(conn)
+ def mark_failed(self, channel_id: str, error: Exception) -> None:
+ self.repo.set_status(channel_id, "error", last_error=str(error)[:480])
+
+ def detached(self, channel_id: str) -> ChannelConnection:
+ conn = self.repo.get_by_id(channel_id)
+ self.db.expunge(conn)
return conn
# ── Update / delete / test ──────────────────────────────────────────
@@ -280,70 +279,23 @@ def delete_bot(self, owner_id: str, channel_id: str) -> None:
_manager().stop_connection(channel_id)
self.repo.delete(channel_id)
- async def test_bot(self, owner_id: str, channel_id: str) -> Dict[str, Any]:
+ def owned_detached(self, channel_id: str, owner_id: str) -> ChannelConnection:
conn = self._owned(channel_id, owner_id)
- adapter = get_adapter(conn.channel_type)
- try:
- await adapter.validate_credentials(conn)
- except Exception as exc: # noqa: BLE001
- self.repo.set_status(channel_id, "error", last_error=str(exc)[:480])
- raise BadRequestError(f"凭据校验失败:{exc}")
- # Credentials and receiver health are separate. Only the receiver can prove
- # that a long connection is running; an HTTP worker may not own that receiver.
- if conn.enabled and conn.transport == "webhook":
- self.repo.set_status(channel_id, "connected")
- return {"ok": True}
+ self.db.expunge(conn)
+ return conn
# ── WeChat QR-code binding (for qr-mode channels, no credential form) ─
- async def start_weixin_bind(
- self, owner_id: str, *, agent_id: Optional[str] = None, local_binding_id: Optional[str] = None
- ) -> Dict[str, Any]:
- """Fetch the WeChat login QR code, stash the polling context in Redis (TTL), return {bind_id, qrcode_img}.
-
- Non-empty ``agent_id`` (binding from the subagent page) → verify ownership and carry it
- in the context to the poll phase, where it is written into the connection.
- """
+ def admit_bind(self, owner_id: str, agent_id: Optional[str]) -> Optional[str]:
+ """Capability check for a new bot; returns the validated sub-agent id."""
caps = resolve_user_capabilities(self.db, owner_id)
if not caps.get("can_create_channel_bot"):
raise AccessDeniedError("无权创建渠道机器人")
- agent_id = self._validate_agent(owner_id, agent_id)
- adapter = get_adapter("weixin")
- try:
- qr = await adapter.start_qr_bind()
- except Exception as exc: # noqa: BLE001
- raise BadRequestError(f"获取微信二维码失败:{exc}")
- bind_id = f"wxbind_{uuid.uuid4().hex[:16]}"
- from core.infra.ephemeral import get_ephemeral_state
-
- await get_ephemeral_state().put(
- f"{_WEIXIN_BIND_PREFIX}{bind_id}",
- json.dumps({"owner_id": owner_id, "qrcode": qr["qrcode"], "agent_id": agent_id, "local_binding_id": local_binding_id}),
- ttl=_WEIXIN_BIND_TTL,
- )
- return {"bind_id": bind_id, "qrcode_img": qr.get("qrcode_img_content", "")}
-
- async def poll_weixin_bind(self, owner_id: str, bind_id: str) -> Dict[str, Any]:
- """Poll the QR-scan status; confirmed → persist ChannelConnection + start long polling, returns {status, channel_id?}."""
- from core.infra.ephemeral import get_ephemeral_state
-
- state = get_ephemeral_state()
- key = f"{_WEIXIN_BIND_PREFIX}{bind_id}"
- raw = await state.get(key)
- ctx = json.loads(raw) if raw else None
- if ctx is None or ctx.get("owner_id") != owner_id:
- raise ResourceNotFoundError("weixin_bind", bind_id)
- adapter = get_adapter("weixin")
- try:
- status = await adapter.poll_qr_status(ctx["qrcode"])
- except Exception as exc: # noqa: BLE001
- raise BadRequestError(f"查询扫码状态失败:{exc}")
- if status.get("status") != "confirmed":
- return {"status": status.get("status") or "waiting"}
- if not isinstance(status.get("bot_token"), str) or not status["bot_token"].strip():
- raise BadRequestError("微信绑定未返回有效凭据,请重新扫码")
-
- # confirmed → create the connection (app_id is derived from bot_token, satisfying the unique constraint)
- await state.drop(key)
+ return self._validate_agent(owner_id, agent_id)
+
+ def record_weixin_bind(
+ self, owner_id: str, ctx: Dict[str, Any], status: Dict[str, Any]
+ ) -> tuple[str, bool]:
+ """Store a confirmed WeChat binding; returns ``(channel_id, created)``."""
bot_token = status["bot_token"]
app_id = f"wx_{hashlib.sha256(bot_token.encode()).hexdigest()[:24]}"
existing = self.repo.get_by_app_id("weixin", app_id)
@@ -359,7 +311,7 @@ async def poll_weixin_bind(self, owner_id: str, bind_id: str) -> Dict[str, Any]:
if isinstance(user_id, str) and user_id.strip():
existing.config = {**(existing.config or {}), "ilink_user_id": user_id}
self.db.commit()
- return {"status": "confirmed", "channel_id": existing.channel_id}
+ return existing.channel_id, False
channel_id = f"chan_{uuid.uuid4().hex[:16]}"
config = {"bot_token_enc": encrypt_secret(bot_token)}
user_id = status.get("ilink_user_id")
@@ -383,17 +335,17 @@ async def poll_weixin_bind(self, owner_id: str, bind_id: str) -> Dict[str, Any]:
"status": "pending",
"enabled": True,
})
- self._start_long_conn(channel_id, "weixin")
- return {"status": "confirmed", "channel_id": channel_id}
+ return channel_id, True
# ── Webhook entry (for webhook-mode channels) ──────────────────────
def handle_webhook(
self, channel_id: str, headers: Dict[str, str], body: bytes
- ) -> Dict[str, Any]:
- """Handle the webhook synchronously: URL verification returns the challenge; message events dispatch the async handle_inbound.
+ ) -> tuple[Dict[str, Any], Any]:
+ """Verify and parse a webhook delivery (database + crypto only).
- Returns the response body (dict) sent back to the channel. Signature verification
- failure raises AccessDeniedError.
+ Returns ``(response body for the channel, inbound message or None)``; the
+ caller dispatches the inbound message on the event loop. Signature
+ verification failure raises AccessDeniedError.
"""
import json
@@ -414,18 +366,13 @@ def handle_webhook(
else:
payload = json.loads(body or b"{}")
if payload.get("type") == "url_verification":
- return {"challenge": payload.get("challenge", "")}
+ return {"challenge": payload.get("challenge", "")}, None
if not adapter.verify_webhook(conn, headers, body):
raise AccessDeniedError("webhook 验签失败")
- inbound = adapter.parse_inbound(conn, payload)
- if inbound is None:
- return {"code": 0} # non-message event, ack
- from integrations.channels.inbound import handle_inbound
-
- spawn(handle_inbound(inbound), name="channel.handle_inbound")
- return {"code": 0}
+ # A non-message event parses to None and is only acknowledged.
+ return {"code": 0}, adapter.parse_inbound(conn, payload)
def handle_webhook_get(self, channel_id: str, params: Dict[str, str]) -> str:
"""GET URL verification (used when configuring the WeCom callback): verify the signature, then return the plaintext echostr.
@@ -446,11 +393,102 @@ def handle_webhook_get(self, channel_id: str, params: Dict[str, str]) -> str:
raise AccessDeniedError(f"URL 校验失败: {exc}")
# ── Internal ────────────────────────────────────────────────────────
- def _start_long_conn(self, channel_id: str, channel_type: str) -> None:
- try:
- _manager().start_connection(channel_id, channel_type)
- except Exception: # noqa: BLE001
- logger.exception("[channels] 启动长连接失败 %s", channel_id)
+def _mark_failed(db: Session, channel_id: str, error: Exception) -> None:
+ ChannelService(db).mark_failed(channel_id, error)
+
+
+async def create_bot(owner_id: str, **fields: Any) -> ChannelConnection:
+ """Store a bot, check its credentials with the platform, then start receiving.
+
+ The credential check is a network call between two short database steps; a
+ failed check keeps the row (marked ``error``) so the owner can fix it.
+ """
+ conn = await run_in_session(lambda db: ChannelService(db).register_bot(owner_id, **fields))
+ try:
+ await get_adapter(conn.channel_type).validate_credentials(conn)
+ except Exception as exc: # noqa: BLE001
+ await run_in_session(_mark_failed, conn.channel_id, exc)
+ raise BadRequestError(f"凭据校验失败:{exc}")
+ if conn.transport == "long_conn":
+ _start_long_conn(conn.channel_id, conn.channel_type)
+ return await run_in_session(lambda db: ChannelService(db).detached(conn.channel_id))
+ return await run_in_session(lambda db: ChannelService(db).mark_connected(conn.channel_id))
+
+
+async def test_bot(owner_id: str, channel_id: str) -> Dict[str, Any]:
+ conn = await run_in_session(lambda db: ChannelService(db).owned_detached(channel_id, owner_id))
+ try:
+ await get_adapter(conn.channel_type).validate_credentials(conn)
+ except Exception as exc: # noqa: BLE001
+ await run_in_session(_mark_failed, channel_id, exc)
+ raise BadRequestError(f"凭据校验失败:{exc}")
+ # Credentials and receiver health are separate. Only the receiver can prove
+ # that a long connection is running; an HTTP worker may not own that receiver.
+ if conn.enabled and conn.transport == "webhook":
+ await run_in_session(lambda db: ChannelService(db).mark_connected(channel_id))
+ return {"ok": True}
+
+
+async def start_weixin_bind(
+ owner_id: str, *, agent_id: Optional[str] = None, local_binding_id: Optional[str] = None
+) -> Dict[str, Any]:
+ """Fetch the WeChat login QR code, stash the polling context in Redis (TTL), return {bind_id, qrcode_img}.
+
+ Non-empty ``agent_id`` (binding from the subagent page) → verify ownership and carry it
+ in the context to the poll phase, where it is written into the connection.
+ """
+ agent_id = await run_in_session(lambda db: ChannelService(db).admit_bind(owner_id, agent_id))
+ adapter = get_adapter("weixin")
+ try:
+ qr = await adapter.start_qr_bind()
+ except Exception as exc: # noqa: BLE001
+ raise BadRequestError(f"获取微信二维码失败:{exc}")
+ bind_id = f"wxbind_{uuid.uuid4().hex[:16]}"
+ from core.infra.ephemeral import get_ephemeral_state
+
+ await get_ephemeral_state().put(
+ f"{_WEIXIN_BIND_PREFIX}{bind_id}",
+ json.dumps({"owner_id": owner_id, "qrcode": qr["qrcode"], "agent_id": agent_id, "local_binding_id": local_binding_id}),
+ ttl=_WEIXIN_BIND_TTL,
+ )
+ return {"bind_id": bind_id, "qrcode_img": qr.get("qrcode_img_content", "")}
+
+
+async def poll_weixin_bind(owner_id: str, bind_id: str) -> Dict[str, Any]:
+ """Poll the QR-scan status; confirmed → persist ChannelConnection + start long polling, returns {status, channel_id?}."""
+ from core.infra.ephemeral import get_ephemeral_state
+
+ state = get_ephemeral_state()
+ key = f"{_WEIXIN_BIND_PREFIX}{bind_id}"
+ raw = await state.get(key)
+ ctx = json.loads(raw) if raw else None
+ if ctx is None or ctx.get("owner_id") != owner_id:
+ raise ResourceNotFoundError("weixin_bind", bind_id)
+ adapter = get_adapter("weixin")
+ try:
+ status = await adapter.poll_qr_status(ctx["qrcode"])
+ except Exception as exc: # noqa: BLE001
+ raise BadRequestError(f"查询扫码状态失败:{exc}")
+ if status.get("status") != "confirmed":
+ return {"status": status.get("status") or "waiting"}
+ if not isinstance(status.get("bot_token"), str) or not status["bot_token"].strip():
+ raise BadRequestError("微信绑定未返回有效凭据,请重新扫码")
+
+ # confirmed → create the connection (app_id is derived from bot_token, satisfying the unique constraint)
+ await state.drop(key)
+ channel_id, created = await run_in_session(
+ lambda db: ChannelService(db).record_weixin_bind(owner_id, ctx, status)
+ )
+ if created:
+ _start_long_conn(channel_id, "weixin")
+ return {"status": "confirmed", "channel_id": channel_id}
+
+
+def _start_long_conn(channel_id: str, channel_type: str) -> None:
+ try:
+ _manager().start_connection(channel_id, channel_type)
+ except Exception: # noqa: BLE001
+ logger.exception("[channels] 启动长连接失败 %s", channel_id)
def _manager():
diff --git a/src/backend/integrations/channels/tools/attachment.py b/src/backend/integrations/channels/tools/attachment.py
index cc1888ef..d8f62429 100644
--- a/src/backend/integrations/channels/tools/attachment.py
+++ b/src/backend/integrations/channels/tools/attachment.py
@@ -14,6 +14,7 @@
Registered only for channel runs (see agent_factory's ``_is_channel_run``).
"""
+from core.llm.factory.tools.definitions import function_tool
import json
import logging
@@ -23,7 +24,7 @@
from agentscope.message import TextBlock
from agentscope.tool import Toolkit
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
logger = logging.getLogger(__name__)
@@ -31,8 +32,8 @@
_MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
-def _err(msg: str) -> ToolResponse:
- return ToolResponse(content=[TextBlock(
+def _err(msg: str) -> ToolChunk:
+ return ToolChunk(content=[TextBlock(
type="text", text=json.dumps({"error": msg}, ensure_ascii=False),
)])
@@ -71,6 +72,17 @@ def _lookup(db, chat_id: str, user_id: Optional[str], file_key: str):
return session, conn, entry, None
+def _resolve(db, chat_id: str, user_id: Optional[str], file_key: str):
+ """``(owner_id, detached connection, entry, inbound)`` or an error string."""
+ session, conn, entry, err = _lookup(db, chat_id, user_id, file_key)
+ if err:
+ return err
+ inbound = _rebuild_inbound(conn, session, entry)
+ owner_id = str(session.user_id)
+ db.expunge(conn)
+ return owner_id, conn, entry, inbound
+
+
def _rebuild_inbound(conn, session, entry: Dict[str, Any]):
"""Minimal InboundMsg carrying just what ``download_resource`` reads.
@@ -96,7 +108,7 @@ def register_channel_attachment(
) -> None:
"""Register ``channel_read_attachment`` for channel (IM bot) runs."""
- async def channel_read_attachment(file_key: str) -> ToolResponse:
+ async def channel_read_attachment(file_key: str) -> ToolChunk:
"""按 key 取回群聊里出现过但尚未下载的文件(图片 / 文档 / 表格等)。
群聊旁听只记录了文件的**句柄**,没有下载内容。当你判断某个文件与当前问题
@@ -116,7 +128,7 @@ async def channel_read_attachment(file_key: str) -> ToolResponse:
"""
from integrations.channels.protocol import ChannelResourceError
from integrations.channels.registry import get_adapter
- from core.db.engine import SessionLocal
+ from core.db.threads import run_in_session
from core.files.artifacts.service import store_bytes_as_artifact
key = (file_key or "").strip()
@@ -125,66 +137,68 @@ async def channel_read_attachment(file_key: str) -> ToolResponse:
if not chat_id:
return _err("当前不是渠道会话,无法取回群聊附件")
- with SessionLocal() as db:
- session, conn, entry, err = _lookup(db, chat_id, user_id, key)
- if err:
- return _err(err)
-
- try:
- adapter = get_adapter(conn.channel_type)
- except Exception: # noqa: BLE001
- return _err(f"渠道 {conn.channel_type} 不可用")
- download = getattr(adapter, "download_resource", None)
- if download is None:
- return _err(f"渠道 {conn.channel_type} 不支持下载消息附件")
-
- name = entry.get("name") or "file.bin"
- try:
- content = await download(
- conn, _rebuild_inbound(conn, session, entry),
- {"kind": entry.get("kind") or "file", "key": key, "name": name},
- )
- except ChannelResourceError as exc:
- # 平台给了明确理由(如跨组织访问被拒),原样透出——把它说成“已过期”
- # 会让用户往完全错误的方向排查。
- return _err(str(exc) or "渠道拒绝了这次读取")
- except Exception: # noqa: BLE001
- logger.exception("[channels] 按需取回附件失败 key=%s", key[:16])
- return _err("附件下载失败,可能下载码已过期")
- if not content:
- return _err("附件下载失败或已过期(渠道返回空内容)")
- if len(content) > _MAX_ATTACHMENT_BYTES:
- return _err(f"文件过大({len(content)} 字节),超出 50MB 上限")
-
- # An online document is fetched as markdown *content*, and its name is a title
- # with no extension. read_artifact dispatches its parser purely on the filename
- # extension, so leaving it bare would mean the file downloads fine and then reads
- # back as "unsupported format" — fetched but unusable. Give it the extension that
- # matches what the bytes actually are.
- if entry.get("kind") == "doc" and not os.path.splitext(name)[1]:
- name = f"{name}.md"
- mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
- art = store_bytes_as_artifact(
- db, user_id=str(session.user_id), content=content, filename=name,
+ found = await run_in_session(_resolve, chat_id, user_id, key)
+ if isinstance(found, str):
+ return _err(found)
+ owner_id, conn, entry, inbound = found
+
+ try:
+ adapter = get_adapter(conn.channel_type)
+ except Exception: # noqa: BLE001
+ return _err(f"渠道 {conn.channel_type} 不可用")
+ download = getattr(adapter, "download_resource", None)
+ if download is None:
+ return _err(f"渠道 {conn.channel_type} 不支持下载消息附件")
+
+ name = entry.get("name") or "file.bin"
+ try:
+ content = await download(
+ conn, inbound,
+ {"kind": entry.get("kind") or "file", "key": key, "name": name},
+ )
+ except ChannelResourceError as exc:
+ # 平台给了明确理由(如跨组织访问被拒),原样透出——把它说成“已过期”
+ # 会让用户往完全错误的方向排查。
+ return _err(str(exc) or "渠道拒绝了这次读取")
+ except Exception: # noqa: BLE001
+ logger.exception("[channels] 按需取回附件失败 key=%s", key[:16])
+ return _err("附件下载失败,可能下载码已过期")
+ if not content:
+ return _err("附件下载失败或已过期(渠道返回空内容)")
+ if len(content) > _MAX_ATTACHMENT_BYTES:
+ return _err(f"文件过大({len(content)} 字节),超出 50MB 上限")
+
+ # An online document is fetched as markdown *content*, and its name is a title
+ # with no extension. read_artifact dispatches its parser purely on the filename
+ # extension, so leaving it bare would mean the file downloads fine and then reads
+ # back as "unsupported format" — fetched but unusable. Give it the extension that
+ # matches what the bytes actually are.
+ if entry.get("kind") == "doc" and not os.path.splitext(name)[1]:
+ name = f"{name}.md"
+ mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
+ artifact_id = await run_in_session(
+ lambda db: store_bytes_as_artifact(
+ db, user_id=owner_id, content=content, filename=name,
mime_type=mime, chat_id=chat_id, source="channel_observed",
extra={"channel_id": conn.channel_id, "observed_file_key": key},
- )
- return ToolResponse(content=[TextBlock(type="text", text=json.dumps({
- "file_id": art.artifact_id,
- "filename": name,
- "mime_type": mime,
- "size": len(content),
- # Images can't be parsed into text by read_artifact — point at the
- # image tool instead, otherwise a group-chat screenshot fetches
- # fine and then reads back as "unsupported format".
- "next": (
- f"用 read_image(file_id='{art.artifact_id}') 看这张图"
- if mime.startswith("image/")
- else f"用 read_artifact(file_id='{art.artifact_id}') 读取内容"
- ),
- }, ensure_ascii=False))])
-
- toolkit.register_tool_function(channel_read_attachment, namesake_strategy="override")
+ ).artifact_id
+ )
+ return ToolChunk(content=[TextBlock(type="text", text=json.dumps({
+ "file_id": artifact_id,
+ "filename": name,
+ "mime_type": mime,
+ "size": len(content),
+ # Images can't be parsed into text by read_artifact — point at the
+ # image tool instead, otherwise a group-chat screenshot fetches
+ # fine and then reads back as "unsupported format".
+ "next": (
+ f"用 read_image(file_id='{artifact_id}') 看这张图"
+ if mime.startswith("image/")
+ else f"用 read_artifact(file_id='{artifact_id}') 读取内容"
+ ),
+ }, ensure_ascii=False))])
+
+ toolkit.append(function_tool(channel_read_attachment))
async def install_attachment_tool(toolkit, context):
diff --git a/src/backend/integrations/dingtalk/cube_credentials.py b/src/backend/integrations/dingtalk/cube_credentials.py
index 705025bf..c47a42bb 100644
--- a/src/backend/integrations/dingtalk/cube_credentials.py
+++ b/src/backend/integrations/dingtalk/cube_credentials.py
@@ -5,6 +5,7 @@
import time
from pathlib import Path
from typing import Any, Optional
+from core.db.threads import run_in_session
logger = logging.getLogger(__name__)
class CubeCredentials:
@@ -65,12 +66,13 @@ async def _get_dingtalk_bundle(self, user_id: str) -> Optional[str]:
export fails.
"""
try:
- from core.db.engine import SessionLocal
from integrations.dingtalk.repository import DingTalkConnectionRepository
from integrations.dingtalk.service import mark_login_expired, verify_and_refresh
- with SessionLocal() as db:
+ def _connection(db):
rec = DingTalkConnectionRepository(db).get(user_id)
+ return rec
+ rec = await run_in_session(_connection)
if not rec or rec.status != "connected":
return None
verdict = await verify_and_refresh(user_id)
diff --git a/src/backend/integrations/dingtalk/service.py b/src/backend/integrations/dingtalk/service.py
index 3717eae6..2b267b3b 100644
--- a/src/backend/integrations/dingtalk/service.py
+++ b/src/backend/integrations/dingtalk/service.py
@@ -30,7 +30,7 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
-from sqlalchemy.orm import Session
+from core.db.threads import AsyncRepository
from integrations.dingtalk.repository import DingTalkConnectionRepository
from core.infra.proc import no_window_kwargs
@@ -296,14 +296,14 @@ async def _device_login_flow(user_id: str) -> None:
class DingTalkService:
- def __init__(self, db: Session):
- self.db = db
- self.repo = DingTalkConnectionRepository(db)
+ """Connection state lives in the repository; every access is its own short session."""
- def _fresh(self, user_id: str):
+ def __init__(self):
+ self.repo = AsyncRepository(DingTalkConnectionRepository)
+
+ async def _fresh(self, user_id: str):
"""Discard the session cache and re-read — to see the latest state committed by the background login task's independent session."""
- self.db.expire_all()
- return self.repo.ensure(user_id)
+ return await self.repo.ensure(user_id)
# ── Status ───────────────────────────────────────────────────────────
def status_dict(self, record) -> Dict[str, Any]:
@@ -323,8 +323,8 @@ def status_dict(self, record) -> Dict[str, Any]:
"last_error": record.last_error,
}
- def get_status(self, user_id: str) -> Dict[str, Any]:
- record = self.repo.ensure(user_id)
+ async def get_status(self, user_id: str) -> Dict[str, Any]:
+ record = await self.repo.ensure(user_id)
return self.status_dict(record)
async def probe_status(self, user_id: str) -> Dict[str, Any]:
@@ -337,16 +337,16 @@ async def probe_status(self, user_id: str) -> Dict[str, Any]:
``unknown`` (network / rate limiting etc., no conclusion) the DB is
left untouched, avoiding misjudgment from a blip.
"""
- record = self._fresh(user_id)
+ record = await self._fresh(user_id)
# Do not interrupt an in-progress login task; just echo the current (pending) state
if record.status == "pending" and user_id in _LOGIN_TASKS:
return self.status_dict(record)
verdict = await verify_and_refresh(user_id)
if verdict == "valid" and record.status != "connected":
- self.repo.update(user_id, {"status": "connected", "last_error": None})
+ await self.repo.update(user_id, {"status": "connected", "last_error": None})
elif verdict == "invalid" and record.status == "connected":
- self.repo.update(user_id, {"status": "disconnected", "last_error": _EXPIRED_ERROR})
- return self.status_dict(self.repo.get(user_id))
+ await self.repo.update(user_id, {"status": "disconnected", "last_error": _EXPIRED_ERROR})
+ return self.status_dict(await self.repo.get(user_id))
# ── Device-flow login (backend subprocess, process stably alive) ─────
async def start_device_login(self, user_id: str) -> Dict[str, Any]:
@@ -357,12 +357,12 @@ async def start_device_login(self, user_id: str) -> Dict[str, Any]:
the user and then polls :meth:`poll_login` until connected. Per-user
isolated HOME → isolation.
"""
- self.repo.ensure(user_id)
+ await self.repo.ensure(user_id)
# Cancel the same user's old task, reset to pending
old = _LOGIN_TASKS.pop(user_id, None)
if old is not None and not old.done():
old.cancel()
- self.repo.update(user_id, {
+ await self.repo.update(user_id, {
"status": "pending",
**_CLEAR_LOGIN,
"login_started_at": utc_now(),
@@ -373,22 +373,22 @@ async def start_device_login(self, user_id: str) -> Dict[str, Any]:
# Wait up to ~15s for the verification URL to appear (the task writes with an independent session; here we expire then re-read)
for _ in range(30):
await asyncio.sleep(0.5)
- rec = self._fresh(user_id)
+ rec = await self._fresh(user_id)
if rec.login_verification_url or rec.status in ("connected", "error"):
break
- return self.status_dict(self._fresh(user_id))
+ return self.status_dict(await self._fresh(user_id))
async def poll_login(self, user_id: str) -> Dict[str, Any]:
"""Poll login progress. The background task drives the DB to
connected/error; this only reads the latest state. If the task is
gone but still pending (e.g. a backend restart lost the task), fall
back to a single status probe."""
- record = self._fresh(user_id)
+ record = await self._fresh(user_id)
if record.status == "pending" and user_id not in _LOGIN_TASKS:
out, _, _ = await _run_dws(user_id, ["auth", "status", "-f", "json"])
if dingtalk_parsing.parse_auth_status(out):
- self.repo.update(user_id, await _build_connected_update(user_id))
- record = self.repo.get(user_id)
+ await self.repo.update(user_id, await _build_connected_update(user_id))
+ record = await self.repo.get(user_id)
return self.status_dict(record)
# ── Disconnect ───────────────────────────────────────────────────────
@@ -412,7 +412,7 @@ async def disconnect(self, user_id: str) -> Dict[str, Any]:
purge_credential_dir(dws_cache_dir(user_id))
except Exception as exc: # noqa: BLE001
logger.warning("[dingtalk] disconnect cache purge failed user=%s: %s", user_id, exc)
- self.repo.update(
+ await self.repo.update(
user_id,
{
"status": "disconnected",
@@ -425,4 +425,4 @@ async def disconnect(self, user_id: str) -> Dict[str, Any]:
"last_error": None,
},
)
- return self.status_dict(self.repo.get(user_id))
+ return self.status_dict(await self.repo.get(user_id))
diff --git a/src/backend/integrations/email/service.py b/src/backend/integrations/email/service.py
index 3e2934c6..7ee862c6 100644
--- a/src/backend/integrations/email/service.py
+++ b/src/backend/integrations/email/service.py
@@ -31,7 +31,7 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
-from sqlalchemy.orm import Session
+from core.db.threads import AsyncRepository
from integrations.email.repository import EmailConnectionRepository
from core.infra.crypto import decrypt_secret, encrypt_secret
@@ -251,9 +251,10 @@ async def _verify(user_id: str) -> Tuple[bool, str]:
class EmailService:
- def __init__(self, db: Session):
- self.db = db
- self.repo = EmailConnectionRepository(db)
+ """Connection state lives in the repository; every access is its own short session."""
+
+ def __init__(self):
+ self.repo = AsyncRepository(EmailConnectionRepository)
# ── Status ───────────────────────────────────────────────────────────
def status_dict(self, record) -> Dict[str, Any]:
@@ -272,22 +273,22 @@ def status_dict(self, record) -> Dict[str, Any]:
"last_error": record.last_error,
}
- def get_status(self, user_id: str) -> Dict[str, Any]:
- return self.status_dict(self.repo.ensure(user_id))
+ async def get_status(self, user_id: str) -> Dict[str, Any]:
+ return self.status_dict(await self.repo.ensure(user_id))
async def probe_status(self, user_id: str) -> Dict[str, Any]:
"""Real connectivity probe and reconciliation (credentials may have been invalidated by a password change). Unconnected records are not probed."""
- record = self.repo.ensure(user_id)
+ record = await self.repo.ensure(user_id)
if record.status != "connected":
return self.status_dict(record)
ok, detail = await self._reverify(user_id, record)
if ok and record.status != "connected":
- self.repo.update(user_id, {"status": "connected", "last_error": None,
+ await self.repo.update(user_id, {"status": "connected", "last_error": None,
"last_verified_at": utc_now()})
elif not ok:
- self.repo.update(user_id, {"status": "error",
+ await self.repo.update(user_id, {"status": "error",
"last_error": f"{_VERIFY_FAIL}。详情: {detail}" if detail else _VERIFY_FAIL})
- return self.status_dict(self.repo.get(user_id))
+ return self.status_dict(await self.repo.get(user_id))
async def _reverify(self, user_id: str, record) -> Tuple[bool, str]:
"""Before probing, ensure the backend HOME has config.toml (after a restart / machine change only the DB row may remain) — rebuild it from secret_enc."""
@@ -307,15 +308,15 @@ async def connect(
server_overrides: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Save mailbox credentials and verify synchronously. Success → connected, failure → error (no half-baked credentials left behind)."""
- self.repo.ensure(user_id)
+ await self.repo.ensure(user_id)
email_address = (email_address or "").strip()
secret = secret or ""
if not email_address or "@" not in email_address:
- self.repo.update(user_id, {"status": "error", "last_error": "邮箱地址格式不正确"})
- return self.status_dict(self.repo.get(user_id))
+ await self.repo.update(user_id, {"status": "error", "last_error": "邮箱地址格式不正确"})
+ return self.status_dict(await self.repo.get(user_id))
if not secret:
- self.repo.update(user_id, {"status": "error", "last_error": "请填写邮箱授权码 / 密码"})
- return self.status_dict(self.repo.get(user_id))
+ await self.repo.update(user_id, {"status": "error", "last_error": "请填写邮箱授权码 / 密码"})
+ return self.status_dict(await self.repo.get(user_id))
# Resolve servers: built-in detection first, then apply server_overrides (an override only applies when non-empty).
detected = detect_provider(email_address) or {
@@ -326,11 +327,11 @@ async def connect(
ov = {k: v for k, v in (server_overrides or {}).items() if v not in (None, "")}
cfg = {**detected, **ov}
if not cfg.get("imap_host") or not cfg.get("smtp_host"):
- self.repo.update(user_id, {
+ await self.repo.update(user_id, {
"status": "error",
"last_error": "无法自动识别该邮箱的服务器,请在「高级设置」手填 IMAP / SMTP 服务器地址",
})
- return self.status_dict(self.repo.get(user_id))
+ return self.status_dict(await self.repo.get(user_id))
# Normalized account/server fields — shared by render and the failure/success DB writes, avoiding duplication.
server_data = {
@@ -352,24 +353,24 @@ async def connect(
)
werr = _write_config(user_id, content)
if werr:
- self.repo.update(user_id, {"status": "error", "last_error": werr})
- return self.status_dict(self.repo.get(user_id))
+ await self.repo.update(user_id, {"status": "error", "last_error": werr})
+ return self.status_dict(await self.repo.get(user_id))
# Synchronous verification (really connects to IMAP)
ok, detail = await _verify(user_id)
if not ok:
# Verification failed: remove the just-written config so the sandbox doesn't keep failing with bad credentials
self._purge_config(user_id)
- self.repo.update(user_id, {
+ await self.repo.update(user_id, {
**server_data,
"status": "error",
"secret_enc": None, "config_bundle": None,
"last_error": f"{_VERIFY_FAIL}。详情: {detail}" if detail else _VERIFY_FAIL,
})
- return self.status_dict(self.repo.get(user_id))
+ return self.status_dict(await self.repo.get(user_id))
# Success: encrypt the app password + store the portable config bundle (for cube injection)
- self.repo.update(user_id, {
+ await self.repo.update(user_id, {
**server_data,
"status": "connected",
"secret_enc": encrypt_secret(secret),
@@ -377,7 +378,7 @@ async def connect(
"last_verified_at": utc_now(),
"last_error": None,
})
- return self.status_dict(self.repo.get(user_id))
+ return self.status_dict(await self.repo.get(user_id))
def _materialize_config(self, user_id: str, record, secret: str) -> None:
"""Rebuild config.toml from DB metadata + the decrypted app password (probe/restart fallback)."""
@@ -408,8 +409,8 @@ def _purge_config(self, user_id: str) -> None:
async def disconnect(self, user_id: str) -> Dict[str, Any]:
"""Clear the backend persistent credential directory + wipe sensitive DB fields + set disconnected."""
self._purge_config(user_id)
- self.repo.ensure(user_id)
- self.repo.update(user_id, {
+ await self.repo.ensure(user_id)
+ await self.repo.update(user_id, {
"status": "disconnected",
"email_address": None, "display_name": None, "provider": None,
"imap_host": None, "imap_port": None, "imap_security": None,
@@ -417,7 +418,7 @@ async def disconnect(self, user_id: str) -> Dict[str, Any]:
"secret_enc": None, "config_bundle": None,
"last_verified_at": None, "last_error": None,
})
- return self.status_dict(self.repo.get(user_id))
+ return self.status_dict(await self.repo.get(user_id))
def get_email_config_bundle(user_id: str) -> Optional[str]:
diff --git a/src/backend/integrations/lark/service.py b/src/backend/integrations/lark/service.py
index fb5761da..85dfae5b 100644
--- a/src/backend/integrations/lark/service.py
+++ b/src/backend/integrations/lark/service.py
@@ -4,7 +4,7 @@
import asyncio
import shutil
from typing import Any, Dict
-from sqlalchemy.orm import Session
+from core.db.threads import AsyncRepository
from integrations.lark.repository import LarkConnectionRepository
from integrations.dingtalk.parsing import make_qr_data_uri
import logging
@@ -16,14 +16,14 @@
from integrations.lark import state as lark_state
class LarkService:
- def __init__(self, db: Session):
- self.db = db
- self.repo = LarkConnectionRepository(db)
+ """Connection state lives in the repository; every access is its own short session."""
- def _fresh(self, user_id: str):
+ def __init__(self):
+ self.repo = AsyncRepository(LarkConnectionRepository)
+
+ async def _fresh(self, user_id: str):
"""Discard the session cache and re-read — to see the latest state committed by the background task's independent session."""
- self.db.expire_all()
- return self.repo.ensure(user_id)
+ return await self.repo.ensure(user_id)
# ── Status ───────────────────────────────────────────────────────────
def status_dict(self, record) -> Dict[str, Any]:
@@ -42,20 +42,20 @@ def status_dict(self, record) -> Dict[str, Any]:
"last_error": record.last_error,
}
- def get_status(self, user_id: str) -> Dict[str, Any]:
- return self.status_dict(self.repo.ensure(user_id))
+ async def get_status(self, user_id: str) -> Dict[str, Any]:
+ return self.status_dict(await self.repo.ensure(user_id))
async def probe_status(self, user_id: str) -> Dict[str, Any]:
"""Real-API liveness-probe reconciliation (corrects mismatches between the DB and actual credentials). Does not interrupt pending (login in progress)."""
- record = self._fresh(user_id)
+ record = await self._fresh(user_id)
if record.status == "pending" and user_id in lark_state.LOGIN_TASKS:
return self.status_dict(record)
verdict = await lark_login.verify_and_refresh(user_id)
if verdict == "valid" and record.status != "connected":
- self.repo.update(user_id, {"status": "connected", "last_error": None})
+ await self.repo.update(user_id, {"status": "connected", "last_error": None})
elif verdict == "invalid" and record.status == "connected":
- self.repo.update(user_id, {"status": "disconnected", "last_error": lark_state.EXPIRED_ERROR})
- return self.status_dict(self.repo.get(user_id))
+ await self.repo.update(user_id, {"status": "disconnected", "last_error": lark_state.EXPIRED_ERROR})
+ return self.status_dict(await self.repo.get(user_id))
# ── Device-flow login (scan-to-bind) ─────────────────────────────────
async def start_device_login(self, user_id: str) -> Dict[str, Any]:
@@ -63,7 +63,7 @@ async def start_device_login(self, user_id: str) -> Dict[str, Any]:
``auth login --no-wait`` gets the device_code / QR URL, and the
background completion task is started. The front end presents
qr_data_uri for the user to scan, then polls."""
- self.repo.ensure(user_id)
+ await self.repo.ensure(user_id)
# Cancel the same user's old task
old = lark_state.LOGIN_TASKS.pop(user_id, None)
if old is not None and not old.done():
@@ -71,20 +71,20 @@ async def start_device_login(self, user_id: str) -> Dict[str, Any]:
cfg_err = await lark_configuration.ensure_app_config(user_id)
if cfg_err:
- self.repo.update(user_id, {"status": "error", **lark_state.CLEAR_LOGIN, "last_error": cfg_err})
- return self.status_dict(self.repo.get(user_id))
+ await self.repo.update(user_id, {"status": "error", **lark_state.CLEAR_LOGIN, "last_error": cfg_err})
+ return self.status_dict(await self.repo.get(user_id))
out, err, rc = await lark_process.run_lark(user_id, lark_state.DEFAULT_LOGIN_ARGS, timeout=40)
dev = lark_parsing.parse_device_login(out)
if rc != 0 or not (dev.get("verification_url") or dev.get("verification_url_complete")):
tail = ((err or out or "").strip())[-200:]
- self.repo.update(user_id, {
+ await self.repo.update(user_id, {
"status": "error", **lark_state.CLEAR_LOGIN,
"last_error": f"发起扫码登录失败,请重试。{('详情: ' + tail) if tail else ''}",
})
- return self.status_dict(self.repo.get(user_id))
+ return self.status_dict(await self.repo.get(user_id))
- self.repo.update(user_id, {
+ await self.repo.update(user_id, {
"status": "pending",
"login_verification_url": dev.get("verification_url"),
"login_verification_url_complete": dev.get("verification_url_complete"),
@@ -98,14 +98,14 @@ async def start_device_login(self, user_id: str) -> Dict[str, Any]:
lark_state.LOGIN_TASKS[user_id] = asyncio.create_task(
lark_login.device_complete_flow(user_id, str(dev["device_code"]))
)
- return self.status_dict(self._fresh(user_id))
+ return self.status_dict(await self._fresh(user_id))
async def poll_login(self, user_id: str) -> Dict[str, Any]:
"""Poll login progress. The background task drives to connected/error;
this reads the latest state. If the task is lost yet still pending
(backend restart), fall back to the persisted device_code and run
completion once more."""
- record = self._fresh(user_id)
+ record = await self._fresh(user_id)
if record.status == "pending" and user_id not in lark_state.LOGIN_TASKS and record.login_device_code:
lark_state.LOGIN_TASKS[user_id] = asyncio.create_task(
lark_login.device_complete_flow(user_id, str(record.login_device_code))
@@ -133,7 +133,7 @@ async def disconnect(self, user_id: str) -> Dict[str, Any]:
purge_credential_dir(lark_cache_dir(user_id))
except Exception as exc: # noqa: BLE001
logger.warning("[lark] disconnect cache purge failed user=%s: %s", user_id, exc)
- self.repo.update(
+ await self.repo.update(
user_id,
{
"status": "disconnected",
@@ -146,7 +146,7 @@ async def disconnect(self, user_id: str) -> Dict[str, Any]:
"last_error": None,
},
)
- return self.status_dict(self.repo.get(user_id))
+ return self.status_dict(await self.repo.get(user_id))
# ── Admin: shared-app one-click initialization (config init --new) ────
def app_status(self) -> Dict[str, Any]:
diff --git a/src/backend/orchestration/automations/automation_conversation.py b/src/backend/orchestration/automations/automation_conversation.py
index 417b98c9..7468e6ae 100644
--- a/src/backend/orchestration/automations/automation_conversation.py
+++ b/src/backend/orchestration/automations/automation_conversation.py
@@ -20,6 +20,7 @@
from features.automation.service import AutomationService
from core.chat.sequencer import ChatSequencer
from core.chat.service import ChatService
+from core.db.threads import run_db, run_in_session
class ConversationStopped(Exception):
@@ -38,7 +39,7 @@ def _prepare(
metadata=None,
lease_token=None,
):
- chat_id = f"chat_{uuid.uuid4().hex[:16]}"
+ chat_id = f"chat_{automation_run_id}" if automation_run_id else f"chat_{uuid.uuid4().hex[:16]}"
with db_engine.SessionLocal() as db:
task = AutomationService(db).get_task(task_id, user_id)
if task is None:
@@ -54,7 +55,6 @@ def _prepare(
)
if content is not None:
service.add_message(chat_id=chat_id, role="user", content=content, model=model_name)
- _link_run(db, automation_run_id, task_id, chat_id, lease_token=lease_token)
return chat_id, context
@@ -70,80 +70,74 @@ def _link_run(db, automation_run_id, task_id, chat_id, *, lease_token=None):
query = query.filter(ScheduledTaskRun.lease_token == lease_token)
if not query.update({ScheduledTaskRun.chat_id: chat_id}, synchronize_session=False):
raise ValueError("定时任务执行记录不存在或已被接管")
- db.commit()
+ db.flush()
+
+
+def _accept_link(run_id, task_id, token, recovery_snapshot=None):
+ def link(db, child):
+ _link_run(db, run_id, task_id, child.chat_id, lease_token=token)
+ if recovery_snapshot is not None:
+ import json
+ child.recovery_snapshot = json.loads(json.dumps(recovery_snapshot, ensure_ascii=False, default=str))
+ child.snapshot_version = 1
+ child.run_phase = "accepted"
+ child.last_operation_safety = "replayable"
+ return link
async def _wait(run, *, automation_run_id=None, lease_token=None) -> Tuple[str, str, Dict]:
"""Observe durable state without taking ownership of the executor's asyncio tasks.
- Cancellation of the scheduler (shutdown/ownership loss) must also fence the child run.
+ Scheduler shutdown transfers observation; the shared executor owns the child run.
User cancellation is an outcome, not cancellation of the scheduler itself.
"""
try:
while True:
- current = runtime.get_run(run.run_id)
+ current = await asyncio.to_thread(runtime.get_run, run.run_id)
if current is None:
raise RuntimeError("会话执行记录不存在")
+ if current.status == "pending":
+ from orchestration.pending import dispatch_pending
+ await dispatch_pending(current)
if current.status not in ("pending", "running"):
- successor = handoff.get_successor_run(run.run_id, user_id=run.user_id)
+ successor = await asyncio.to_thread(handoff.get_successor_run, run.run_id, user_id=run.user_id)
if successor is not None:
run = successor
continue
# A cancelled cooperative plan may still be releasing its writer.
if current.status == "cancelled" and current.writer_slot:
- await asyncio.sleep(0.1)
+ await asyncio.sleep(1)
continue
if current.status == "cancelled":
raise ConversationStopped(run.chat_id)
if current.status != "completed":
raise RuntimeError(current.error_message or "会话执行未完成")
- with db_engine.SessionLocal() as db:
- durable = db.get(ChatRun, run.run_id)
- snapshot = durable.recovery_snapshot or {}
- message_id = (
- snapshot.get("message_id")
- or snapshot.get("worker_args", {}).get("context", {}).get("message_id")
- or run.message_id
- )
- message = db.get(ChatMessage, message_id)
- if message is None:
- raise RuntimeError("会话执行结果未保存")
- from core.infra.model_output import strip_think_blocks
-
- result = strip_think_blocks(message.content or "") or "执行完成"
- return run.chat_id, result, dict(message.usage or {})
- await asyncio.sleep(0.1)
+ return await asyncio.to_thread(_result, run)
+ await asyncio.sleep(1)
except asyncio.CancelledError:
- # Fence first, then check the durable handoff. Completion may win
- # the cancellation CAS and atomically create a successor in between.
- while True:
- if lease_token is not None:
- with db_engine.SessionLocal() as db:
- owner = db.get(ScheduledTaskRun, automation_run_id)
- if owner is None or owner.lease_token != lease_token:
- # A replacement observer adopted this same durable child.
- # An obsolete observer must not cancel its successor's work.
- break
- await runtime.cancel_run(
- run.run_id,
- user_id=run.user_id,
- **(
- {"cancel_guard": owned_run_guard(automation_run_id, lease_token)}
- if lease_token is not None
- else {}
- ),
- )
- successor = handoff.get_successor_run(run.run_id, user_id=run.user_id)
- if successor is None:
- break
- run = successor
+ # The durable child is owned by the shared run executor. Replacing an
+ # observer must not cancel accepted work; recovery follows the same run.
raise
-async def resume_conversation(
- chat_id, user_id, started_at, *, automation_run_id=None, lease_token=None
-):
- """Observe this invocation's original child and its durable successors."""
+def _result(run):
+ with db_engine.SessionLocal() as db:
+ durable = db.get(ChatRun, run.run_id)
+ snapshot = durable.recovery_snapshot or {}
+ message_id = (
+ snapshot.get("message_id")
+ or snapshot.get("worker_args", {}).get("context", {}).get("message_id")
+ or run.message_id
+ )
+ message = db.get(ChatMessage, message_id)
+ if message is None:
+ raise RuntimeError("会话执行结果未保存")
+ from core.infra.model_output import strip_think_blocks
+ result = strip_think_blocks(message.content or "") or "执行完成"
+ return run.chat_id, result, dict(message.usage or {})
+
+
+def _find_child(chat_id, user_id, started_at):
with db_engine.SessionLocal() as db:
# Prompt/Plan use fresh chats; Loop reuses its chat. The first main run
# after this invocation started is its root, not a later user message.
@@ -162,9 +156,89 @@ async def resume_conversation(
)
if run is None:
raise RuntimeError("定时任务关联的会话执行记录不存在")
+ return run
+
+
+async def resume_conversation(
+ chat_id, user_id, started_at, *, automation_run_id=None, lease_token=None
+):
+ """Observe this invocation's original child and its durable successors."""
+ run = await asyncio.to_thread(_find_child, chat_id, user_id, started_at)
return await _wait(run, automation_run_id=automation_run_id, lease_token=lease_token)
+def _accept_prompt_run(db, **kwargs):
+ accepted = ChatSequencer(db).accept_main_run(**kwargs)
+ db.refresh(accepted.run)
+ db.expunge(accepted.run)
+ return accepted.run
+
+
+def _abandon_run(db, run_id, reason):
+ ChatSequencer(db).abandon_pending_run(run_id, reason=reason)
+
+
+def _approve_plan(db, task_id, user_id, plan_id):
+ """Check the task and plan, re-arm a finished plan; returns the plan title."""
+ from core.chat.plans import PlanService
+
+ task = AutomationService(db).get_task(task_id, user_id)
+ if task is None:
+ raise ValueError("任务不存在或无权访问")
+ task_execution_context(db, task)
+ service = PlanService(db)
+ plan = service.get_plan(plan_id, user_id)
+ if plan is None:
+ raise ValueError("计划不存在或无权访问")
+ title = plan.title
+ if plan.status in ("completed", "failed", "cancelled"):
+ service.update_plan(plan_id, status="approved", completed_steps=0)
+ for step in plan.steps:
+ service.update_step(
+ step.step_id,
+ status="pending",
+ result_summary=None,
+ ai_output=None,
+ error_message=None,
+ )
+ elif plan.status == "draft":
+ service.update_plan(plan_id, status="approved")
+ return title
+
+
+def _loop_target(db, user_id, task_name, loop_id, task_id):
+ """Prepare the loop's chat; returns a skip notice, or (chat_id, goal_spec, budget, project_context)."""
+ from features.automation.loops import LoopService
+
+ task = AutomationService(db).get_task(task_id, user_id)
+ if task is None:
+ raise ValueError("任务不存在或无权访问")
+ project_context = task_execution_context(db, task)
+ loop = LoopService(db).get_loop(loop_id)
+ if loop is None or loop.user_id != user_id:
+ raise ValueError("loop 不存在或无权访问")
+ if loop.status in ("completed", "cancelled"):
+ return f"loop 已终态({loop.status}),跳过"
+ chat_id = loop.chat_id or f"loopchat_{loop_id}"
+ goal_spec, budget = dict(loop.goal_spec or {}), dict(loop.budget or {})
+ ChatService(db).ensure_session(
+ chat_id=chat_id,
+ user_id=user_id,
+ title=task_name,
+ project_id=project_context.get("project_id"),
+ extra_data={
+ "automation_task_id": task_id,
+ "automation_run": True,
+ "autonomous_loop": True,
+ "loop_id": loop_id,
+ },
+ )
+ if not loop.chat_id:
+ loop.chat_id = chat_id
+ db.commit()
+ return chat_id, goal_spec, budget, project_context
+
+
async def execute_prompt(
*,
user_id: str,
@@ -179,16 +253,20 @@ async def execute_prompt(
lease_token: Optional[str] = None,
):
model = user_model_selection.resolve_effective_chat_model_name() or DEFAULT_CHAT_MODEL_ALIAS
- chat_id, project_context = _prepare(
- user_id, task_id, task_name, None, model, automation_run_id, lease_token=lease_token
+ chat_id, project_context = await run_db(
+ _prepare, user_id, task_id, task_name, None, model, automation_run_id, lease_token=lease_token
)
- with db_engine.SessionLocal() as db:
+ def _enabled_capabilities(db):
skills, agents, mcps = resolve_enabled_capabilities(
db, user_id, enabled_skill_ids, enabled_agent_ids, enabled_mcp_ids
)
+ return mcps, agents, skills
+ mcps, agents, skills = await run_in_session(_enabled_capabilities)
from core.ontology.runtime_policy import build_user_ontology_runtime
- ontology_enabled, ontology_runtime = build_user_ontology_runtime(user_id=user_id, task=prompt)
+ ontology_enabled, ontology_runtime = await run_db(
+ build_user_ontology_runtime, user_id=user_id, task=prompt
+ )
context = build_runtime_context(
model_name=model,
enable_thinking=True,
@@ -217,25 +295,37 @@ async def execute_prompt(
"chat_mode": context["chat_mode"],
"enable_thinking": context["enable_thinking"],
}
- with db_engine.SessionLocal() as db:
- with ChatSequencer(db).launching_main_run(
+ worker_args = {
+ "session_messages": [{"role": "user", "content": prompt}],
+ "effective_user_message": prompt, "raw_user_message": prompt,
+ "context": context, "model_name": model,
+ }
+ accepted_run = await run_in_session(
+ _accept_prompt_run,
+ on_accept=_accept_link(
+ automation_run_id, task_id, lease_token, {"kind": "chat", "worker_args": worker_args}
+ ),
+ chat_id=chat_id,
+ user_id=user_id,
+ user_content=prompt,
+ request_payload=payload,
+ model=model,
+ )
+ try:
+ run = await admission.start_run(
+ accepted_run=accepted_run,
chat_id=chat_id,
user_id=user_id,
- user_content=prompt,
+ session_messages=[{"role": "user", "content": prompt}],
+ effective_user_message=prompt,
+ raw_user_message=prompt,
+ context=context,
request_payload=payload,
- model=model,
- ) as accepted:
- run = await admission.start_run(
- accepted_run=accepted.run,
- chat_id=chat_id,
- user_id=user_id,
- session_messages=[{"role": "user", "content": prompt}],
- effective_user_message=prompt,
- raw_user_message=prompt,
- context=context,
- request_payload=payload,
- model_name=model,
- )
+ model_name=model,
+ )
+ except BaseException as exc:
+ await run_in_session(_abandon_run, accepted_run.run_id, str(exc) or type(exc).__name__)
+ raise
return await _wait(run, automation_run_id=automation_run_id, lease_token=lease_token)
@@ -253,33 +343,11 @@ async def execute_plan(
automation_run_id: Optional[str] = None,
lease_token: Optional[str] = None,
):
- from core.chat.plans import PlanService
-
model = user_model_selection.resolve_effective_chat_model_name() or DEFAULT_CHAT_MODEL_ALIAS
- with db_engine.SessionLocal() as db:
- task = AutomationService(db).get_task(task_id, user_id)
- if task is None:
- raise ValueError("任务不存在或无权访问")
- task_execution_context(db, task)
- service = PlanService(db)
- plan = service.get_plan(plan_id, user_id)
- if plan is None:
- raise ValueError("计划不存在或无权访问")
- title = plan.title
- if plan.status in ("completed", "failed", "cancelled"):
- service.update_plan(plan_id, status="approved", completed_steps=0)
- for step in plan.steps:
- service.update_step(
- step.step_id,
- status="pending",
- result_summary=None,
- ai_output=None,
- error_message=None,
- )
- elif plan.status == "draft":
- service.update_plan(plan_id, status="approved")
+ title = await run_in_session(_approve_plan, task_id, user_id, plan_id)
content = f"自动化执行计划:{title}"
- chat_id, _ = _prepare(
+ chat_id, _ = await run_db(
+ _prepare,
user_id,
task_id,
task_name,
@@ -290,6 +358,7 @@ async def execute_plan(
lease_token=lease_token,
)
run = await _orchestration_plans_admission.start_plan_execute_run(
+ on_accept=_accept_link(automation_run_id, task_id, lease_token),
plan_id=plan_id,
chat_id=chat_id,
user_id=user_id,
@@ -306,41 +375,16 @@ async def execute_plan(
async def execute_loop(
*, user_id, task_name, loop_id, task_id, automation_run_id=None, lease_token=None
):
- from features.automation.loops import LoopService
-
if not loop_id:
raise ValueError("loop 任务缺少 extra_data.loop_id")
- with db_engine.SessionLocal() as db:
- task = AutomationService(db).get_task(task_id, user_id)
- if task is None:
- raise ValueError("任务不存在或无权访问")
- project_context = task_execution_context(db, task)
- loop = LoopService(db).get_loop(loop_id)
- if loop is None or loop.user_id != user_id:
- raise ValueError("loop 不存在或无权访问")
- if loop.status in ("completed", "cancelled"):
- return None, f"loop 已终态({loop.status}),跳过", {}
- chat_id = loop.chat_id or f"loopchat_{loop_id}"
- goal_spec, budget = dict(loop.goal_spec or {}), dict(loop.budget or {})
- ChatService(db).ensure_session(
- chat_id=chat_id,
- user_id=user_id,
- title=task_name,
- project_id=project_context.get("project_id"),
- extra_data={
- "automation_task_id": task_id,
- "automation_run": True,
- "autonomous_loop": True,
- "loop_id": loop_id,
- },
- )
- if not loop.chat_id:
- loop.chat_id = chat_id
- db.commit()
- _link_run(db, automation_run_id, task_id, chat_id, lease_token=lease_token)
- if runtime.get_active_run_for_chat(chat_id, user_id):
+ target = await run_in_session(_loop_target, user_id, task_name, loop_id, task_id)
+ if isinstance(target, str):
+ return None, target, {}
+ chat_id, goal_spec, budget, project_context = target
+ if await run_db(runtime.get_active_run_for_chat, chat_id, user_id):
return chat_id, "loop 已在推进中,跳过本次触发", {}
run = await _orchestration_loops_admission.start_autonomous_loop_run(
+ on_accept=_accept_link(automation_run_id, task_id, lease_token),
loop_id=loop_id,
chat_id=chat_id,
user_id=user_id,
diff --git a/src/backend/orchestration/automations/automation_dispatch.py b/src/backend/orchestration/automations/automation_dispatch.py
index 7c9f2e07..97b55a00 100644
--- a/src/backend/orchestration/automations/automation_dispatch.py
+++ b/src/backend/orchestration/automations/automation_dispatch.py
@@ -6,6 +6,7 @@
from core.infra.logging import get_logger
from core.infra.time import utc_now
+from core.db.threads import run_in_session
logger = get_logger(__name__)
@@ -19,9 +20,133 @@ def _run_chat_id(run_id):
return run.chat_id if run else None
-async def execute_task(scheduler, task_id: str, user_id: str, *, resume_run_id=None):
+def _claimed(db, task_id, user_id, run_id, lease_token):
+ """The leased run (detached) and a snapshot of its task, or None if the claim is stale."""
+ from features.automation.models import ScheduledTaskRun
+ from features.automation.service import AutomationService
+
+ task = AutomationService(db).get_task_by_id(task_id)
+ if not task or task.user_id != user_id:
+ return None
+ run = db.get(ScheduledTaskRun, run_id)
+ if (
+ run is None
+ or run.task_id != task_id
+ or run.status != "running"
+ or run.lease_token != lease_token
+ ):
+ return None
+ db.expunge(run)
+ return run, {
+ "task_type": task.task_type,
+ "task_name": task.name or "定时任务",
+ "prompt": task.prompt,
+ "plan_id": task.plan_id,
+ "consecutive_failures": task.consecutive_failures or 0,
+ "max_failures": task.max_failures or 3,
+ # Pass `None` (not `[]`) when the task wasn't configured with
+ # explicit IDs so that plan-mode falls back to plan-declared
+ # expected_* lists. An explicit empty list now means "strictly
+ # no tools/skills/agents".
+ "enabled_mcp_ids": task.enabled_mcp_ids or None,
+ "enabled_skill_ids": task.enabled_skill_ids or None,
+ "enabled_kb_ids": task.enabled_kb_ids or None,
+ "enabled_agent_ids": task.enabled_agent_ids or None,
+ "metadata": dict(task.extra_data or {}),
+ }
+
+
+def _record_success(db, run_id, task_id, *, lease_token, chat_id, result_text, duration_ms, usage):
+ """Returns whether the task still exists, or None when the run was taken over."""
+ from features.automation.service import AutomationService
+
+ svc = AutomationService(db)
+ # 任务可能在本次执行的几分钟里被用户删掉了(delete_task 是硬删)。
+ # 下面的 update_task_system / finalize 对不存在的任务本就是空操作,
+ # 但通知与渠道投递用的是开跑时抓下来的 task_name / task_metadata 快照,
+ # 不查这一下就会给一个已经删掉的任务推「执行完成」。
+ still_exists = svc.get_task_by_id(task_id) is not None
+ completed = svc.record_run_complete(
+ run_id,
+ lease_token=lease_token,
+ commit=False,
+ status="success",
+ chat_id=chat_id,
+ # 存全文:这一份同时是渠道投递的正文来源,截断只发生在展示侧
+ # (run_to_dict 的 summary_limit / 通知中心)。
+ result_summary=result_text,
+ duration_ms=duration_ms,
+ usage=usage,
+ )
+ if completed is None:
+ return None
+ svc.update_task_system(
+ task_id,
+ consecutive_failures=0,
+ last_run_at=utc_now(),
+ sidebar_activated=True,
+ )
+ # Admission advanced the schedule; completion finalizes exhausted tasks.
+ svc.finalize_after_run(task_id)
+ return still_exists
+
+
+def _record_stopped(db, run_id, task_id, *, lease_token, chat_id, duration_ms) -> bool:
+ from features.automation.service import AutomationService
+
+ svc = AutomationService(db)
+ completed = svc.record_run_complete(
+ run_id,
+ lease_token=lease_token,
+ commit=False,
+ status="failed",
+ chat_id=chat_id,
+ error_message="用户已停止执行",
+ duration_ms=duration_ms,
+ )
+ if completed is None:
+ return False
+ svc.update_task_system(task_id, last_run_at=utc_now(), sidebar_activated=True)
+ svc.finalize_after_run(task_id)
+ return True
+
+
+def _record_failure(
+ db, run_id, task_id, *, lease_token, error_msg, duration_ms, failures, max_failures
+):
+ """Returns whether the task still exists, or None when the run was taken over."""
+ from features.automation.service import AutomationService
+
+ svc = AutomationService(db)
+ still_exists = svc.get_task_by_id(task_id) is not None
+ completed = svc.record_run_complete(
+ run_id,
+ lease_token=lease_token,
+ commit=False,
+ status="failed",
+ chat_id=_run_chat_id(run_id),
+ error_message=error_msg,
+ duration_ms=duration_ms,
+ )
+ if completed is None:
+ return None
+ updates: Dict[str, Any] = {
+ "consecutive_failures": failures,
+ "last_error": error_msg,
+ "last_run_at": utc_now(),
+ "sidebar_activated": True,
+ }
+ if failures >= max_failures:
+ updates["status"] = "disabled"
+ logger.warning("[scheduler] task %s auto-disabled after %d failures", task_id, failures)
+ svc.update_task_system(task_id, **updates)
+ # Finalize failed one-shot tasks without overriding auto-disable.
+ svc.finalize_after_run(task_id)
+ return still_exists
+
+
+async def execute_task(scheduler, task_id: str, user_id: str, *, run_id: str):
"""Execute a single scheduled task."""
- from core.db.engine import SessionLocal
from features.automation.service import AutomationService
from .automation_conversation import (
@@ -35,45 +160,21 @@ async def execute_task(scheduler, task_id: str, user_id: str, *, resume_run_id=N
lease_token = scheduler._leases[task_id].token
start = time.monotonic()
- with SessionLocal() as db:
- svc = AutomationService(db)
- task = svc.get_task_by_id(task_id)
- if not task or task.user_id != user_id:
- return
- if resume_run_id is None and task.status not in ("active", "paused"):
- return
- if resume_run_id is None:
- run = svc.record_run_start(task_id, lease_token=lease_token)
- else:
- from features.automation.models import ScheduledTaskRun
-
- run = db.get(ScheduledTaskRun, resume_run_id)
- if (
- run is None
- or run.task_id != task_id
- or run.status != "running"
- or run.lease_token != lease_token
- ):
- return
- task_type = task.task_type
- task_name = task.name or "定时任务"
- task_prompt = task.prompt
- task_plan_id = task.plan_id
- task_consecutive_failures = task.consecutive_failures or 0
- task_max_failures = task.max_failures or 3
- # Pass `None` (not `[]`) when the task wasn't configured with
- # explicit IDs so that plan-mode falls back to plan-declared
- # expected_* lists. An explicit empty list now means "strictly
- # no tools/skills/agents".
- enabled_mcp_ids = task.enabled_mcp_ids or None
- enabled_skill_ids = task.enabled_skill_ids or None
- enabled_kb_ids = task.enabled_kb_ids or None
- enabled_agent_ids = task.enabled_agent_ids or None
- task_metadata = dict(
- task.extra_data or {}
- ) # includes optional channel delivery destinations
-
- if resume_run_id is not None:
+ claimed = await run_in_session(_claimed, task_id, user_id, run_id, lease_token)
+ if claimed is None:
+ return
+ run, task = claimed
+ task_type = task["task_type"]
+ task_name = task["task_name"]
+ task_consecutive_failures = task["consecutive_failures"]
+ task_max_failures = task["max_failures"]
+ enabled_mcp_ids = task["enabled_mcp_ids"]
+ enabled_skill_ids = task["enabled_skill_ids"]
+ enabled_kb_ids = task["enabled_kb_ids"]
+ enabled_agent_ids = task["enabled_agent_ids"]
+ task_metadata = task["metadata"] # includes optional channel delivery destinations
+
+ if run.chat_id:
from core.infra.time import as_utc
start -= max(0, (utc_now() - as_utc(run.started_at)).total_seconds())
@@ -81,12 +182,13 @@ async def execute_task(scheduler, task_id: str, user_id: str, *, resume_run_id=N
try:
from features.automation.execution import task_execution_context
- with SessionLocal() as db:
+ def _check_task(db):
current_task = AutomationService(db).get_task(task_id, user_id)
if current_task is None:
raise ValueError("任务已删除")
task_execution_context(db, current_task)
- if resume_run_id is not None:
+ await run_in_session(_check_task)
+ if run.chat_id:
from .automation_conversation import resume_conversation
chat_id, result_text, usage = await resume_conversation(
@@ -100,7 +202,7 @@ async def execute_task(scheduler, task_id: str, user_id: str, *, resume_run_id=N
chat_id, result_text, usage = await execute_prompt(
user_id=user_id,
task_name=task_name,
- prompt=task_prompt,
+ prompt=task["prompt"],
task_id=task_id,
automation_run_id=run.run_id,
lease_token=lease_token,
@@ -113,7 +215,7 @@ async def execute_task(scheduler, task_id: str, user_id: str, *, resume_run_id=N
chat_id, result_text, usage = await execute_plan(
user_id=user_id,
task_name=task_name,
- plan_id=task_plan_id,
+ plan_id=task["plan_id"],
task_id=task_id,
automation_run_id=run.run_id,
lease_token=lease_token,
@@ -137,39 +239,18 @@ async def execute_task(scheduler, task_id: str, user_id: str, *, resume_run_id=N
duration_ms = int((time.monotonic() - start) * 1000)
- still_exists = True
- with SessionLocal() as db:
- svc = AutomationService(db)
- # 任务可能在本次执行的几分钟里被用户删掉了(delete_task 是硬删)。
- # 下面的 update_task_system / finalize 对不存在的任务本就是空操作,
- # 但通知与渠道投递用的是开跑时抓下来的 task_name / task_metadata 快照,
- # 不查这一下就会给一个已经删掉的任务推「执行完成」。
- still_exists = svc.get_task_by_id(task_id) is not None
- completed = svc.record_run_complete(
- run.run_id,
- lease_token=lease_token,
- commit=False,
- status="success",
- chat_id=chat_id,
- # 存全文:这一份同时是渠道投递的正文来源,截断只发生在展示侧
- # (run_to_dict 的 summary_limit / 通知中心)。
- result_summary=result_text,
- duration_ms=duration_ms,
- usage=usage,
- )
- if completed is None:
- return
- svc.update_task_system(
- task_id,
- consecutive_failures=0,
- last_run_at=utc_now(),
- sidebar_activated=True,
- )
- # next_run_at already pre-advanced in _check_and_fire; now that
- # the run actually finished, flip one-shot/exhausted tasks to
- # "completed" (advance_next_run intentionally left them active so
- # this run could pass the execute_task guard).
- svc.finalize_after_run(task_id)
+ still_exists = await run_in_session(
+ _record_success,
+ run.run_id,
+ task_id,
+ lease_token=lease_token,
+ chat_id=chat_id,
+ result_text=result_text,
+ duration_ms=duration_ms,
+ usage=usage,
+ )
+ if still_exists is None:
+ return
logger.info("[scheduler] task %s completed in %dms", task_id, duration_ms)
if still_exists:
@@ -185,75 +266,36 @@ async def execute_task(scheduler, task_id: str, user_id: str, *, resume_run_id=N
)
except ConversationStopped as stopped:
- with SessionLocal() as db:
- svc = AutomationService(db)
- completed = svc.record_run_complete(
- run.run_id,
- lease_token=lease_token,
- commit=False,
- status="failed",
- chat_id=stopped.chat_id,
- error_message="用户已停止执行",
- duration_ms=int((time.monotonic() - start) * 1000),
- )
- if completed is None:
- return
- svc.update_task_system(task_id, last_run_at=utc_now(), sidebar_activated=True)
- svc.finalize_after_run(task_id)
+ stopped_recorded = await run_in_session(
+ _record_stopped,
+ run.run_id,
+ task_id,
+ lease_token=lease_token,
+ chat_id=stopped.chat_id,
+ duration_ms=int((time.monotonic() - start) * 1000),
+ )
+ if not stopped_recorded:
+ return
logger.info("[scheduler] task %s stopped by user", task_id)
except asyncio.CancelledError:
- with SessionLocal() as db:
- svc = AutomationService(db)
- completed = svc.record_run_complete(
- run.run_id,
- lease_token=lease_token,
- commit=False,
- status="failed",
- chat_id=_run_chat_id(run.run_id),
- error_message="执行已中断",
- duration_ms=int((time.monotonic() - start) * 1000),
- )
- if completed is not None:
- db.commit()
- svc.finalize_after_run(task_id)
raise
except Exception as e:
duration_ms = int((time.monotonic() - start) * 1000)
error_msg = str(e)[:2000] or type(e).__name__
logger.error("[scheduler] task %s failed: %s", task_id, error_msg, exc_info=True)
- still_exists = True
- with SessionLocal() as db:
- svc = AutomationService(db)
- still_exists = svc.get_task_by_id(task_id) is not None
- completed = svc.record_run_complete(
- run.run_id,
- lease_token=lease_token,
- commit=False,
- status="failed",
- chat_id=_run_chat_id(run.run_id),
- error_message=error_msg,
- duration_ms=duration_ms,
- )
- if completed is None:
- return
- new_failures = task_consecutive_failures + 1
- updates: Dict[str, Any] = {
- "consecutive_failures": new_failures,
- "last_error": error_msg,
- "last_run_at": utc_now(),
- "sidebar_activated": True,
- }
- if new_failures >= task_max_failures:
- updates["status"] = "disabled"
- logger.warning(
- "[scheduler] task %s auto-disabled after %d failures", task_id, new_failures
- )
- svc.update_task_system(task_id, **updates)
- # next_run_at already pre-advanced in _check_and_fire. Finalize
- # one-shot tasks so a failed single run lands in a terminal state
- # instead of dangling active (finalize_after_run skips disabled).
- svc.finalize_after_run(task_id)
+ still_exists = await run_in_session(
+ _record_failure,
+ run.run_id,
+ task_id,
+ lease_token=lease_token,
+ error_msg=error_msg,
+ duration_ms=duration_ms,
+ failures=task_consecutive_failures + 1,
+ max_failures=task_max_failures,
+ )
+ if still_exists is None:
+ return
if still_exists:
await deliver_result(scheduler, user_id, task_id, task_name, "failed", error_msg[:200])
diff --git a/src/backend/orchestration/automations/automation_lease.py b/src/backend/orchestration/automations/automation_lease.py
index 34989ba3..c929dc23 100644
--- a/src/backend/orchestration/automations/automation_lease.py
+++ b/src/backend/orchestration/automations/automation_lease.py
@@ -34,19 +34,26 @@ def _finished(self):
async def _renew(self):
stopped = asyncio.create_task(self._stop.wait())
+ attempt = None
try:
while not self._finished():
await asyncio.wait({stopped}, timeout=RENEW_INTERVAL_SECONDS)
if self._finished():
return
# CAS cannot recreate a deleted key or replace another token.
- if not await self.state.compare_exchange(
- self.key, self.token, self.token, ttl=LEASE_TTL_SECONDS
- ):
+ attempt = spawn(
+ self.state.compare_exchange(
+ self.key, self.token, self.token, ttl=LEASE_TTL_SECONDS
+ ), name="automation.lease.cas",
+ )
+ done, _ = await asyncio.wait({attempt}, timeout=RENEW_INTERVAL_SECONDS)
+ if not done or not attempt.result():
return
if self._finished():
return
finally:
+ if attempt is not None and not attempt.done():
+ attempt.cancel()
stopped.cancel()
await asyncio.gather(stopped, return_exceptions=True)
diff --git a/src/backend/orchestration/automations/automation_recovery.py b/src/backend/orchestration/automations/automation_recovery.py
index 01d3db97..0a92ace9 100644
--- a/src/backend/orchestration/automations/automation_recovery.py
+++ b/src/backend/orchestration/automations/automation_recovery.py
@@ -1,66 +1,39 @@
-"""Recover automation outcomes by observing their durable child conversations."""
-
+"""Take over a durable invocation without creating another run record."""
+import asyncio
from core.db import engine as db_engine
from features.automation.models import ScheduledTaskRun
-from core.infra.time import as_utc, utc_now
from features.automation.runs import AutomationRunRepository
from features.automation.service import AutomationService
-async def recover_task(scheduler, task_id):
- # The caller renews task ownership while the shared ChatRun executor resumes
- # the durable child. Do not start a second child or abandon the old result.
+def _runs(task_id):
with db_engine.SessionLocal() as db:
task = AutomationService(db).get_task_by_id(task_id)
if task is None:
- return
- user_id = task.user_id
- runs = [
- (row.run_id, row.chat_id, row.lease_token)
- for row in db.query(ScheduledTaskRun)
- .filter(
+ return None, []
+ return task.user_id, [
+ (row.run_id, row.lease_token)
+ for row in db.query(ScheduledTaskRun).filter(
ScheduledTaskRun.task_id == task_id,
- ScheduledTaskRun.status == "running",
- )
- .all()
+ ScheduledTaskRun.status.in_(("pending", "running")),
+ ).order_by(ScheduledTaskRun.started_at).all()
]
+
+
+def _claim(run_id, token, expected):
+ with db_engine.SessionLocal() as db:
+ return AutomationRunRepository(db).claim(run_id, token, expected_token=expected)
+
+
+async def recover_task(scheduler, task_id):
+ user_id, runs = await asyncio.to_thread(_runs, task_id)
lease = scheduler._leases[task_id]
- lease_token = lease.token
- for run_id, chat_id, expected_token in runs:
+ for run_id, expected in runs:
if not await lease.owns():
return
- with db_engine.SessionLocal() as db:
- if not AutomationRunRepository(db).claim(
- run_id, lease_token, expected_token=expected_token
- ):
- continue
- claimed = db.get(ScheduledTaskRun, run_id)
- if claimed is None or claimed.status != "running" or claimed.lease_token != lease_token:
- continue
- # The old owner may have linked its child after our first snapshot
- # but before the conditional claim. Observe the claimed row's link.
- chat_id = claimed.chat_id
+ if not await asyncio.to_thread(_claim, run_id, lease.token, expected):
+ continue
if not await lease.owns():
return
- if chat_id:
- from .automation_dispatch import execute_task
-
- await execute_task(scheduler, task_id, user_id, resume_run_id=run_id)
- else:
- # No conversation was ever linked; there is no child to resume.
- with db_engine.SessionLocal() as db:
- service = AutomationService(db)
- if (
- service.record_run_complete(
- run_id,
- lease_token=lease_token,
- status="failed",
- error_message="interrupted (orphan, no linked conversation)",
- )
- is None
- ):
- continue
- task = service.get_task_by_id(task_id)
- if task and task.next_run_at and as_utc(task.next_run_at) <= utc_now():
- service.advance_next_run(task_id)
- service.finalize_after_run(task_id)
+ from .automation_dispatch import execute_task
+ await execute_task(scheduler, task_id, user_id, run_id=run_id)
diff --git a/src/backend/orchestration/automations/automation_scheduler.py b/src/backend/orchestration/automations/automation_scheduler.py
index ce8e59d2..a3d32ed4 100644
--- a/src/backend/orchestration/automations/automation_scheduler.py
+++ b/src/backend/orchestration/automations/automation_scheduler.py
@@ -1,278 +1,87 @@
-"""Automation scheduler — polls the DB for due tasks and fires them.
-
-Inspired by claude-code's CronScheduler:
-- Polls every 15s for due tasks
-- Uses Redis distributed lock to prevent double-firing across instances
-- Handles missed tasks on startup
-- Auto-disables tasks after consecutive failure threshold
-- Writes notifications to Redis for frontend polling
-"""
-
+"""Bounded dispatch of durable automation invocations."""
import asyncio
import contextlib
-import random
import uuid
-from typing import List, Optional, Tuple
-from core.infra.background import spawn
from core.infra.logging import get_logger
from core.infra.time import utc_now
logger = get_logger(__name__)
-
-
-POLL_INTERVAL_SECONDS = 15
-# 手动触发要比 cron 轮询响应快得多——按钮叫「立即执行」,等 15 秒不叫立即。一拍就是
-# 一条 UPDATE,所以这一档只取到「点下去像是立刻有反应」为止,不必更密。
-MANUAL_DRAIN_INTERVAL_SECONDS = 3
+POLL_INTERVAL_SECONDS = 3
+MAX_EXECUTIONS = 8
REDIS_LOCK_PREFIX = "jx:auto:lock:"
class AutomationScheduler:
- """Async scheduler that polls the DB for due tasks and fires them."""
-
def __init__(self):
self._running = False
- self._task: Optional[asyncio.Task] = None
- self._manual_task: Optional[asyncio.Task] = None
- self._executions: set[asyncio.Task] = set()
+ self._task = None
+ self._executions = set()
self._leases = {}
async def start(self):
+ if self._running:
+ return
self._running = True
self._task = asyncio.create_task(self._poll_loop())
- self._manual_task = asyncio.create_task(self._manual_drain_loop())
- logger.info("[scheduler] started")
async def stop(self):
self._running = False
- for task in (self._task, self._manual_task):
- if task:
- task.cancel()
- with contextlib.suppress(asyncio.CancelledError):
- await task
+ if self._task is not None:
+ self._task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await self._task
+ self._task = None
executions = list(self._executions)
- for execution in executions:
- execution.cancel()
- if executions:
- await asyncio.gather(*executions, return_exceptions=True)
- # A queued execution can be cancelled before its coroutine enters its
- # finally block. Its already-started renewal must still stop with us.
+ for task in executions:
+ task.cancel()
+ await asyncio.gather(*executions, return_exceptions=True)
for task_id in list(self._leases):
await self._release_lock(task_id)
- logger.info("[scheduler] stopped")
-
- async def _poll_loop(self):
- # Small initial delay to let the app finish startup
- await asyncio.sleep(5)
-
- await self._recover_stuck_running_runs()
- # Then handle missed one-shot tasks
- await self._recover_missed_tasks()
-
- while self._running:
- try:
- await self._recover_stuck_running_runs()
- await self._check_and_fire()
- except Exception as e:
- logger.error("[scheduler] poll error: %s", e, exc_info=True)
- jitter = random.uniform(0, 5)
- await asyncio.sleep(POLL_INTERVAL_SECONDS + jitter)
-
- async def _manual_drain_loop(self):
- """执行用户点下的「立即执行」,节奏见 MANUAL_DRAIN_INTERVAL_SECONDS。"""
- await asyncio.sleep(5)
- while self._running:
- try:
- await self._fire_manual_triggers()
- except Exception as e:
- logger.error("[scheduler] manual drain error: %s", e, exc_info=True)
- await asyncio.sleep(MANUAL_DRAIN_INTERVAL_SECONDS)
-
- async def _fire_manual_triggers(self):
- # 每 3 秒一次的同步 DB 调用不能压在事件循环上——这个 worker 的 SSE 流和它抢同
- # 一个连接池,池满时 checkout 会把整个循环卡住 DB_POOL_TIMEOUT 那么久。
- pending = await asyncio.to_thread(self._take_manual_triggers)
- for task_id, user_id in pending:
- # 手动触发不推进 next_run_at——用户按一次不该打乱 cron 排期。
- await self._fire(task_id, user_id, "manual trigger", self._bump_run_count)
-
- @staticmethod
- def _take_manual_triggers() -> List[Tuple[str, str]]:
- from core.db.engine import SessionLocal
- from features.automation.service import AutomationService
-
- with SessionLocal() as db:
- return AutomationService(db).take_manual_triggers()
-
- async def _check_and_fire(self):
- from core.db.engine import SessionLocal
- from features.automation.service import AutomationService
-
- now = utc_now()
- with SessionLocal() as db:
- svc = AutomationService(db)
- due_tasks = svc.get_due_tasks(now)
-
- if not due_tasks:
- return
-
- logger.info("[scheduler] found %d due tasks", len(due_tasks))
- for task in due_tasks:
- # Pre-advance next_run_at BEFORE firing so the schedule moves
- # on regardless of whether the run itself succeeds, fails, or
- # gets killed mid-flight. Mirrors how real cron behaves and
- # prevents the death-spiral where a stuck "running" row leaves
- # next_run_at in the past forever and causes every poll to
- # re-fire the same task. The success/failure branches in
- # execute_task no longer call advance_next_run.
- await self._fire(task.task_id, task.user_id, "due task", self._advance_next_run)
-
- @staticmethod
- def _advance_next_run(task_id: str) -> None:
- from core.db.engine import SessionLocal
- from features.automation.service import AutomationService
-
- with SessionLocal() as db:
- AutomationService(db).advance_next_run(task_id)
@staticmethod
- def _bump_run_count(task_id: str) -> None:
- # run_count 只在 advance_next_run 里 +1,而手动触发不走那条路,所以单独补一次。
+ def _pending():
from core.db.engine import SessionLocal
- from features.automation.service import AutomationService
+ from features.automation.admission import admit, candidates
with SessionLocal() as db:
- AutomationService(db).bump_run_count(task_id)
+ pending = candidates(db, MAX_EXECUTIONS)
+ admit(db, max(0, MAX_EXECUTIONS - len(pending)))
+ return candidates(db, MAX_EXECUTIONS)
- async def _fire(self, task_id: str, user_id: str, kind: str, pre_run) -> None:
- """取锁 → 跑 pre_run 记账 → 后台执行。定时与手动触发共用,所以两者对同一个任务
- 不会并发跑;pre_run 失败不拦截执行,理由同 _check_and_fire 的 pre-advance。"""
- if not await self._acquire_lock(task_id):
- logger.info("[scheduler] %s for %s skipped: already running", kind, task_id)
+ async def _poll_loop(self):
+ while self._running:
+ try:
+ await self._dispatch_pending()
+ except Exception:
+ logger.exception("automation dispatch deferred")
+ await asyncio.sleep(POLL_INTERVAL_SECONDS)
+
+ async def _dispatch_pending(self):
+ capacity = MAX_EXECUTIONS - len(self._executions)
+ if capacity <= 0:
return
- try:
- if await asyncio.to_thread(self._has_running_runs, task_id):
- self._launch_recovery(task_id)
- return
- await asyncio.to_thread(pre_run, task_id)
- except asyncio.CancelledError:
- await self._release_lock(task_id)
- raise
- except Exception as exc:
- logger.warning(
- "[scheduler] pre-run bookkeeping failed for %s: %s — firing anyway", task_id, exc
- )
- logger.info("[scheduler] firing %s for %s", kind, task_id)
- self._launch_execution(task_id, user_id, lease=self._leases[task_id])
-
- def _launch_execution(self, task_id: str, user_id: str, *, lease=None) -> asyncio.Task:
- execution = spawn(
- self.execute_task(task_id, user_id, lease=lease), name=f"automation.{task_id}"
- )
- self._executions.add(execution)
- execution.add_done_callback(self._executions.discard)
- return execution
-
- async def execute_task(self, task_id: str, user_id: str, *, lease=None):
- from .automation_dispatch import execute_task
-
- if lease is None:
+ for task_id in await asyncio.to_thread(self._pending):
+ if len(self._executions) >= MAX_EXECUTIONS:
+ break
if not await self._acquire_lock(task_id):
- return
- lease = self._leases[task_id]
- try:
- if await asyncio.to_thread(self._has_running_runs, task_id):
- from .automation_recovery import recover_task
-
- await lease.run(recover_task(self, task_id))
- else:
- await lease.run(execute_task(self, task_id, user_id))
- finally:
- await self._release_lock(task_id)
-
- @staticmethod
- def _has_running_runs(task_id):
- from core.db.engine import SessionLocal
- from features.automation.models import ScheduledTaskRun
+ continue
+ task = asyncio.create_task(self.execute_task(task_id))
+ self._executions.add(task)
+ task.add_done_callback(self._executions.discard)
- with SessionLocal() as db:
- return (
- db.query(ScheduledTaskRun.run_id)
- .filter(
- ScheduledTaskRun.task_id == task_id,
- ScheduledTaskRun.status == "running",
- )
- .first()
- is not None
- )
-
- def _launch_recovery(self, task_id):
- recovery = spawn(self._recover_task(task_id), name=f"automation.recover.{task_id}")
- self._executions.add(recovery)
- recovery.add_done_callback(self._executions.discard)
-
- async def _recover_stuck_running_runs(self):
- """Recover abandoned runs only after acquiring their execution ownership.
-
- Elapsed runtime cannot identify an orphan: a healthy task may run for
- days. A live worker's renewable lease excludes recovery on every poll.
- """
- from core.db.engine import SessionLocal
- from features.automation.models import ScheduledTaskRun
-
- try:
- with SessionLocal() as db:
- task_ids = [
- row[0]
- for row in db.query(ScheduledTaskRun.task_id)
- .filter(ScheduledTaskRun.status == "running")
- .distinct()
- .all()
- ]
- for task_id in task_ids:
- if not await self._acquire_lock(task_id):
- continue
- self._launch_recovery(task_id)
- except Exception as exc:
- logger.error("[scheduler] orphan recovery failed: %s", exc, exc_info=True)
-
- async def _recover_task(self, task_id):
+ async def execute_task(self, task_id):
from .automation_recovery import recover_task
try:
await self._leases[task_id].run(recover_task(self, task_id))
- except Exception as exc:
- logger.error(
- "[scheduler] orphan recovery failed for %s: %s", task_id, exc, exc_info=True
- )
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ logger.exception("automation execution deferred task=%s", task_id)
finally:
await self._release_lock(task_id)
- async def _recover_missed_tasks(self):
- """On startup, check for one-shot tasks whose next_run_at is in the past."""
- from core.db.engine import SessionLocal
- from features.automation.service import AutomationService
-
- now = utc_now()
- try:
- with SessionLocal() as db:
- svc = AutomationService(db)
- missed = svc.get_due_tasks(now)
- one_shot_count = 0
- for task in missed:
- if not task.recurring:
- one_shot_count += 1
- await self._fire(
- task.task_id, task.user_id, "missed task", self._advance_next_run
- )
- if one_shot_count:
- logger.info("[scheduler] recovering %d missed one-shot tasks", one_shot_count)
- except Exception as e:
- logger.error("[scheduler] recovery error: %s", e)
-
- # ── Redis lock helpers ─────────────────────────────────────────
-
async def _acquire_lock(self, task_id: str) -> bool:
from .automation_lease import AutomationLease
@@ -297,8 +106,6 @@ async def _release_lock(self, task_id: str):
except Exception as e:
logger.error("[scheduler] lock release failed for %s: %s", task_id, e)
- # ── Notification ───────────────────────────────────────────────
-
async def _send_notification(
self,
user_id: str,
@@ -306,7 +113,7 @@ async def _send_notification(
task_name: str,
status: str,
summary: str,
- chat_id: Optional[str] = None,
+ chat_id: str | None = None,
):
try:
from features.automation import notifications as notifications
diff --git a/src/backend/orchestration/loops/admission.py b/src/backend/orchestration/loops/admission.py
index 8460f987..bdca4c9d 100644
--- a/src/backend/orchestration/loops/admission.py
+++ b/src/backend/orchestration/loops/admission.py
@@ -4,12 +4,10 @@
from typing import Any, Dict, Optional
-import core.db.engine as engine
import core.runs.ownership as ownership
-import core.runs.runtime as runtime
-import orchestration.loops.run as _orchestration_loops_run
from core.db.models import ChatRun
from core.infra.logging import get_logger
+from core.db.threads import run_in_session
logger = get_logger(__name__)
@@ -31,6 +29,7 @@ async def start_autonomous_loop_run(
is_resume: bool = False,
project_id: Optional[str] = None,
automation_run: bool = False,
+ on_accept=None,
) -> ChatRun:
"""Start an autonomous-loop run (background task + Redis Stream, mirroring start_plan_execute_run)."""
if not chat_id:
@@ -61,7 +60,7 @@ async def start_autonomous_loop_run(
try:
from features.automation.loops import LoopService as _LoopSvc
- with engine.SessionLocal() as db:
+ def _save_start_params(db):
_LoopSvc(db).save_start_params(
loop_id,
{
@@ -75,14 +74,32 @@ async def start_autonomous_loop_run(
"automation_run": automation_run,
},
)
+ await run_in_session(_save_start_params)
except Exception: # noqa: BLE001 - 参数存档失败不阻塞启动
logger.warning("loop start_params persist failed", exc_info=True)
+ initial_args = {
+ "loop_id": loop_id,
+ "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,
+ "enable_thinking": enable_thinking,
+ "chat_mode": chat_mode,
+ "is_resume": is_resume,
+ "project_id": project_id,
+ "automation_run": automation_run,
+ }
run = ownership.create_run_record(
+ on_accept=on_accept,
chat_id=chat_id,
user_id=user_id,
request_payload=request_payload,
recovery_snapshot={
"kind": "autonomous_loop",
+ "initial_args": initial_args,
"worker_args": {
"context": {
"model_name": model_name,
@@ -94,31 +111,8 @@ async def start_autonomous_loop_run(
},
},
)
- journal_owner = ownership.new_worker_owner(run.run_id)
- runtime.register_run_task(
- run.run_id,
- _orchestration_loops_run._run_autonomous_loop_workflow(
- run_id=run.run_id,
- loop_id=loop_id,
- chat_id=chat_id,
- user_id=user_id,
- message_id=run.message_id,
- 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,
- enable_thinking=enable_thinking,
- chat_mode=chat_mode,
- is_resume=is_resume,
- project_id=project_id,
- automation_run=automation_run,
- journal_owner=journal_owner,
- ),
- name=f"autoloop_run:{run.run_id}",
- )
+ from orchestration.pending import dispatch_pending
+ await dispatch_pending(run)
logger.info(
"autonomous_loop_run_started",
run_id=run.run_id,
diff --git a/src/backend/orchestration/loops/recovery.py b/src/backend/orchestration/loops/recovery.py
index b83ce4e8..fa3998a2 100644
--- a/src/backend/orchestration/loops/recovery.py
+++ b/src/backend/orchestration/loops/recovery.py
@@ -4,13 +4,22 @@
import os
-import core.db.engine as engine
import orchestration.loops.admission as _orchestration_loops_admission
from core.infra.logging import get_logger
+from core.db.threads import run_in_session
logger = get_logger(__name__)
+def _mark_interrupted(db, loop_ids) -> None:
+ from features.automation.loops import LoopService
+
+ for loop_id in loop_ids:
+ LoopService(db).mark_interrupted(
+ loop_id, reason="服务重启导致运行中断,可点击「继续」从断点续跑"
+ )
+
+
async def resume_running_loops() -> int:
"""启动对账 + 按需自动续跑:进程重启后收养/归位孤儿自主循环。
@@ -33,7 +42,7 @@ async def resume_running_loops() -> int:
"yes",
)
resumed = 0
- with engine.SessionLocal() as db:
+ def _running_loop_specs(db):
loops = db.query(AgentLoop).filter(AgentLoop.status == "running").all()
specs = [
(
@@ -47,16 +56,12 @@ async def resume_running_loops() -> int:
)
for x in loops
]
+ return specs
+ specs = await run_in_session(_running_loop_specs)
if not auto:
if specs:
- from features.automation.loops import LoopService as _LoopSvc
-
- with engine.SessionLocal() as db:
- for loop_id, *_rest in specs:
- _LoopSvc(db).mark_interrupted(
- loop_id, reason="服务重启导致运行中断,可点击「继续」从断点续跑"
- )
+ await run_in_session(_mark_interrupted, [spec[0] for spec in specs])
logger.info("[startup] orphan loops marked interrupted: %d", len(specs))
return 0
diff --git a/src/backend/orchestration/loops/run.py b/src/backend/orchestration/loops/run.py
index 872c6532..37d74343 100644
--- a/src/backend/orchestration/loops/run.py
+++ b/src/backend/orchestration/loops/run.py
@@ -11,6 +11,7 @@
from typing import Any, Dict, List, Optional
import core.db.engine as engine
+from core.db.threads import run_db, run_in_session
import core.runs.events.writer as writer
import core.runs.ownership as ownership
import core.runs.runtime as runtime
@@ -24,6 +25,18 @@
logger = get_logger(__name__)
+def _project_binding(db, project_id, chat_id):
+ """The project context, and whether the loop's chat still needs binding to it."""
+ from core.db.models import ChatSession
+ from core.projects.edition import build_project_ctx
+
+ project_ctx = build_project_ctx(db, project_id)
+ if not project_ctx:
+ return project_ctx, False
+ sess = db.get(ChatSession, chat_id)
+ return project_ctx, sess is not None and sess.project_id != project_id
+
+
async def _run_autonomous_loop_workflow(
*,
run_id: str,
@@ -50,7 +63,7 @@ async def _run_autonomous_loop_workflow(
owner = journal_owner or ownership.new_worker_owner(run_id)
journal = ownership.journal()
- if not journal.claim(run_id, owner=owner, lease_seconds=settings.RUN_LEASE_SECONDS):
+ if not journal.claim(run_id, owner=owner, lease_seconds=settings.RUN_LEASE_SECONDS, pending_only=True):
logger.info("autonomous_loop_claim_skipped", run_id=run_id, owner=owner)
return
current = asyncio.current_task()
@@ -234,14 +247,10 @@ def _commit(db) -> None: # noqa: ANN001
if project_id:
try:
from core.db.models import ChatSession
- from core.projects.edition import build_project_ctx
- needs_binding = False
- with engine.SessionLocal() as db:
- project_ctx = build_project_ctx(db, project_id)
- if project_ctx:
- sess = db.get(ChatSession, chat_id)
- needs_binding = sess is not None and sess.project_id != project_id
+ project_ctx, needs_binding = await run_in_session(
+ _project_binding, project_id, chat_id
+ )
if project_ctx and needs_binding:
@@ -250,7 +259,8 @@ def _commit_project_binding(db) -> None: # noqa: ANN001
if sess is not None:
sess.project_id = project_id
- journal.append_operation(
+ await run_db(
+ journal.append_operation,
run_id,
owner=owner,
operation_type="loop_project_bound",
diff --git a/src/backend/orchestration/pending.py b/src/backend/orchestration/pending.py
new file mode 100644
index 00000000..72c88510
--- /dev/null
+++ b/src/backend/orchestration/pending.py
@@ -0,0 +1,36 @@
+"""Dispatch only never-started Plan/Loop runs, using their accepted arguments."""
+
+import asyncio
+from core.db import engine
+from core.db.models import ChatRun
+from core.runs import runtime
+
+
+def _arguments(run_id):
+ with engine.SessionLocal() as db:
+ row = db.query(ChatRun.recovery_snapshot).filter(
+ ChatRun.run_id == run_id, ChatRun.status == "pending",
+ ).first()
+ return (row[0] or {}).get("initial_args") if row else None
+
+
+async def dispatch_pending(run):
+ if run.status != "pending":
+ return False
+ kind = (run.request_payload or {}).get("kind")
+ if kind == "plan_execute":
+ from orchestration.plans.execute_run import _run_plan_execute_workflow as worker
+ elif kind == "autonomous_loop":
+ from orchestration.loops.run import _run_autonomous_loop_workflow as worker
+ else:
+ return False
+ args = await asyncio.to_thread(_arguments, run.run_id)
+ if args is None:
+ return False
+ runtime.register_run_task(
+ run.run_id,
+ worker(run_id=run.run_id, chat_id=run.chat_id, user_id=run.user_id,
+ message_id=run.message_id, **args),
+ name=f"{kind}:{run.run_id}",
+ )
+ return True
diff --git a/src/backend/orchestration/plans/admission.py b/src/backend/orchestration/plans/admission.py
index e05f6ffa..35619813 100644
--- a/src/backend/orchestration/plans/admission.py
+++ b/src/backend/orchestration/plans/admission.py
@@ -10,7 +10,6 @@
import core.runs.ownership as ownership
import core.runs.runtime as runtime
import core.runs.settings as runs_settings
-import orchestration.plans.execute_run as _orchestration_plans_execute_run
import orchestration.plans.generate_run as _orchestration_plans_generate_run
from core.db.models import ChatRun
from core.infra.logging import get_logger
@@ -29,6 +28,7 @@ async def start_plan_execute_run(
enabled_agent_ids: Optional[List[str]] = None,
session_messages: List[Dict[str, Any]],
model_name: Optional[str] = None,
+ on_accept=None,
) -> ChatRun:
"""Plan Phase-2 (execute): background task on the same ChatRun infrastructure.
@@ -48,12 +48,23 @@ async def start_plan_execute_run(
"model_name": model_name,
}
effective_model_name = model_name or settings.DEFAULT_CHAT_MODEL_ALIAS
+ initial_args = {
+ "plan_id": plan_id,
+ "enabled_mcp_ids": enabled_mcp_ids,
+ "enabled_skill_ids": enabled_skill_ids,
+ "enabled_kb_ids": enabled_kb_ids,
+ "enabled_agent_ids": enabled_agent_ids,
+ "session_messages": session_messages,
+ "model_name": effective_model_name,
+ }
run = ownership.create_run_record(
+ on_accept=on_accept,
chat_id=chat_id,
user_id=user_id,
request_payload=request_payload,
recovery_snapshot={
"kind": "plan_execute",
+ "initial_args": initial_args,
"worker_args": {
"context": {
"mcp_ids": list(enabled_mcp_ids or []),
@@ -64,25 +75,8 @@ async def start_plan_execute_run(
},
},
)
- journal_owner = ownership.new_worker_owner(run.run_id)
- runtime.register_run_task(
- run.run_id,
- _orchestration_plans_execute_run._run_plan_execute_workflow(
- run_id=run.run_id,
- plan_id=plan_id,
- chat_id=chat_id,
- user_id=user_id,
- message_id=run.message_id,
- enabled_mcp_ids=enabled_mcp_ids,
- enabled_skill_ids=enabled_skill_ids,
- enabled_kb_ids=enabled_kb_ids,
- enabled_agent_ids=enabled_agent_ids,
- session_messages=session_messages,
- model_name=effective_model_name,
- journal_owner=journal_owner,
- ),
- name=f"plan_run:{run.run_id}",
- )
+ from orchestration.pending import dispatch_pending
+ await dispatch_pending(run)
logger.info(
"plan_run_started",
run_id=run.run_id,
diff --git a/src/backend/orchestration/plans/execute_run.py b/src/backend/orchestration/plans/execute_run.py
index 7fe8e97d..3608da3c 100644
--- a/src/backend/orchestration/plans/execute_run.py
+++ b/src/backend/orchestration/plans/execute_run.py
@@ -9,6 +9,7 @@
from typing import Any, Dict, List, Optional
import core.db.engine as engine
+from core.db.threads import run_db, run_in_session
import core.db.models as models
import core.runs.events.writer as writer
import core.runs.ownership as ownership
@@ -20,6 +21,22 @@
logger = get_logger(__name__)
+def _is_automation_chat(db, chat_id: str) -> bool:
+ session = db.get(models.ChatSession, chat_id)
+ return bool(session and (session.extra_data or {}).get("automation_run"))
+
+
+def _execution_snapshot(db, plan_id, user_id, completed_steps, total_steps, result_text):
+ from core.chat.plans import PlanService
+
+ plan = PlanService(db).get_plan(plan_id, user_id)
+ if not plan:
+ return None
+ return PlanService.build_execution_snapshot(
+ plan, completed_steps=completed_steps, total_steps=total_steps, result_text=result_text
+ )
+
+
async def _run_plan_execute_workflow(
*,
run_id: str,
@@ -44,12 +61,13 @@ async def _run_plan_execute_workflow(
from core.chat.tool_log import upsert_tool_call as _upsert_tool_call
from core.llm.runtime import workspace as _workspace_mod
from core.files.artifacts.service import persist_artifacts as _persist_artifacts
- from core.chat.plans import PlanService
from orchestration.plans.mode import astream_execute_plan
owner = journal_owner or ownership.new_worker_owner(run_id)
journal = ownership.journal()
- if not journal.claim(run_id, owner=owner, lease_seconds=settings.RUN_LEASE_SECONDS):
+ if not await run_db(
+ journal.claim, run_id, owner=owner, lease_seconds=settings.RUN_LEASE_SECONDS, pending_only=True
+ ):
logger.info("plan_run_claim_skipped", run_id=run_id, owner=owner)
return
current = asyncio.current_task()
@@ -64,7 +82,8 @@ async def _run_plan_execute_workflow(
_emit = offsets.emit
async def _pause_plan_tool_outcome(exc: BaseException) -> None:
- if not journal.needs_attention(
+ if not await run_db(
+ journal.needs_attention,
run_id,
owner=owner,
reason=f"tool outcome pending policy recovery: {exc}",
@@ -176,67 +195,55 @@ def _persist_plan_partial() -> None:
}
)
- # astream_execute_plan needs a Session; isolate it from the persistence one.
from core.harness.tool_call_context import CURRENT_RUN_BINDING
binding_token = CURRENT_RUN_BINDING.set((run_id, owner))
try:
- with engine.SessionLocal() as stream_db:
- from core.db.models import ChatSession
-
- session = stream_db.get(ChatSession, chat_id)
- automation_run = bool(session and (session.extra_data or {}).get("automation_run"))
- async for event in astream_execute_plan(
- plan_id=plan_id,
- user_id=user_id,
- db=stream_db,
- model_name=model_name,
- enabled_mcp_ids=enabled_mcp_ids,
- enabled_skill_ids=enabled_skill_ids,
- enabled_kb_ids=enabled_kb_ids,
- enabled_agent_ids=enabled_agent_ids,
- session_messages=session_messages,
- chat_id=chat_id,
- run_id=run_id,
- automation_run=automation_run,
- ):
- evt_type = event.get("type")
- if evt_type == "plan_complete":
- result_text = event.get("result_text", "")
- completed_steps = event.get("completed_steps", 0)
- total_steps = event.get("total_steps", 0)
- exec_usage = event.get("usage") or None
- elif evt_type == "tool_call":
- _upsert_tool_call(
- tool_calls_log,
- {
- "tool_name": event.get("tool_name"),
- "tool_id": event.get("tool_id"),
- "tool_args": event.get("tool_args", {}),
- "step_id": event.get("step_id"),
- },
- )
- elif evt_type == "tool_result":
- res = event.get("result")
- tid = event.get("tool_id")
- tn = event.get("tool_name")
- _attach_tool_result(tool_calls_log, tid, tn, res)
- await _emit(event)
+ automation_run = await run_in_session(_is_automation_chat, chat_id)
+ async for event in astream_execute_plan(
+ plan_id=plan_id,
+ user_id=user_id,
+ model_name=model_name,
+ enabled_mcp_ids=enabled_mcp_ids,
+ enabled_skill_ids=enabled_skill_ids,
+ enabled_kb_ids=enabled_kb_ids,
+ enabled_agent_ids=enabled_agent_ids,
+ session_messages=session_messages,
+ chat_id=chat_id,
+ run_id=run_id,
+ automation_run=automation_run,
+ ):
+ evt_type = event.get("type")
+ if evt_type == "plan_complete":
+ result_text = event.get("result_text", "")
+ completed_steps = event.get("completed_steps", 0)
+ total_steps = event.get("total_steps", 0)
+ exec_usage = event.get("usage") or None
+ elif evt_type == "tool_call":
+ _upsert_tool_call(
+ tool_calls_log,
+ {
+ "tool_name": event.get("tool_name"),
+ "tool_id": event.get("tool_id"),
+ "tool_args": event.get("tool_args", {}),
+ "step_id": event.get("step_id"),
+ },
+ )
+ elif evt_type == "tool_result":
+ res = event.get("result")
+ tid = event.get("tool_id")
+ tn = event.get("tool_name")
+ _attach_tool_result(tool_calls_log, tid, tn, res)
+ await _emit(event)
finally:
CURRENT_RUN_BINDING.reset(binding_token)
# Build plan snapshot in its own short session
plan_snapshot = None
try:
- with engine.SessionLocal() as snap_db:
- updated_plan = PlanService(snap_db).get_plan(plan_id, user_id)
- if updated_plan:
- plan_snapshot = PlanService.build_execution_snapshot(
- updated_plan,
- completed_steps=completed_steps,
- total_steps=total_steps,
- result_text=result_text,
- )
+ plan_snapshot = await run_in_session(
+ _execution_snapshot, plan_id, user_id, completed_steps, total_steps, result_text
+ )
except Exception as snap_exc:
logger.warning("plan_run_snapshot_failed", run_id=run_id, error=str(snap_exc))
@@ -247,7 +254,8 @@ def _persist_plan_partial() -> None:
artifacts_meta = _ws_pinned
_ws_files = _workspace_mod.get_pinned_file_ids()
- completed = journal.complete(
+ completed = await run_db(
+ journal.complete,
run_id,
owner=owner,
status="completed",
@@ -267,17 +275,18 @@ def _persist_plan_partial() -> None:
# 用户取消时 cancel_run 已先写终态、清租约,上面的 CAS 注定输掉。
# 协作式取消让生成器正常收尾,本轮已完成的步骤和正文就在手里 ——
# 不落库的话,用户回到会话只剩自己那句话。
- if ownership.cancelled_by_user(run_id):
- _persist_plan_partial()
+ if await run_db(ownership.cancelled_by_user, run_id):
+ await run_db(_persist_plan_partial)
else:
logger.warning("plan_run_terminal_cas_lost", run_id=run_id, owner=owner)
except asyncio.CancelledError:
if lease_lost.is_set():
logger.warning("plan_run_worker_fenced", run_id=run_id, owner=owner)
- if ownership.cancelled_by_user(run_id):
- _persist_plan_partial()
- elif journal.complete(
+ if await run_db(ownership.cancelled_by_user, run_id):
+ await run_db(_persist_plan_partial)
+ elif await run_db(
+ journal.complete,
run_id,
owner=owner,
status="cancelled",
@@ -305,7 +314,8 @@ def _persist_plan_partial() -> None:
await _pause_plan_tool_outcome(nested_unknown)
return
logger.error("plan_run_failed", run_id=run_id, error=str(exc), exc_info=True)
- failed = journal.complete(
+ failed = await run_db(
+ journal.complete,
run_id,
owner=owner,
status="failed",
diff --git a/src/backend/orchestration/plans/generate_run.py b/src/backend/orchestration/plans/generate_run.py
index 35413c0c..51198aa5 100644
--- a/src/backend/orchestration/plans/generate_run.py
+++ b/src/backend/orchestration/plans/generate_run.py
@@ -9,7 +9,7 @@
import typing as _typing
from typing import Any, Dict, List, Optional
-import core.db.engine as engine
+from core.db.threads import run_db
import core.runs.events.writer as writer
import core.runs.ownership as ownership
import core.runs.runtime as runtime
@@ -43,11 +43,12 @@ async def _run_plan_generate_workflow(
owner = journal_owner or ownership.new_worker_owner(run_id)
journal = ownership.journal()
- if not journal.claim(run_id, owner=owner, lease_seconds=settings.RUN_LEASE_SECONDS):
+ if not await run_db(journal.claim, run_id, owner=owner, lease_seconds=settings.RUN_LEASE_SECONDS):
logger.info("plan_generate_claim_skipped", run_id=run_id, owner=owner)
return
try:
- journal.append_operation(
+ await run_db(
+ journal.append_operation,
run_id,
owner=owner,
operation_type="worker_started",
@@ -95,54 +96,52 @@ async def _run_plan_generate_workflow(
binding_token = CURRENT_RUN_BINDING.set((run_id, owner))
try:
- with engine.SessionLocal() as stream_db:
- async for event in astream_generate_plan(
- task_description=task_description,
- user_id=user_id,
- db=stream_db,
- model_name=model_name,
- model_provider_id=model_provider_id,
- enabled_mcp_ids=enabled_mcp_ids,
- enabled_skill_ids=enabled_skill_ids,
- enabled_kb_ids=enabled_kb_ids,
- enabled_agent_ids=enabled_agent_ids,
- session_messages=session_messages,
- uploaded_files=uploaded_files,
- chat_id=chat_id,
- ):
- if event.get("type") == "plan_generated":
- plan_record = dict(event)
- plan_id_out = event.get("plan_id")
- plan_title = event.get("title", "")
- plan_desc = event.get("description", "")
- steps = event.get("steps", [])
- step_summary = "\n".join(
- f"{i + 1}. {s.get('title', '')}" for i, s in enumerate(steps)
- )
- assistant_content = (
- f"已生成执行计划:**{plan_title}**\n\n"
- f"{plan_desc}\n\n"
- f"**执行步骤:**\n{step_summary}"
- )
- plan_snapshot = {
- "mode": "preview",
- "title": plan_title,
- "description": plan_desc,
- "steps": [
- {
- "step_order": s.get("step_order", i + 1),
- "title": s.get("title", ""),
- "description": s.get("description"),
- "expected_tools": s.get("expected_tools", []),
- "expected_skills": s.get("expected_skills", []),
- }
- for i, s in enumerate(steps)
- ],
- "total_steps": len(steps),
- "completed_steps": 0,
- }
- gen_usage = event.get("usage") or None
- await _emit(event)
+ async for event in astream_generate_plan(
+ task_description=task_description,
+ user_id=user_id,
+ model_name=model_name,
+ model_provider_id=model_provider_id,
+ enabled_mcp_ids=enabled_mcp_ids,
+ enabled_skill_ids=enabled_skill_ids,
+ enabled_kb_ids=enabled_kb_ids,
+ enabled_agent_ids=enabled_agent_ids,
+ session_messages=session_messages,
+ uploaded_files=uploaded_files,
+ chat_id=chat_id,
+ ):
+ if event.get("type") == "plan_generated":
+ plan_record = dict(event)
+ plan_id_out = event.get("plan_id")
+ plan_title = event.get("title", "")
+ plan_desc = event.get("description", "")
+ steps = event.get("steps", [])
+ step_summary = "\n".join(
+ f"{i + 1}. {s.get('title', '')}" for i, s in enumerate(steps)
+ )
+ assistant_content = (
+ f"已生成执行计划:**{plan_title}**\n\n"
+ f"{plan_desc}\n\n"
+ f"**执行步骤:**\n{step_summary}"
+ )
+ plan_snapshot = {
+ "mode": "preview",
+ "title": plan_title,
+ "description": plan_desc,
+ "steps": [
+ {
+ "step_order": s.get("step_order", i + 1),
+ "title": s.get("title", ""),
+ "description": s.get("description"),
+ "expected_tools": s.get("expected_tools", []),
+ "expected_skills": s.get("expected_skills", []),
+ }
+ for i, s in enumerate(steps)
+ ],
+ "total_steps": len(steps),
+ "completed_steps": 0,
+ }
+ gen_usage = event.get("usage") or None
+ await _emit(event)
finally:
CURRENT_RUN_BINDING.reset(binding_token)
@@ -183,7 +182,8 @@ def _commit_plan_preview(persist_db) -> None: # noqa: ANN001
commit=False,
)
- completed = journal.complete(
+ completed = await run_db(
+ journal.complete,
run_id,
owner=owner,
status="completed",
@@ -206,14 +206,15 @@ def _commit_plan_preview(persist_db) -> None: # noqa: ANN001
if lease_lost.is_set():
logger.warning("plan_generate_worker_fenced", run_id=run_id, owner=owner)
else:
- cancelled = journal.complete(
+ cancelled = await run_db(
+ journal.complete,
run_id,
owner=owner,
status="cancelled",
failure_reason="plan generation worker cancelled",
last_event_offset=offsets.last,
)
- current = runtime.get_run(run_id)
+ current = await run_db(runtime.get_run, run_id)
if cancelled or (current is not None and current.status == "cancelled"):
logger.info("plan_generate_run_cancelled", run_id=run_id)
else:
@@ -239,7 +240,8 @@ def _commit_plan_preview(persist_db) -> None: # noqa: ANN001
except Exception as exc:
nested_unknown = errors_module.find_tool_outcome_unknown(exc)
if nested_unknown is not None:
- if journal.needs_attention(
+ if await run_db(
+ journal.needs_attention,
run_id,
owner=owner,
reason=f"tool outcome pending policy recovery: {nested_unknown}",
@@ -254,7 +256,8 @@ def _commit_plan_preview(persist_db) -> None: # noqa: ANN001
await _emit({"type": settings.TERMINAL_TYPE, "chat_id": chat_id}, terminal=True)
return
logger.error("plan_generate_run_failed", run_id=run_id, error=str(exc), exc_info=True)
- failed = journal.complete(
+ failed = await run_db(
+ journal.complete,
run_id,
owner=owner,
status="failed",
diff --git a/src/backend/orchestration/plans/mode.py b/src/backend/orchestration/plans/mode.py
index e092bbda..ffb885c9 100644
--- a/src/backend/orchestration/plans/mode.py
+++ b/src/backend/orchestration/plans/mode.py
@@ -10,6 +10,7 @@
from core.harness import log_context
from core.harness import log_writer
+from core.db.threads import run_in_session
import asyncio
import json
import logging
@@ -27,7 +28,7 @@
from core.infra.logging import LogContext
from core.infra.time import utc_now
from core.llm.context.rendering import append_context_text, next_request_sequence, render_context_item
-from core.llm.context.ir_state import KIND_ATTACHMENT, KIND_REMINDER, KIND_SYSTEM_RULE, POLICY_HEAD_TAIL, POLICY_NEVER, SESSION_CONTEXT_META_KEY
+from core.llm.context.ir_constants import KIND_ATTACHMENT, KIND_REMINDER, KIND_SYSTEM_RULE, POLICY_HEAD_TAIL, POLICY_NEVER, SESSION_CONTEXT_META_KEY
from core.llm.context.items import make_text_context_item, session_context_metadata
from core.llm.factory import create_agent_executor
from core.llm.mcp.manager import close_clients
@@ -379,10 +380,37 @@ def _build_file_context(
return build_lazy_file_context(uploaded_files, user_id=user_id)
+def _generation_inputs(
+ db: Session,
+ user_id: str,
+ task: str,
+ chat_id: Optional[str],
+ enabled_agent_ids: Optional[List[str]],
+ enabled_skill_ids: Optional[List[str]],
+ enabled_mcp_ids: Optional[List[str]],
+):
+ """Everything plan generation reads from the database, read in one session.
+
+ Merges enabled plugin components into the enabled skill / MCP sets (the
+ frontend catalog lists don't contain them) so plan mode's capabilities match
+ main chat; attaches the chat's project context and the user's ontology
+ runtime. The model run that follows holds no connection.
+ """
+ from core.ontology.runtime_policy import build_user_ontology_runtime
+ from core.projects.edition import build_project_ctx_from_chat_id
+
+ visible_agents = _load_visible_agents(db, user_id, enabled_agent_ids)
+ skills, mcps, plugins = _resolve_plugin_capabilities(
+ db, user_id, enabled_skill_ids, enabled_mcp_ids
+ )
+ project_ctx = build_project_ctx_from_chat_id(db, chat_id)
+ ontology = build_user_ontology_runtime(user_id=user_id, task=task, db=db)
+ return visible_agents, skills, mcps, plugins, project_ctx, ontology
+
+
async def astream_generate_plan(
task_description: str,
user_id: str,
- db: Session,
model_name: str = DEFAULT_CHAT_MODEL_ALIAS,
model_provider_id: Optional[str] = None,
enabled_mcp_ids: Optional[List[str]] = None,
@@ -400,13 +428,21 @@ async def astream_generate_plan(
- plan_generated {plan_id, title, description, steps: [...]}
- plan_error {error: str}
"""
- visible_agents = _load_visible_agents(db, user_id, enabled_agent_ids)
-
- # Merge enabled plugin components into the enabled skill / MCP sets (the frontend
- # catalog lists don't contain plugin components), so plan mode's available
- # capabilities match main chat, and fetch active plugin metadata for prompt display.
- enabled_skill_ids, enabled_mcp_ids, plugins = _resolve_plugin_capabilities(
- db, user_id, enabled_skill_ids, enabled_mcp_ids
+ (
+ visible_agents,
+ enabled_skill_ids,
+ enabled_mcp_ids,
+ plugins,
+ _project_ctx,
+ (ontology_enabled, ontology_runtime),
+ ) = await run_in_session(
+ _generation_inputs,
+ user_id,
+ task_description,
+ chat_id,
+ enabled_agent_ids,
+ enabled_skill_ids,
+ enabled_mcp_ids,
)
tools_desc = _build_tools_description(
@@ -414,20 +450,6 @@ async def astream_generate_plan(
)
system_prompt = _load_plan_prompt(tools_desc)
- # Project context: when the chat is attached to a project, inject the project's
- # instructions / file listing into the plan-generation agent's system prompt so
- # the planning phase already understands the project context.
- from core.projects.edition import build_project_ctx_from_chat_id
-
- _project_ctx = build_project_ctx_from_chat_id(db, chat_id)
- from core.ontology.runtime_policy import build_user_ontology_runtime
-
- ontology_enabled, ontology_runtime = build_user_ontology_runtime(
- user_id=user_id,
- task=task_description,
- db=db,
- )
-
try:
# ``enabled_skill_ids=[]`` keeps the factory from falling back to all
# main-agent skills — otherwise the JSON-only plan generator gets a
@@ -600,10 +622,73 @@ def _parse_plan_json(text: str) -> Optional[Dict[str, Any]]:
return None
-async def astream_execute_plan(
+def _plan_snapshot(db: Session, plan_id: str, user_id: str):
+ """The plan and its steps, detached: execution reads them without a session."""
+ plan = PlanService(db).get_plan(plan_id, user_id)
+ if plan is None:
+ return None
+ list(plan.steps) # load them before detaching
+ db.expunge_all()
+ return plan
+
+
+def _start_execution(
+ db: Session,
plan_id: str,
user_id: str,
+ chat_id: Optional[str],
+ enabled_skill_ids: Optional[List[str]],
+ enabled_mcp_ids: Optional[List[str]],
+):
+ """Mark the plan running; return its project context and effective capabilities.
+
+ The project context confines each step-execution agent's file scope to the
+ project folder (same ProjectScope source as regular chat); plugin components
+ are merged into the enabled sets so step agents can load plugin-packaged
+ skills / MCP, consistent with main chat and the generation phase.
+ """
+ from core.projects.edition import build_project_ctx_from_chat_id
+
+ project_ctx = build_project_ctx_from_chat_id(db, chat_id)
+ skills, mcps, _plugins = _resolve_plugin_capabilities(
+ db, user_id, enabled_skill_ids, enabled_mcp_ids
+ )
+ PlanService(db).update_plan(plan_id, status="running")
+ return project_ctx, skills, mcps
+
+
+def _plan_status(db: Session, plan_id: str) -> Optional[str]:
+ from core.db.models import Plan
+
+ return db.query(Plan.status).filter(Plan.plan_id == plan_id).scalar()
+
+
+def _update_step(db: Session, step_id: str, **changes: Any) -> None:
+ PlanService(db).update_step(step_id, **changes)
+
+
+def _update_plan(db: Session, plan_id: str, **changes: Any) -> None:
+ PlanService(db).update_plan(plan_id, **changes)
+
+
+def _step_inputs(
db: Session,
+ user_id: str,
+ task: str,
+ agent_ids: Optional[List[str]],
+ cached_agents: Optional[List[Dict[str, Any]]],
+):
+ """Visible sub-agents (cached per id set across steps) and the step's ontology runtime."""
+ from core.ontology.runtime_policy import build_user_ontology_runtime
+
+ agents = cached_agents if cached_agents is not None else _load_visible_agents(db, user_id, agent_ids)
+ _enabled, runtime = build_user_ontology_runtime(user_id=user_id, task=task, db=db)
+ return agents, runtime
+
+
+async def astream_execute_plan(
+ plan_id: str,
+ user_id: str,
model_name: str = DEFAULT_CHAT_MODEL_ALIAS,
enabled_mcp_ids: Optional[List[str]] = None,
enabled_skill_ids: Optional[List[str]] = None,
@@ -628,8 +713,7 @@ async def astream_execute_plan(
"""
logger.warning("[plan-exec] astream_execute_plan called for plan_id=%s", plan_id)
- svc = PlanService(db)
- plan = svc.get_plan(plan_id, user_id)
+ plan = await run_in_session(_plan_snapshot, plan_id, user_id)
if not plan:
yield {"type": "plan_error", "plan_id": plan_id, "error": "计划不存在"}
return
@@ -651,21 +735,11 @@ async def astream_execute_plan(
if not chat_id:
chat_id = plan_meta.get("chat_id") or None
- # Project context: when the chat is attached to a project, inject the project
- # instructions / files, and confine each step-execution agent's file scope to the
- # project folder (same ProjectScope source as regular chat).
- from core.projects.edition import build_project_ctx_from_chat_id
-
- _project_ctx = build_project_ctx_from_chat_id(db, chat_id)
-
- # Merge enabled plugin components into the enabled sets (frontend catalog lists don't
- # contain plugin components) so each step-execution agent can load the plugin-packaged
- # skills / MCP — consistent with main chat and with the generation phase.
- enabled_skill_ids, enabled_mcp_ids, _plan_plugins = _resolve_plugin_capabilities(
- db, user_id, enabled_skill_ids, enabled_mcp_ids
+ # Project context, plugin-merged capabilities and the plan's running status
+ # are read and written in one session before any step starts.
+ _project_ctx, enabled_skill_ids, enabled_mcp_ids = await run_in_session(
+ _start_execution, plan_id, user_id, chat_id, enabled_skill_ids, enabled_mcp_ids
)
-
- svc.update_plan(plan_id, status="running")
completed_count = 0
cancelled = False
@@ -749,14 +823,16 @@ def _cancellation_requested() -> bool:
"[plan-exec] === Step %d/%d: %s ===", step_idx + 1, len(plan.steps), step.title
)
if cancelled:
- svc.update_step(step.step_id, status="skipped")
+ await run_in_session(_update_step, step.step_id, status="skipped")
continue
- # Refresh plan to check for cancellation
- db.refresh(plan)
- if plan.status == "cancelled" or _cancellation_requested():
+ # Re-read the plan's status to check for cancellation
+ if (
+ await run_in_session(_plan_status, plan_id) == "cancelled"
+ or _cancellation_requested()
+ ):
cancelled = True
- svc.update_step(step.step_id, status="skipped")
+ await run_in_session(_update_step, step.step_id, status="skipped")
continue
yield {
@@ -768,7 +844,9 @@ def _cancellation_requested() -> bool:
# Heartbeat to keep SSE alive during agent setup (3-10s)
yield {"type": "heartbeat"}
- svc.update_step(step.step_id, status="running", started_at=utc_now())
+ await run_in_session(
+ _update_step, step.step_id, status="running", started_at=utc_now()
+ )
# Build step instruction with context from previous steps
step_instruction = _build_step_instruction(
@@ -813,9 +891,14 @@ def _cancellation_requested() -> bool:
step_agent_ids = _merge_enabled(enabled_agent_ids, step.expected_agents or [])
_agent_key = tuple(step_agent_ids) if step_agent_ids is not None else None
- if _agent_key not in _agent_cache:
- _agent_cache[_agent_key] = _load_visible_agents(db, user_id, step_agent_ids)
- step_visible_agents = _agent_cache[_agent_key]
+ step_visible_agents, step_ontology_runtime = await run_in_session(
+ _step_inputs,
+ user_id,
+ f"{plan.task_input}\n{step_instruction}",
+ step_agent_ids,
+ _agent_cache.get(_agent_key),
+ )
+ _agent_cache[_agent_key] = step_visible_agents
logger.warning(
"[plan-exec] step %d effective: mcp=%s skills=%s agents=%s",
@@ -827,13 +910,6 @@ def _cancellation_requested() -> bool:
# max_iters bounds tool calls per step so the agent doesn't loop excessively.
_step_max_iters = int(os.environ.get("PLAN_STEP_MAX_ITERS", "5"))
- from core.ontology.runtime_policy import build_user_ontology_runtime
-
- _step_ontology_enabled, step_ontology_runtime = build_user_ontology_runtime(
- user_id=user_id,
- task=f"{plan.task_input}\n{step_instruction}",
- db=db,
- )
from core.harness.registry.capability_runtime import child_scope
agent, mcp_clients = await create_agent_executor(
@@ -935,7 +1011,8 @@ def _cancellation_requested() -> bool:
if cancelled:
step_text = "步骤执行被取消"
- svc.update_step(
+ await run_in_session(
+ _update_step,
step.step_id,
status="skipped",
completed_at=utc_now(),
@@ -1216,7 +1293,8 @@ def _cancellation_requested() -> bool:
if len(step_outputs_for_context) > _MAX_PREVIOUS_OUTPUTS:
step_outputs_for_context = step_outputs_for_context[-_MAX_PREVIOUS_OUTPUTS:]
- svc.update_step(
+ await run_in_session(
+ _update_step,
step.step_id,
status="success",
result_summary=summary,
@@ -1247,7 +1325,8 @@ def _cancellation_requested() -> bool:
except Exception as step_exc:
logger.exception("Step %s failed", step.step_id)
error_msg = str(step_exc)
- svc.update_step(
+ await run_in_session(
+ _update_step,
step.step_id,
status="failed",
error_message=error_msg,
@@ -1289,7 +1368,8 @@ def _cancellation_requested() -> bool:
# ── Final result: use the last step's full output directly ──
result_text = last_step_text
- svc.update_plan(
+ await run_in_session(
+ _update_plan,
plan_id,
status=final_status,
completed_steps=completed_count,
@@ -1330,7 +1410,7 @@ def _cancellation_requested() -> bool:
except Exception as exc:
logger.exception("Plan execution failed")
- svc.update_plan(plan_id, status="failed", result_summary=str(exc))
+ await run_in_session(_update_plan, plan_id, status="failed", result_summary=str(exc))
await log_writer.finish_subagent_log(
_plan_subagent_log_id,
status="failed",
diff --git a/src/backend/orchestration/workers/_worker_base.py b/src/backend/orchestration/workers/_worker_base.py
index 0030c47e..5dc217ba 100644
--- a/src/backend/orchestration/workers/_worker_base.py
+++ b/src/backend/orchestration/workers/_worker_base.py
@@ -26,6 +26,7 @@
import pytz
from core.db.engine import SessionLocal
+from core.db.threads import run_db
from core.infra.ephemeral import get_ephemeral_state
from core.infra.time import utc_now
@@ -163,7 +164,7 @@ async def _worker() -> int:
processed = 0
while True:
async with sem:
- item_id = claim()
+ item_id = await run_db(claim)
if item_id is None:
return processed
@@ -172,7 +173,7 @@ async def _worker() -> int:
if not allowed:
logger.info("[%s] budget exhausted: %s", log_tag, reason)
if on_budget_exhausted is not None:
- on_budget_exhausted(item_id, reason)
+ await run_db(on_budget_exhausted, item_id, reason)
return processed
try:
diff --git a/src/backend/personal_agent/api/sessions.py b/src/backend/personal_agent/api/sessions.py
index eeb74618..2c71cc30 100644
--- a/src/backend/personal_agent/api/sessions.py
+++ b/src/backend/personal_agent/api/sessions.py
@@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends, Request, HTTPException, Header
from pydantic import BaseModel, Field, ConfigDict
from core.db.engine import get_db
+from core.db.threads import run_in_session
from core.infra.responses import success_response
from personal_agent import access
from personal_agent.sessions.session_auth import resolve
@@ -21,9 +22,9 @@ class OperationRequest(BaseModel):
request_key: str = Field(min_length=1, max_length=100)
-async def identity(request: Request, db=Depends(get_db)):
- user, grant = await resolve(request, db)
- access.require(db, user.user_id)
+async def identity(request: Request):
+ user, grant = await resolve(request)
+ await run_in_session(access.require, user.user_id)
return user, grant
@@ -43,12 +44,12 @@ def devices(auth=Depends(identity)):
@router.post("/session-manager/operations", status_code=202)
-async def submit(body: OperationRequest, auth=Depends(identity), db=Depends(get_db)):
+async def submit(body: OperationRequest, auth=Depends(identity)):
user, grant = auth
try:
return success_response(
data=await dispatch(
- db, user, body.target, body.operation, body.args, body.request_key, grant
+ user, body.target, body.operation, body.args, body.request_key, grant
)
)
except LookupError as exc:
diff --git a/src/backend/personal_agent/background.py b/src/backend/personal_agent/background.py
index 20da74f8..06eb7c35 100644
--- a/src/backend/personal_agent/background.py
+++ b/src/backend/personal_agent/background.py
@@ -6,6 +6,7 @@
from sqlalchemy import select
from core.db.engine import SessionLocal
from personal_agent.models import PersonalAgent
+from core.db.threads import run_in_session
log = logging.getLogger(__name__)
SCHEDULE_INTERVAL_SEC = 15
@@ -97,10 +98,12 @@ async def maintenance_loop(self):
await self.every(MAINTENANCE_INTERVAL_SEC, "computer maintenance", self.maintain_computers)
async def maintain_computers(self):
- with SessionLocal() as db:
+ def _computer_bindings(db):
bindings = db.execute(
select(PersonalAgent.id, PersonalAgent.sandbox_session_id, PersonalAgent.user_id)
).all()
+ return bindings
+ bindings = await run_in_session(_computer_bindings)
if not bindings:
return
from personal_agent.computer.sandbox import get_personal_sandbox_provider
@@ -112,8 +115,9 @@ async def maintain_computers(self):
await provider.ensure_user_workspace(box, uid)
await provider.touch_session(box)
except Exception:
- with SessionLocal() as db:
+ def _mark_degraded(db):
row = db.get(PersonalAgent, ident)
if row:
row.computer_status = "degraded"
db.commit()
+ await run_in_session(_mark_degraded)
diff --git a/src/backend/personal_agent/channels/channel_bridge.py b/src/backend/personal_agent/channels/channel_bridge.py
index 1a2d3e0d..ef2190e4 100644
--- a/src/backend/personal_agent/channels/channel_bridge.py
+++ b/src/backend/personal_agent/channels/channel_bridge.py
@@ -15,6 +15,7 @@
from sqlalchemy import and_, or_, select, update
from core.db.engine import SessionLocal
+from core.db.threads import run_in_session
from core.db.models import Artifact, ChatMessage
from personal_agent import access
from personal_agent.models import PersonalAgent, PersonalMessageTask
@@ -78,12 +79,27 @@ def unpack(data):
fields = {field.name for field in dataclasses.fields(InboundMsg)}
return InboundMsg(**{key: value for key, value in data.items() if key in fields})
+def _channel_chat(db, conn, msg):
+ """The Personal Agent conversation this IM conversation feeds."""
+ from personal_agent.context import AgentContext
+ from personal_agent.conversation.conversations import channel_session
+ from personal_agent.identity.config import locked_agent
+ svc = AgentContext(db, conn.owner_user_id)
+ svc.identity.ensure()
+ chat_id = channel_session(db, locked_agent(db, svc.user_id), conn, msg).chat_id
+ db.commit()
+ return chat_id
+
+def _admit_message(db, owner_id, chat_id, text, key, file_ids, origin):
+ from personal_agent.context import AgentContext
+ from personal_agent.conversation.message_routing import admit
+ admit(AgentContext(db, owner_id, chat_id), text, key, file_ids,
+ extra={"origin": "channel", "channel_origin": origin})
+
async def admit_inbound(conn, msg, adapter):
"""Store the IM message in the Personal Agent conversation. The worker answers it later."""
from integrations.channels.inbound_attachments import ingest_attachments as _ingest_attachments
from integrations.channels.inbound_observation import speaker_label as _speaker_label
- from personal_agent.conversation.message_routing import admit
- from personal_agent.context import AgentContext
placeholder = None
try:
send = getattr(adapter, "send_placeholder", None) or adapter.send_text
@@ -91,21 +107,14 @@ async def admit_inbound(conn, msg, adapter):
placeholder = sent.message_id if sent.success else None
except Exception: # noqa: BLE001 - a missing acknowledgement must not drop the message
log.debug("Personal channel acknowledgement failed", exc_info=True)
- with SessionLocal() as db:
- svc = AgentContext(db, conn.owner_user_id)
- svc.identity.ensure()
- from personal_agent.identity.config import locked_agent
- from personal_agent.conversation.conversations import channel_session
- session = channel_session(db, locked_agent(db, svc.user_id), conn, msg)
- svc.chat_id = session.chat_id
- agent_view = {"chat_id": session.chat_id}
- files = await _ingest_attachments(db, adapter, conn, conn.owner_user_id, agent_view["chat_id"], msg)
- text = (msg.text or "").strip() or ("[收到文件] " + "、".join(item.get("name", "") for item in files))
- if msg.chat_type == "group": text = _speaker_label(msg) + ":" + text
- key = "channel:" + conn.channel_id + ":" + (msg.message_id or uuid.uuid4().hex)
- origin = {"channel_id": conn.channel_id, "channel_type": conn.channel_type, "msg": pack(msg), "placeholder_id": placeholder}
- admit(svc, text[:20000], key[:100], [item["file_id"] for item in files if item.get("file_id")][:10],
- extra={"origin": "channel", "channel_origin": origin})
+ chat_id = await run_in_session(_channel_chat, conn, msg)
+ files = await _ingest_attachments(adapter, conn, conn.owner_user_id, chat_id, msg)
+ text = (msg.text or "").strip() or ("[收到文件] " + "、".join(item.get("name", "") for item in files))
+ if msg.chat_type == "group": text = _speaker_label(msg) + ":" + text
+ key = "channel:" + conn.channel_id + ":" + (msg.message_id or uuid.uuid4().hex)
+ origin = {"channel_id": conn.channel_id, "channel_type": conn.channel_type, "msg": pack(msg), "placeholder_id": placeholder}
+ await run_in_session(_admit_message, conn.owner_user_id, chat_id, text[:20000], key[:100],
+ [item["file_id"] for item in files if item.get("file_id")][:10], origin)
def origin_of(db, task_id=None, message_id=None):
"""The IM conversation that started a task, or that sent one message."""
diff --git a/src/backend/personal_agent/computer/computer.py b/src/backend/personal_agent/computer/computer.py
index ef1da76c..4bbdf381 100644
--- a/src/backend/personal_agent/computer/computer.py
+++ b/src/backend/personal_agent/computer/computer.py
@@ -1,4 +1,5 @@
"""Observe-act-verify tools with exclusive desktop control and exact approvals."""
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import json
import shlex
@@ -146,4 +147,4 @@ async def pa_device_list():
from personal_agent.computer.devices import list_devices
return response(list_devices(uid))
- for fn in (pa_computer_observe, pa_computer_action, pa_device_list): toolkit.register_tool_function(fn)
+ for fn in (pa_computer_observe, pa_computer_action, pa_device_list): toolkit.append(function_tool(fn))
diff --git a/src/backend/personal_agent/computer/devices.py b/src/backend/personal_agent/computer/devices.py
index adae3683..0ab229fe 100644
--- a/src/backend/personal_agent/computer/devices.py
+++ b/src/backend/personal_agent/computer/devices.py
@@ -8,6 +8,7 @@
from sqlalchemy import select
from personal_agent.device_link import CAPABILITIES
from core.db.engine import SessionLocal
+from core.db.threads import run_in_session
from personal_agent.models import PersonalDevice, PersonalDeviceCommand, PersonalWorkItem
def pair(db, user_id, name, platform, capabilities):
@@ -66,26 +67,34 @@ def acknowledge(db, ident, token, command_id, result):
db.commit()
return {"accepted": True}
+def _queue_command(db, task_id, user_id, ident, action):
+ """Queue the command; returns (command_id, epoch), or a waiting notice."""
+ device = db.scalar(select(PersonalDevice).where(PersonalDevice.id == ident, PersonalDevice.user_id == user_id, PersonalDevice.revoked == 0).with_for_update())
+ if device is None: raise LookupError("Device not found")
+ if not device.control_enabled:return {"waiting": True, "message": "请在本地电脑允许接管"}
+ if action["type"] not in device.capabilities: raise ValueError("Device cannot perform this action")
+ if not device.last_seen or device.last_seen < time.time() - 20: return {"waiting": True, "message": "电脑未连接"}
+ from personal_agent.tasks.guards import owned_task
+ state = owned_task(task_id, user_id)
+ row = PersonalDeviceCommand(run_id=state["run_id"], id="pa_command_" + uuid.uuid4().hex, device_id=ident, epoch=device.epoch,
+ work_item_id=task_id, action=action, deadline=time.time() + 30)
+ db.add(row)
+ db.commit()
+ return row.id, device.epoch
+
+def _command_result(db, ident, command_id, epoch):
+ device, row = db.get(PersonalDevice, ident), db.get(PersonalDeviceCommand, command_id)
+ if device.revoked or device.epoch != epoch: raise ValueError("Device access revoked")
+ return row.result if row.status == "completed" else None
+
async def execute_command(task_id, user_id, ident, action):
- with SessionLocal() as db:
- device = db.scalar(select(PersonalDevice).where(PersonalDevice.id == ident, PersonalDevice.user_id == user_id, PersonalDevice.revoked == 0).with_for_update())
- if device is None: raise LookupError("Device not found")
- if not device.control_enabled:return {"waiting": True, "message": "请在本地电脑允许接管"}
- if action["type"] not in device.capabilities: raise ValueError("Device cannot perform this action")
- if not device.last_seen or device.last_seen < time.time() - 20: return {"waiting": True, "message": "电脑未连接"}
- from personal_agent.tasks.guards import owned_task
- state = owned_task(task_id, user_id)
- row = PersonalDeviceCommand(run_id=state["run_id"], id="pa_command_" + uuid.uuid4().hex, device_id=ident, epoch=device.epoch,
- work_item_id=task_id, action=action, deadline=time.time() + 30)
- db.add(row)
- db.commit()
- command_id, epoch = row.id, device.epoch
+ queued = await run_in_session(_queue_command, task_id, user_id, ident, action)
+ if isinstance(queued, dict): return queued
+ command_id, epoch = queued
for _ in range(120):
await asyncio.sleep(.25)
- with SessionLocal() as db:
- device, row = db.get(PersonalDevice, ident), db.get(PersonalDeviceCommand, command_id)
- if device.revoked or device.epoch != epoch: raise ValueError("Device access revoked")
- if row.status == "completed": return row.result
+ result = await run_in_session(_command_result, ident, command_id, epoch)
+ if result is not None: return result
return {"outcome_unknown": True, "message": "电脑未返回结果,请检查操作结果"}
def consent(db,ident,token,enabled):
diff --git a/src/backend/personal_agent/computer/live_stream.py b/src/backend/personal_agent/computer/live_stream.py
index 5f5cadff..d78bda00 100644
--- a/src/backend/personal_agent/computer/live_stream.py
+++ b/src/backend/personal_agent/computer/live_stream.py
@@ -83,24 +83,33 @@ def upstream(sandbox_id, execd_secret):
url = scheme + "://" + domain.split("://", 1)[-1].rstrip("/") + "/sandboxes/" + sandbox_id + "/proxy/" + str(STREAM_PORT)
return url, {"OPEN-SANDBOX-API-KEY": key, "X-PA-Stream-Token": stream_token(execd_secret)}
-async def relay(websocket, value):
- import websockets
- from core.db.engine import SessionLocal
+
+def _computer_target(db, grant):
+ """``(sandbox_id, secret, agent_id)`` for a ready computer the grant names, else None."""
from core.infra.crypto import decrypt_secret
from personal_agent.models import PersonalAgent
+ agent = db.get(PersonalAgent, grant["agent_id"])
+ if agent is None or agent.user_id != grant["user_id"] or agent.computer_status != "ready" or not agent.sandbox_id or not agent.execd_secret_enc:
+ return None
+ return agent.sandbox_id, decrypt_secret(agent.execd_secret_enc), agent.id
+
+
+async def relay(websocket, value):
+ import websockets
+ from core.db.threads import run_in_session
+
state = get_ephemeral_state()
raw = await state.take("pa:stream:" + value) if value else None
if not raw:
await websocket.close(4401)
return
grant = json.loads(raw)
- with SessionLocal() as db:
- agent = db.get(PersonalAgent, grant["agent_id"])
- if agent is None or agent.user_id != grant["user_id"] or agent.computer_status != "ready" or not agent.sandbox_id or not agent.execd_secret_enc:
- await websocket.close(4404)
- return
- sandbox_id, secret, agent_id = agent.sandbox_id, decrypt_secret(agent.execd_secret_enc), agent.id
+ target = await run_in_session(_computer_target, grant)
+ if target is None:
+ await websocket.close(4404)
+ return
+ sandbox_id, secret, agent_id = target
control = bool(grant["token"])
url, headers = upstream(sandbox_id, secret)
try:
diff --git a/src/backend/personal_agent/computer/sandbox.py b/src/backend/personal_agent/computer/sandbox.py
index 0c6ca7e5..aff2cb92 100644
--- a/src/backend/personal_agent/computer/sandbox.py
+++ b/src/backend/personal_agent/computer/sandbox.py
@@ -14,8 +14,10 @@
from core.sandbox.providers.opensandbox.models import SandboxSessionRecord as _Session
from core.sandbox.errors import SandboxError
from personal_agent.models import PersonalAgent
+from core.db.threads import off_loop
+from core.db.threads import run_in_session
-# Layout of the permanent computer image (docker/Dockerfile.personal-agent and its entrypoint).
+# Layout of the permanent computer image (docker/personal-agent/Dockerfile.personal-agent and its entrypoint).
SANDBOX_PYTHON = "/opt/python/current/bin/python3"
DEFAULT_IMAGE = "hugagent-personal-agent:v5"
DESKTOP_HOME = "/home/pa-desktop"
@@ -57,6 +59,9 @@ async def install_once(provider, box, uid, name, digest, command):
finally:
await state.release(lock, token)
+def _computer_owner(db, session_id):
+ return db.scalar(select(PersonalAgent.user_id).where(PersonalAgent.sandbox_session_id == session_id))
+
class PersonalSandboxProvider(OpenSandboxProvider):
def _make_config(self):
@@ -122,13 +127,14 @@ async def _get_or_create_session(self, session_id, user_id=None):
# Only a confirmed missing instance permits recreation. Network errors preserve identity.
if getattr(exc, "status_code", None) != 404: raise
await self._check_mount_revision(sandbox, user_id)
- with SessionLocal() as db:
+ def _drop_lost_sandbox(db):
row = db.get(PersonalAgent, ident)
if row.sandbox_id == sandbox_id:
row.sandbox_id = None
row.generation += 1
row.computer_status = "recovering"
db.commit()
+ await run_in_session(_drop_lost_sandbox)
_, sandbox_id, generation = self._binding(session_id, user_id)
sandbox = await self._provision(ident, session_id, user_id, generation)
else:
@@ -188,10 +194,11 @@ async def _provision_locked(self, ident, session_id, user_id, generation):
if found.status.state not in {"RUNNING", "PENDING"}: raise SandboxError("Permanent computer requires recovery")
sandbox = await Sandbox.connect(found.id, connection_config=self._computer_config(ident)[0], skip_health_check=True)
await self._check_mount_revision(sandbox, user_id)
- with SessionLocal() as db:
+ def _mark_ready(db):
row = db.get(PersonalAgent, ident)
row.sandbox_id, row.computer_status = sandbox.id, "ready"
db.commit()
+ await run_in_session(_mark_ready)
return sandbox
finally: await manager.close()
root = os.getenv("PERSONAL_AGENT_VOLUME_ROOT", "").strip()
@@ -212,17 +219,18 @@ async def _provision_locked(self, ident, session_id, user_id, generation):
desktop.mkdir(parents=True)
os.chmod(desktop, 0o700)
desktop_volume = Volume(name="personal-desktop", host=Host(path=str(Path(root) / ident / "desktop")), mount_path=DESKTOP_HOME, read_only=False)
- from core.files.spaces.mounts import prepare_volumes
- space_volumes, space_metadata = await asyncio.to_thread(prepare_volumes, user_id)
+ from core.files.spaces.mounts import provision_volumes
+ space_volumes, space_metadata = await asyncio.to_thread(provision_volumes, user_id)
computer_config, access_token = self._computer_config(ident)
sandbox = await Sandbox.create(os.getenv("PERSONAL_AGENT_IMAGE", DEFAULT_IMAGE), connection_config=computer_config, env={"EXECD_ACCESS_TOKEN": access_token},
timeout=None, skip_health_check=False, volumes=[volume, desktop_volume, *space_volumes],
metadata={"hugagent-owner": "personal-agent", "hugagent-user-id": user_id, "personal-agent-id": ident, "generation": str(generation), **space_metadata},
entrypoint=["/bin/bash", "/opt/personal-agent/start.sh"])
- with SessionLocal() as db:
+ def _mark_ready(db):
row = db.get(PersonalAgent, ident)
row.sandbox_id, row.computer_status = sandbox.id, "ready"
db.commit()
+ await run_in_session(_mark_ready)
return sandbox
finally:
# Only the token owner releases; do not delete a successor's lock.
@@ -311,10 +319,8 @@ async def reap_idle_sessions(self):
return 0
async def touch_session(self, session_id):
- with SessionLocal() as db:
- row = db.scalar(select(PersonalAgent).where(PersonalAgent.sandbox_session_id == session_id))
- if row is None: return False
- uid = row.user_id
+ uid = await run_in_session(_computer_owner, session_id)
+ if uid is None: return False
sess = await self._get_or_create_session(session_id, uid)
try:
info = await sess.sandbox.get_info()
@@ -326,13 +332,15 @@ async def touch_session(self, session_id):
info = await sess.sandbox.get_info()
if info.expires_at is not None:
raise SandboxError("Permanent computer unexpectedly has an expiration time")
- with SessionLocal() as db:
+ def _mark_ready(db):
row = db.scalar(select(PersonalAgent).where(PersonalAgent.sandbox_session_id == session_id))
row.computer_status = "ready"
db.commit()
+ await run_in_session(_mark_ready)
return True
- async def current_sandbox_id(self, session_id):
+ @off_loop
+ def current_sandbox_id(self, session_id):
with SessionLocal() as db:
row = db.scalar(select(PersonalAgent).where(PersonalAgent.sandbox_session_id == session_id))
return row.sandbox_id if row else None
diff --git a/src/backend/personal_agent/conversation/front.py b/src/backend/personal_agent/conversation/front.py
index 09abdb8c..e4bafc4b 100644
--- a/src/backend/personal_agent/conversation/front.py
+++ b/src/backend/personal_agent/conversation/front.py
@@ -24,6 +24,7 @@
from personal_agent.tasks import task_ops
from personal_agent.tasks.task_threads import StreamRecord
from sqlalchemy import func, select
+from core.db.threads import run_in_session
log = logging.getLogger(__name__)
PROFILE = "personal_agent_front"
@@ -286,8 +287,10 @@ async def converse(plan, binding):
from core.llm.factory import create_agent_executor
from core.llm.runtime.streaming import StreamingAgent
from personal_agent.identity.capabilities import front_mcp_ids, runtime_config
- with SessionLocal() as db:
+ def _agent_config(db):
config = runtime_config(db, plan["uid"], plan["config"])
+ return config
+ config = await run_in_session(_agent_config)
reply = plan["reply"]
effort = config.get("thinking_effort", "fast")
instance, clients = await create_agent_executor(user_query=plan["query"], current_user_id=plan["uid"],
diff --git a/src/backend/personal_agent/sessions/session_auth.py b/src/backend/personal_agent/sessions/session_auth.py
index 7266690a..4a180218 100644
--- a/src/backend/personal_agent/sessions/session_auth.py
+++ b/src/backend/personal_agent/sessions/session_auth.py
@@ -13,28 +13,35 @@
def key(token):
return "pa:session-grant:" + hashlib.sha256(token.encode()).hexdigest()
-async def mint(task_id, uid, ttl):
+def _grant_payload(db, task_id, uid):
from personal_agent.tasks.guards import owned_task
- from core.db.engine import SessionLocal
owned_task(task_id, uid)
- with SessionLocal() as db:
- task = db.get(PersonalWorkItem, task_id)
- payload = {
- "uid": uid,
- "task": task_id,
- "run": task.run_id,
- "owner": task.owner,
- }
+ task = db.get(PersonalWorkItem, task_id)
+ return {
+ "uid": uid,
+ "task": task_id,
+ "run": task.run_id,
+ "owner": task.owner,
+ }
+
+
+async def mint(task_id, uid, ttl):
+ from core.db.threads import run_in_session
+
+ payload = await run_in_session(_grant_payload, task_id, uid)
token = "pa_cli_" + secrets.token_urlsafe(32)
await get_ephemeral_state().put(key(token), json.dumps(payload), ttl=ttl)
return token
-async def resolve(request, db):
+async def resolve(request):
+ """The caller's identity and, for a task's CLI token, its grant."""
+ from core.db.threads import run_in_session
+
credentials = await security(request)
token = credentials.credentials if credentials else ""
if not token.startswith("pa_cli_"):
- user = await get_current_user(request, credentials, db)
+ user = await get_current_user(request, credentials)
if user.api_key_agent_id:
raise HTTPException(403, "Agent-specific key cannot manage sessions")
return user, None
@@ -42,6 +49,11 @@ async def resolve(request, db):
if not raw:
raise HTTPException(401, "Task grant expired")
payload = json.loads(raw)
+ return await run_in_session(_grant_owner, payload), payload
+
+
+def _grant_owner(db, payload):
+ """The task owner, provided the grant still matches the live task and its lease."""
task = db.get(PersonalWorkItem, payload["task"])
agent = db.get(PersonalAgent, task.agent_id) if task else None
if (
@@ -59,9 +71,6 @@ async def resolve(request, db):
row = db.get(UserShadow, payload["uid"])
if not row:
raise HTTPException(401, "Owner unavailable")
- return (
- UserContext(
- user_id=row.user_id, user_center_id=row.user_center_id or "", username=row.username
- ),
- payload,
+ return UserContext(
+ user_id=row.user_id, user_center_id=row.user_center_id or "", username=row.username
)
diff --git a/src/backend/personal_agent/sessions/session_cloud.py b/src/backend/personal_agent/sessions/session_cloud.py
index 95cb5365..82486a3c 100644
--- a/src/backend/personal_agent/sessions/session_cloud.py
+++ b/src/backend/personal_agent/sessions/session_cloud.py
@@ -4,8 +4,44 @@
from hugagent_cli.contract import validate
-async def execute(db, user, operation, args, origin=None):
+async def execute(user, operation, args, origin=None):
+ """Run one session operation as ``user``; database operations run in a worker thread."""
args = validate(operation, args)
+ if operation == "chat.send":
+ from core.chat.schemas import ChatRequest
+ from core.chat.operations.admission import start_response_run as _start_response_run
+
+ run = await _start_response_run(ChatRequest(**args, stream=True), user)
+ return {
+ "run_id": run.run_id,
+ "chat_id": run.chat_id,
+ "message_id": run.message_id,
+ "status": "accepted",
+ }
+ if operation == "run.status":
+ from core.db.threads import run_db
+
+ return await run_db(run_status, args["run_id"], user)
+ if operation == "run.cancel":
+ from core.chat.operations.commands import cancel_chat_run
+
+ return (await cancel_chat_run(args["run_id"], user=user))["data"]
+ if operation == "run.steer":
+ from core.chat.operations.commands import steer_chat_run
+ from core.chat.operations.schemas import SteerChatRunRequest
+
+ result = await steer_chat_run(
+ args["run_id"],
+ SteerChatRunRequest(**{k: v for k, v in args.items() if k != "run_id"}),
+ user=user,
+ )
+ return result["data"]
+ from core.db.threads import run_in_session
+
+ return await run_in_session(_execute_in_session, user, operation, args, origin)
+
+
+def _execute_in_session(db, user, operation, args, origin):
from core.chat.operations import history
from core.chat.operations import runs as run_views
from core.chat.operations import schemas as models
@@ -57,36 +93,10 @@ async def execute(db, user, operation, args, origin=None):
user=user,
db=db,
)
- elif operation == "chat.send":
- from core.chat.schemas import ChatRequest
- from core.chat.operations.admission import start_response_run as _start_response_run
-
- run = await _start_response_run(ChatRequest(**args, stream=True), user, db)
- return {
- "run_id": run.run_id,
- "chat_id": run.chat_id,
- "message_id": run.message_id,
- "status": "accepted",
- }
elif operation == "run.active":
result = run_views.chat_active_run(args["chat_id"], user=user, db=db)
elif operation == "run.list":
result = run_views.list_active_chat_runs(user=user, db=db)
- elif operation == "run.status":
- return run_status(args["run_id"], user)
- elif operation == "run.cancel":
- from core.chat.operations.commands import cancel_chat_run
-
- result = await cancel_chat_run(args["run_id"], user=user)
- elif operation == "run.steer":
- from core.chat.operations.commands import steer_chat_run
- from core.chat.operations.schemas import SteerChatRunRequest
-
- result = await steer_chat_run(
- args["run_id"],
- SteerChatRunRequest(**{k: v for k, v in args.items() if k != "run_id"}),
- user=user,
- )
# Defaults match the HTTP routes that the device path calls.
elif operation == "project.list":
from core.chat.operations.projects import list_projects
@@ -102,7 +112,7 @@ async def execute(db, user, operation, args, origin=None):
elif operation == "project.get":
from core.chat.operations.projects import get_project
- result = get_project(access=await project_access(args, user, db), db=db)
+ result = get_project(access=project_access(args, user, db), db=db)
elif operation == "project.chats":
from core.chat.operations.projects import list_project_chats
@@ -110,16 +120,16 @@ async def execute(db, user, operation, args, origin=None):
page=args.get("page", 1),
page_size=args.get("page_size", 50),
scope="all",
- access=await project_access(args, user, db),
+ access=project_access(args, user, db),
db=db,
)
return result["data"]
-async def project_access(args, user, db):
+def project_access(args, user, db):
"""The HTTP routes' own view check, which also audits denials."""
from core.auth.edition import require_project_access
- return await require_project_access("view")(
+ return require_project_access("view")(
project_id=args["project_id"], request=None, user=user, db=db
)
diff --git a/src/backend/personal_agent/sessions/session_manager.py b/src/backend/personal_agent/sessions/session_manager.py
index 7e6f85b7..f707c378 100644
--- a/src/backend/personal_agent/sessions/session_manager.py
+++ b/src/backend/personal_agent/sessions/session_manager.py
@@ -7,7 +7,38 @@
from personal_agent.models import PersonalSessionOperation as Operation
from personal_agent.sessions import session_queue
-async def dispatch(db, user, target, operation, args, request_key, grant=None):
+def _claim_cloud(db, job_id):
+ """Mark a queued cloud operation sent; False when another dispatcher took it."""
+ row = db.scalar(
+ select(Operation)
+ .where(Operation.id == job_id)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+ if row.status != "queued":
+ return False
+ if not session_queue.active_task(db, row):
+ raise HTTPException(409, "Task expired")
+ row.status = "sent"
+ db.commit()
+ return True
+
+
+def _complete(db, job_id, result):
+ row = db.get(Operation, job_id)
+ row.result = result
+ row.status = "completed"
+ db.commit()
+
+
+async def dispatch(user, target, operation, args, request_key, grant=None):
+ """Queue a session operation; cloud targets run here, local ones through the device.
+
+ The queue rows are read and written on sessions of their own, so the
+ operation itself (which may start a chat run) holds no connection.
+ """
+ from core.db.threads import run_db, run_in_session
+
validate(operation, args)
task_id = grant["task"] if grant else None
run_id = grant["run"] if grant else None
@@ -22,90 +53,78 @@ async def dispatch(db, user, target, operation, args, request_key, grant=None):
"args": args,
"request_key": request_key,
}
- digest, result = authorize(task_id, user.user_id, action)
+ digest, result = await run_db(authorize, task_id, user.user_id, action)
if result:
return result
- job = session_queue.submit(
- db, user.user_id, target, operation, args, request_key, task_id, run_id, grant
+ job = await run_in_session(
+ session_queue.submit, user.user_id, target, operation, args, request_key, task_id, run_id, grant
)
if target == "cloud" and job["status"] == "queued":
- row = db.scalar(
- select(Operation)
- .where(Operation.id == job["id"])
- .with_for_update()
- .execution_options(populate_existing=True)
- )
- if row.status == "queued":
- if not session_queue.active_task(db, row):
- raise HTTPException(409, "Task expired")
- row.status = "sent"
- db.commit()
+ if await run_in_session(_claim_cloud, job["id"]):
from personal_agent.sessions.session_cloud import execute
try:
- result = await execute(db, user, operation, args, origin=task_id)
+ result = await execute(user, operation, args, origin=task_id)
except HTTPException as exc:
- db.rollback()
- row = db.get(Operation, job["id"])
- row.status = "completed"
- row.result = {"error": "request_refused", "http_status": exc.status_code}
- db.commit()
+ await run_in_session(
+ _complete, job["id"], {"error": "request_refused", "http_status": exc.status_code}
+ )
except Exception:
- db.rollback() # Effect may have happened; never automatically replay.
+ # Effect may have happened; never automatically replay.
raise HTTPException(502, "Operation result unknown; query its receipt") from None
else:
- row = db.get(Operation, job["id"])
- row.result = result
- row.status = "completed"
- db.commit()
- job = session_queue.view(db, user.user_id, job["id"], task_id)
+ await run_in_session(_complete, job["id"], result)
+ job = await run_in_session(session_queue.view, user.user_id, job["id"], task_id)
if isinstance(job.get("result"), dict) and job["result"].get("outcome_unknown"):
job = {**job, "outcome_unknown": True}
if digest:
from personal_agent.tasks.approvals import receipt
- receipt(task_id, user.user_id, digest, job)
+ await run_db(receipt, task_id, user.user_id, digest, job)
return job
-async def dispatch_approved(task_id, uid, action):
- from core.db.engine import SessionLocal
+
+def _approved_owner(db, task_id, uid):
from core.db.models import UserShadow
from core.auth.backend import UserContext
+ from personal_agent.models import PersonalWorkItem
+
+ task = db.get(PersonalWorkItem, task_id)
+ user = db.get(UserShadow, uid)
+ owner = UserContext(user_id=uid, user_center_id=user.user_center_id or "", username=user.username)
+ return owner, task.owner
+
+
+async def dispatch_approved(task_id, uid, action):
+ from core.db.threads import run_db, run_in_session
from personal_agent.tasks.guards import owned_task
- state = owned_task(task_id, uid)
- with SessionLocal() as db:
- from personal_agent.models import PersonalWorkItem
-
- task = db.get(PersonalWorkItem, task_id)
- user = db.get(UserShadow, uid)
- job = await dispatch(
- db,
- UserContext(
- user_id=uid, user_center_id=user.user_center_id or "", username=user.username
- ),
- action["target"],
- action["operation"],
- action["args"],
- action["request_key"],
- {
- "task": task_id,
- "run": state["run_id"],
- "owner": task.owner,
- },
- )
-
- if job.get("id") and job.get("status") in {"queued", "sent"}:
- import asyncio
-
- while job["status"] in {"queued", "sent"} and time.time() < job["deadline"]:
- await asyncio.sleep(0.3)
- db.expire_all()
- job = session_queue.view(db, uid, job["id"], task_id)
- from personal_agent.tasks.approvals import receipt, fingerprint
-
- if job["status"] != "completed" or (
- isinstance(job.get("result"), dict) and job["result"].get("outcome_unknown")
- ):
- job = {**job, "outcome_unknown": True}
- receipt(task_id, uid, fingerprint(action), job)
+ state = await run_db(owned_task, task_id, uid)
+ user, task_owner = await run_in_session(_approved_owner, task_id, uid)
+ job = await dispatch(
+ user,
+ action["target"],
+ action["operation"],
+ action["args"],
+ action["request_key"],
+ {
+ "task": task_id,
+ "run": state["run_id"],
+ "owner": task_owner,
+ },
+ )
+
+ if job.get("id") and job.get("status") in {"queued", "sent"}:
+ import asyncio
+
+ # The device answers through its queue; poll it without holding a session.
+ while job["status"] in {"queued", "sent"} and time.time() < job["deadline"]:
+ await asyncio.sleep(0.3)
+ job = await run_in_session(session_queue.view, uid, job["id"], task_id)
+ from personal_agent.tasks.approvals import receipt, fingerprint
+
+ if job["status"] != "completed" or (
+ isinstance(job.get("result"), dict) and job["result"].get("outcome_unknown")
+ ):
+ job = {**job, "outcome_unknown": True}
+ await run_db(receipt, task_id, uid, fingerprint(action), job)
diff --git a/src/backend/personal_agent/sessions/session_tool.py b/src/backend/personal_agent/sessions/session_tool.py
index 39294acc..7e753e13 100644
--- a/src/backend/personal_agent/sessions/session_tool.py
+++ b/src/backend/personal_agent/sessions/session_tool.py
@@ -7,6 +7,8 @@
like any other external action.
"""
import asyncio
+
+from core.db.threads import run_in_session
import hashlib
import json
import time
@@ -64,6 +66,17 @@ def owner(db, uid):
if row is None: raise PermissionError("Owner unavailable")
return UserContext(user_id=row.user_id, user_center_id=row.user_center_id or "", username=row.username)
+def _caller(db, uid, task_id):
+ """The owner, and the running task's grant when the call belongs to one."""
+ grant = None
+ if task_id:
+ from personal_agent.models import PersonalWorkItem
+ from personal_agent.tasks.guards import owned_task
+ state = owned_task(task_id, uid)
+ task = db.get(PersonalWorkItem, task_id)
+ grant = {"task": task_id, "run": state["run_id"], "owner": task.owner}
+ return owner(db, uid), grant
+
def request_key(task_id, target, operation, args):
"""A write keeps one key per task and exact action: after the user approves it, the task's
next run finds the receipt instead of asking again. Reads always run fresh."""
@@ -88,22 +101,14 @@ async def call(uid, operation, args, target="cloud", task_id=None, wait=60):
return {"status": "completed", "result": list_devices(uid)}
if operation not in READS and not task_id:
raise PermissionError("Only a background task may change the user's HugAgentOS")
- with SessionLocal() as db:
- grant = None
- if task_id:
- from personal_agent.models import PersonalWorkItem
- from personal_agent.tasks.guards import owned_task
- state = owned_task(task_id, uid)
- task = db.get(PersonalWorkItem, task_id)
- grant = {"task": task_id, "run": state["run_id"], "owner": task.owner}
- job = await dispatch(db, owner(db, uid), target, operation, args,
- request_key(task_id, target, operation, args), grant)
- # A device answers through its queue.
- end = time.monotonic() + wait
- while job.get("id") and job.get("status") in {"queued", "sent"} and time.monotonic() < end:
- await asyncio.sleep(0.3)
- db.expire_all()
- job = session_queue.view(db, uid, job["id"], task_id)
+ user, grant = await run_in_session(_caller, uid, task_id)
+ job = await dispatch(user, target, operation, args,
+ request_key(task_id, target, operation, args), grant)
+ # A device answers through its queue; poll it without holding a session.
+ end = time.monotonic() + wait
+ while job.get("id") and job.get("status") in {"queued", "sent"} and time.monotonic() < end:
+ await asyncio.sleep(0.3)
+ job = await run_in_session(session_queue.view, uid, job["id"], task_id)
if not job.get("id"): return job # pending_approval, rejected or an earlier receipt
if job["status"] in {"queued", "sent"}: return {"status": "waiting_for_device", "operation_id": job["id"]}
return {"status": job["status"], "result": job.get("result")}
diff --git a/src/backend/personal_agent/tasks/attachments.py b/src/backend/personal_agent/tasks/attachments.py
index 2d0a143d..957e85a4 100644
--- a/src/backend/personal_agent/tasks/attachments.py
+++ b/src/backend/personal_agent/tasks/attachments.py
@@ -1,9 +1,9 @@
"""Stage explicitly attached task files for initial and steered inputs."""
import asyncio
import posixpath
-from core.db.engine import SessionLocal
from core.db.models import Artifact
from core.db.artifact_scope import is_personal_artifact
+from core.db.threads import run_in_session
async def stage_inputs(task_id, uid, box, artifact_ids):
@@ -14,11 +14,13 @@ async def stage_inputs(task_id, uid, box, artifact_ids):
provider = get_personal_sandbox_provider()
prompt = ""
for artifact_id in artifact_ids:
- with SessionLocal() as db:
+ def _attachment(db):
artifact = db.get(Artifact, artifact_id)
if not artifact or artifact.user_id != uid or not is_personal_artifact(artifact) or artifact.deleted_at:
raise PermissionError("Task attachment no longer available")
filename, key, size = artifact.filename, artifact.storage_key, artifact.size_bytes
+ return size, key, filename
+ size, key, filename = await run_in_session(_attachment)
if size > 20 * 1024 * 1024:
raise ValueError("Attachment exceeds 20 MiB")
path = "/workspace/work/" + task_id + "/inputs/" + artifact_id + "/" + posixpath.basename(filename)
diff --git a/src/backend/personal_agent/tasks/runtime.py b/src/backend/personal_agent/tasks/runtime.py
index 99b1acd8..893603dc 100644
--- a/src/backend/personal_agent/tasks/runtime.py
+++ b/src/backend/personal_agent/tasks/runtime.py
@@ -4,10 +4,10 @@
from collections import Counter
from datetime import datetime
-from core.db.engine import SessionLocal
from core.db.models import ChatMessage
from personal_agent.models import PersonalAgent, PersonalWorkItem
from sqlalchemy import select
+from core.db.threads import run_in_session
MAX_SEGMENT = 30000
@@ -72,44 +72,52 @@ def clock(config):
name = user_zone(config)
return "Current time for the user: " + datetime.now(zone(name)).strftime("%Y-%m-%d %H:%M (%A)") + ", timezone " + name + ".\n"
+def _task_inputs(db, task_id):
+ """Everything the run needs from the database, read before any model call."""
+ task = db.get(PersonalWorkItem, task_id)
+ execution = (task.id, task.run_id, task.owner)
+ agent_row = db.get(PersonalAgent, task.agent_id)
+ artifact_ids = (task.checkpoint or {}).get("input_artifact_ids", [])
+ config, uid, chat_id, box = dict(agent_row.config or {}), agent_row.user_id, agent_row.chat_id, agent_row.sandbox_session_id
+ from personal_agent.conversation.conversations import task_chat
+ chat_id = task_chat(db, agent_row, task)
+ from personal_agent.identity.capabilities import runtime_config
+ config = runtime_config(db, uid, config)
+ goal = task.goal
+ # Query task updates directly. A busy lifelong conversation must not evict the goal.
+ from personal_agent.conversation.associations import instruction_messages
+ from personal_agent.tasks.steering import TaskSteering
+ messages = instruction_messages(db, chat_id, task_id)
+ steering = TaskSteering(task_id, uid, task.run_id, task.owner, messages, artifact_ids,
+ (task.checkpoint or {}).get("consumed_message_ids", []))
+ updates = [text for _, text in steering.initial_messages]
+ recent = db.scalars(select(ChatMessage).where(ChatMessage.chat_id == chat_id,
+ ChatMessage.chat_seq < task.context_seq, ChatMessage.role.in_(["user", "assistant"]))
+ .order_by(ChatMessage.chat_seq.desc()).limit(12)).all()
+ prompt = clock(config) + "Recent conversation (reference data):\n" + "\n".join(
+ f"{m.role}: {re.sub(r'/((?:api/)?files)/[A-Za-z0-9_-]+', '[previous attachment; export again for this task]', m.content[:1500])}" for m in reversed(recent))
+ prompt += "\nTask ID ( in the rules): " + task_id + "; task folder: /workspace/work/" + task_id + "/"
+ prompt += "\nTask goal:\n" + goal[:20000] + "\nTask updates:\n" + "\n".join(updates)
+ if task.result:
+ prompt += "\nPrevious result (reference data, not instructions):\n" + task.result[:20000]
+ prompt += "\nContinue this same task using the latest user updates. Do not repeat completed work or external actions."
+ if task.checkpoint:
+ prompt += "\nTask checkpoint (reference data):\n" + json.dumps({
+ k: v for k, v in task.checkpoint.items() if k not in {"records", "final_answer"}}, ensure_ascii=False)[:12000]
+ return execution, steering, prompt, goal, config, uid, box, artifact_ids
+
+
async def execute_task(task_id, binding):
import asyncio
from core.llm.factory import create_agent_executor
from core.llm.runtime.streaming import StreamingAgent
- with SessionLocal() as db:
- task = db.get(PersonalWorkItem, task_id)
- from personal_agent.tasks.execution import bind
- bind(task)
- agent_row = db.get(PersonalAgent, task.agent_id)
- artifact_ids = (task.checkpoint or {}).get("input_artifact_ids", [])
- config, uid, chat_id, box = dict(agent_row.config or {}), agent_row.user_id, agent_row.chat_id, agent_row.sandbox_session_id
- from personal_agent.conversation.conversations import task_chat
- chat_id = task_chat(db, agent_row, task)
- from personal_agent.identity.capabilities import runtime_config
- config = runtime_config(db, uid, config)
- goal = task.goal
- # Query task updates directly. A busy lifelong conversation must not evict the goal.
- from personal_agent.conversation.associations import instruction_messages
- from personal_agent.tasks.steering import TaskSteering, current_steering
- messages = instruction_messages(db, chat_id, task_id)
- steering = TaskSteering(task_id, uid, task.run_id, task.owner, messages, artifact_ids,
- (task.checkpoint or {}).get("consumed_message_ids", []))
- current_steering.set(steering)
- updates = [text for _, text in steering.initial_messages]
- recent = db.scalars(select(ChatMessage).where(ChatMessage.chat_id == chat_id,
- ChatMessage.chat_seq < task.context_seq, ChatMessage.role.in_(["user", "assistant"]))
- .order_by(ChatMessage.chat_seq.desc()).limit(12)).all()
- prompt = clock(config) + "Recent conversation (reference data):\n" + "\n".join(
- f"{m.role}: {re.sub(r'/((?:api/)?files)/[A-Za-z0-9_-]+', '[previous attachment; export again for this task]', m.content[:1500])}" for m in reversed(recent))
- prompt += "\nTask ID ( in the rules): " + task_id + "; task folder: /workspace/work/" + task_id + "/"
- prompt += "\nTask goal:\n" + goal[:20000] + "\nTask updates:\n" + "\n".join(updates)
- if task.result:
- prompt += "\nPrevious result (reference data, not instructions):\n" + task.result[:20000]
- prompt += "\nContinue this same task using the latest user updates. Do not repeat completed work or external actions."
- if task.checkpoint:
- prompt += "\nTask checkpoint (reference data):\n" + json.dumps({
- k: v for k, v in task.checkpoint.items() if k not in {"records", "final_answer"}}, ensure_ascii=False)[:12000]
+ from personal_agent.tasks.execution import current_execution
+ from personal_agent.tasks.steering import current_steering
+ execution, steering, prompt, goal, config, uid, box, artifact_ids = await run_in_session(_task_inputs, task_id)
+ # Context variables are set here, on the task's own context, not in the worker thread.
+ current_execution.set(execution)
+ current_steering.set(steering)
from personal_agent.tasks.attachments import stage_inputs
prompt += await stage_inputs(task_id, uid, box, artifact_ids)
from personal_agent.identity.capabilities import capability_ids
@@ -144,8 +152,10 @@ async def execute_task(task_id, binding):
records.append({"event": event})
if event == "error": raise RuntimeError("Model execution failed")
await asyncio.to_thread(transcript.flush)
- with SessionLocal() as db:
+ def _checkpoint(db):
state = dict(db.get(PersonalWorkItem, task_id).checkpoint or {})
+ return state
+ state = await run_in_session(_checkpoint)
answer = state.get("final_answer") or {}
if answer.get("run_id") == binding.run_id and answer.get("text"):
final = answer["text"]
diff --git a/src/backend/personal_agent/tasks/worker.py b/src/backend/personal_agent/tasks/worker.py
index 2f846a34..a1d5f2dd 100644
--- a/src/backend/personal_agent/tasks/worker.py
+++ b/src/backend/personal_agent/tasks/worker.py
@@ -12,6 +12,7 @@
from personal_agent.models import PersonalAgent, PersonalWorkItem
from personal_agent.context import AgentContext
from personal_agent.identity.config import concurrency
+from core.db.threads import run_in_session
log = logging.getLogger(__name__)
MAX_AUTO_RESUMES = 2
@@ -71,6 +72,81 @@ def notify(db, row, text):
except Exception as exc:
log.warning("Personal notice failed (%s)", type(exc).__name__)
+def _renew_lease(db, ident, identity):
+ row = db.get(PersonalWorkItem, ident)
+ if row is None or row.status != "running" or row.owner != identity or not row.lease_until or row.lease_until < time.time():
+ return False
+ row.lease_until = time.time() + TASK_LEASE_SEC
+ db.commit()
+ return True
+
+def _start_run(db, ident, identity):
+ row = db.scalar(select(PersonalWorkItem).where(PersonalWorkItem.id == ident).with_for_update())
+ if row is None or row.owner != identity or row.status != "running": return None
+ agent = db.get(PersonalAgent, row.agent_id)
+ from personal_agent.tasks.task_threads import sync_thread
+ session = sync_thread(db, row, agent.user_id)
+ started = agent.user_id, session.chat_id, row.run_id
+ db.commit()
+ return started
+
+def _ready_to_execute(db, ident, identity, run_id):
+ row = db.get(PersonalWorkItem, ident)
+ if row is None or row.owner != identity or row.run_id != run_id or row.status != "running" or not row.lease_until or row.lease_until < time.time(): return False
+ row.pop_state("decided_during_run")
+ reason = blocked(row)
+ parked = computer_wait.park(row)
+ if reason: wait_for_user(db, row, reason)
+ db.commit()
+ return not (reason or parked)
+
+def _record_outcome(db, ident, identity, uid, result, checkpoint):
+ # Locked until the outcome is written, so an approval cannot slip in between.
+ row = db.scalar(select(PersonalWorkItem).where(PersonalWorkItem.id == ident).with_for_update())
+ if row.owner != identity or row.status != "running": return
+ state = row.checkpoint or {}
+ if computer_wait.park(row):
+ db.commit()
+ elif row.unconfirmed() or state.get("task_wait_reason"):
+ wait_for_user(db, row, blocked(row))
+ db.commit()
+ elif row.pop_state("decided_during_run"):
+ # The user answered while this run was ending. Run again to act on the answer.
+ row.requeue()
+ db.commit()
+ elif state.get("pending_approval"):
+ wait_for_user(db, row, AWAITING_APPROVAL)
+ db.commit()
+ else:
+ AgentContext(db, uid).tasks.complete(ident, result, {**(row.checkpoint or {}), **checkpoint}, owner=identity)
+
+def _record_failure(db, ident, identity, unknown):
+ row = db.get(PersonalWorkItem, ident)
+ if not (row and row.status == "running" and row.owner == identity): return
+ if not unknown and computer_wait.park(row):
+ db.commit()
+ return
+ uncertain = unknown or blocked(row) is not None
+ row.status = "waiting" if uncertain else "failed"
+ row.waiting_reason = "操作结果未知,需核对后继续" if uncertain else "任务执行失败。请检查模型、连接器或电脑状态后重试。"
+ notify(db, row, row.label + ("需要你核对操作结果。" if uncertain else "执行失败。可以在任务卡片中重试。"))
+ if row.status == "failed":
+ from personal_agent.workspace.file_memory import record_task
+ record_task(db, row, db.get(PersonalAgent, row.agent_id))
+ db.commit()
+
+def _approved_payloads(db, ident):
+ row = db.get(PersonalWorkItem, ident)
+ return (row.id, row.run_id, row.owner), dict((row.checkpoint or {}).get("approved_action_payloads", {}))
+
+def _consume_payload(db, ident, digest):
+ """Drop one dispatched action; False when the task now waits for the user."""
+ row = db.get(PersonalWorkItem, ident)
+ state = dict(row.checkpoint or {})
+ remaining = dict(state.get("approved_action_payloads", {})); remaining.pop(digest, None)
+ state["approved_action_payloads"] = remaining; row.checkpoint = state; db.commit()
+ return not (state.get("pending_approval") or state.get("task_wait_reason"))
+
class PersonalAgentWorker:
def __init__(self, execute=None):
self.execute = execute
@@ -139,127 +215,73 @@ def recover(self):
async def heartbeat(self, ident):
while True:
await asyncio.sleep(10)
- with SessionLocal() as db:
- row = db.get(PersonalWorkItem, ident)
- if row is None or row.status != "running" or row.owner != self.identity or not row.lease_until or row.lease_until < time.time():
- running = self.active.get(ident)
- if running: running.cancel()
- return
- row.lease_until = time.time() + TASK_LEASE_SEC
- db.commit()
+ if not await run_in_session(_renew_lease, ident, self.identity):
+ running = self.active.get(ident)
+ if running: running.cancel()
+ return
async def run(self, ident):
beat = asyncio.create_task(self.heartbeat(ident))
try:
- with SessionLocal() as db:
- row = db.scalar(select(PersonalWorkItem).where(PersonalWorkItem.id == ident).with_for_update())
- if row is None or row.owner != self.identity or row.status != "running": return
- agent = db.get(PersonalAgent, row.agent_id)
- from personal_agent.tasks.task_threads import sync_thread
- session = sync_thread(db, row, agent.user_id)
- uid, chat_id, run_id = agent.user_id, session.chat_id, row.run_id
- db.commit()
+ started = await run_in_session(_start_run, ident, self.identity)
+ if started is None: return
+ uid, chat_id, run_id = started
async with durable_run_binding(user_id=uid, chat_id=chat_id, kind="personal_agent_task", external_id=ident, binding_run_id=run_id) as binding:
await self.dispatch_approved(ident, uid)
- with SessionLocal() as db:
- row = db.get(PersonalWorkItem, ident)
- if row is None or row.owner != self.identity or row.run_id != run_id or row.status != "running" or not row.lease_until or row.lease_until < time.time(): return
- row.pop_state("decided_during_run")
- reason = blocked(row)
- parked = computer_wait.park(row)
- if reason: wait_for_user(db, row, reason)
- db.commit()
- if reason or parked: return
+ if not await run_in_session(_ready_to_execute, ident, self.identity, run_id): return
from personal_agent.tasks.runtime import execute_task, RunawayOutput
try:
result, checkpoint = await (self.execute or execute_task)(ident, binding)
except RunawayOutput as exc:
reason = "模型输出重复或过长,已停止。请补充要求后点击重试。"
- with SessionLocal() as db:
+ def _park_or_pause(db):
row = db.get(PersonalWorkItem, ident)
if row.status == "running" and row.owner == self.identity:
if not computer_wait.park(row): pause(db, row, reason)
db.commit()
+ await run_in_session(_park_or_pause)
log.warning("Personal task stopped: %s (%s)", ident, type(exc).__name__)
return
- with SessionLocal() as db:
- # Locked until the outcome is written, so an approval cannot slip in between.
- row = db.scalar(select(PersonalWorkItem).where(PersonalWorkItem.id == ident).with_for_update())
- if row.owner != self.identity or row.status != "running": return
- state = row.checkpoint or {}
- if computer_wait.park(row):
- db.commit()
- elif row.unconfirmed() or state.get("task_wait_reason"):
- wait_for_user(db, row, blocked(row))
- db.commit()
- elif row.pop_state("decided_during_run"):
- # The user answered while this run was ending. Run again to act on the answer.
- row.requeue()
- db.commit()
- elif state.get("pending_approval"):
- wait_for_user(db, row, AWAITING_APPROVAL)
- db.commit()
- else:
- AgentContext(db, uid).tasks.complete(ident, result, {**(row.checkpoint or {}), **checkpoint}, owner=self.identity)
+ await run_in_session(_record_outcome, ident, self.identity, uid, result, checkpoint)
except asyncio.CancelledError:
- with SessionLocal() as db:
+ def _interrupt(db):
row = db.get(PersonalWorkItem, ident)
if row and row.status == "running" and row.owner == self.identity:
interrupt(db, row, "执行已中断。请检查操作结果后继续。")
db.commit()
+ await run_in_session(_interrupt)
raise
except Exception as exc:
from core.runs.tool_effects.errors import find_tool_outcome_unknown
- with SessionLocal() as db:
- row = db.get(PersonalWorkItem, ident)
- if row and row.status == "running" and row.owner == self.identity:
- unknown = find_tool_outcome_unknown(exc) is not None
- if not unknown and computer_wait.park(row):
- db.commit()
- return
- uncertain = unknown or blocked(row) is not None
- row.status = "waiting" if uncertain else "failed"
- row.waiting_reason = "操作结果未知,需核对后继续" if uncertain else "任务执行失败。请检查模型、连接器或电脑状态后重试。"
- notify(db, row, row.label + ("需要你核对操作结果。" if uncertain else "执行失败。可以在任务卡片中重试。"))
- if row.status == "failed":
- from personal_agent.workspace.file_memory import record_task
- record_task(db, row, db.get(PersonalAgent, row.agent_id))
- db.commit()
+ await run_in_session(
+ _record_failure, ident, self.identity, find_tool_outcome_unknown(exc) is not None
+ )
log.warning("Personal task failed: %s (%s)", ident, type(exc).__name__)
finally:
beat.cancel()
with suppress(asyncio.CancelledError): await beat
async def dispatch_approved(self, ident, uid):
- from personal_agent.tasks.execution import bind
- with SessionLocal() as db:
- row = db.get(PersonalWorkItem, ident)
- bind(row)
- payloads = dict((row.checkpoint or {}).get("approved_action_payloads", {}))
+ from personal_agent.tasks.execution import current_execution
+ execution, payloads = await run_in_session(_approved_payloads, ident)
+ current_execution.set(execution)
if not payloads: return
- class Tools:
- def __init__(self): self.functions = {}
- def register_tool_function(self, function): self.functions[function.__name__] = function
- tools = Tools()
+ tools = []
from personal_agent.tools.email import register_email_tools
from personal_agent.computer.computer import register_computer_tools
register_email_tools(tools, ident, uid)
register_computer_tools(tools, ident, uid)
+ functions = {tool.name: tool._func for tool in tools}
for digest, action in payloads.items():
if action["kind"] == "email.send":
- await tools.functions["pa_email_send"](action["to"], action["subject"], action["body"],
+ await functions["pa_email_send"](action["to"], action["subject"], action["body"],
[item["path"] for item in action.get("attachments", [])])
elif action["kind"] == "hugagent.session":
from personal_agent.sessions.session_manager import dispatch_approved
await dispatch_approved(ident, uid, action)
elif action["kind"] == "computer.action":
- await tools.functions["pa_computer_action"](action["action"], action["target"])
- with SessionLocal() as db:
- row = db.get(PersonalWorkItem, ident)
- state = dict(row.checkpoint or {})
- remaining = dict(state.get("approved_action_payloads", {})); remaining.pop(digest, None)
- state["approved_action_payloads"] = remaining; row.checkpoint = state; db.commit()
- if state.get("pending_approval") or state.get("task_wait_reason"): break
+ await functions["pa_computer_action"](action["action"], action["target"])
+ if not await run_in_session(_consume_payload, ident, digest): break
async def loop(self):
last_maintenance = 0
diff --git a/src/backend/personal_agent/tools/email.py b/src/backend/personal_agent/tools/email.py
index 456b2f24..1bc29f1f 100644
--- a/src/backend/personal_agent/tools/email.py
+++ b/src/backend/personal_agent/tools/email.py
@@ -1,4 +1,5 @@
"""IMAP/SMTP broker. Credentials remain in the backend, never in the sandbox."""
+from core.llm.factory.tools.definitions import function_tool
import asyncio
import hashlib
import imaplib
@@ -134,4 +135,4 @@ def send():
receipt(ident, uid, digest, result)
return response(result)
- for fn in (pa_email_list, pa_email_read, pa_email_download, pa_email_send): toolkit.register_tool_function(fn)
+ for fn in (pa_email_list, pa_email_read, pa_email_download, pa_email_send): toolkit.append(function_tool(fn))
diff --git a/src/backend/personal_agent/tools/front_tools.py b/src/backend/personal_agent/tools/front_tools.py
index e1f15fb0..18587a87 100644
--- a/src/backend/personal_agent/tools/front_tools.py
+++ b/src/backend/personal_agent/tools/front_tools.py
@@ -4,19 +4,21 @@
Work with external effects (Bash, files, email, the computer, writes to the user's
HugAgentOS) belongs to tasks; a turn may only read the user's HugAgentOS sessions.
"""
+from core.llm.factory.tools.definitions import function_tool
import json
from contextlib import contextmanager
from typing import Literal
from agentscope.message import TextBlock
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
from sqlalchemy import select
from core.db.engine import SessionLocal
from core.db.models import ChatMessage
from personal_agent.models import PersonalAgent, PersonalWorkItem
from personal_agent.tasks import task_ops
+from core.db.threads import off_loop
def response(value):
- return ToolResponse(content=[TextBlock(type="text", text=json.dumps(value, ensure_ascii=False, default=str))])
+ return ToolChunk(content=[TextBlock(type="text", text=json.dumps(value, ensure_ascii=False, default=str))])
def register_front_tools(toolkit, request, loader=None):
from personal_agent.conversation.front import EVENT_ROLE, current_turn, owned
@@ -53,7 +55,8 @@ def changing(state):
yield AgentContext(db, uid, chat_id=db.get(ChatMessage, ident).chat_id), db.get(ChatMessage, ident)
db.commit()
- async def pa_task_create(goal: str, title: str = "", related_task_ids: list[str] | None = None) -> ToolResponse:
+ @off_loop
+ def pa_task_create(goal: str, title: str = "", related_task_ids: list[str] | None = None) -> ToolChunk:
"""Start a background task. goal: a self-contained instruction in the user's language with the
requirements and the expected result; the task sees only the last few messages. title: a short
name of at most 20 characters. related_task_ids: existing tasks this new deliverable builds on.
@@ -71,7 +74,8 @@ async def pa_task_create(goal: str, title: str = "", related_task_ids: list[str]
state["count"] += 1
return response(result)
- async def pa_task_update(task_id: str, instruction: str) -> ToolResponse:
+ @off_loop
+ def pa_task_update(task_id: str, instruction: str) -> ToolChunk:
"""Continue or change one existing task, including a completed task. Use only for a clear follow-up to its goal or result, never because it is recent or the only task. Read pa_task_get first before updating a task unless the user explicitly selected its ID. instruction: only what applies to this task."""
state = turn(changes_tasks=True)
with checked(state) as db:
@@ -84,7 +88,8 @@ async def pa_task_update(task_id: str, instruction: str) -> ToolResponse:
result = {"updated": True, "status": task_ops.update_task(svc, message, task_id, instruction, attachments=state["attachments"]).status}
return response(result)
- async def pa_task_control(task_id: str, action: Literal["pause", "resume", "cancel"]) -> ToolResponse:
+ @off_loop
+ def pa_task_control(task_id: str, action: Literal["pause", "resume", "cancel"]) -> ToolChunk:
"""Pause, resume or cancel one task. Resume never approves a pending action; the user approves
those in the approval card. Resume applies to waiting tasks; to continue a completed, failed or
cancelled task, read it and use pa_task_update with the user's follow-up instruction."""
@@ -92,7 +97,8 @@ async def pa_task_control(task_id: str, action: Literal["pause", "resume", "canc
result = {"status": task_ops.control(svc, message, task_id, action).status}
return response(result)
- async def pa_task_get(task_id: str) -> ToolResponse:
+ @off_loop
+ def pa_task_get(task_id: str) -> ToolChunk:
"""Read one task: goal, status, progress, the user's updates and the result."""
from personal_agent.conversation.associations import instructions
with checked(turn()) as db:
@@ -104,7 +110,8 @@ async def pa_task_get(task_id: str) -> ToolResponse:
"updates": instructions(db, db.get(ChatMessage, ident).chat_id, task.id, 10),
"result": (task.result or "")[:12000], "updated": task.updated_at})
- async def pa_history_search(query: str, limit: int = 10) -> ToolResponse:
+ @off_loop
+ def pa_history_search(query: str, limit: int = 10) -> ToolChunk:
"""Search earlier messages and task results of this conversation."""
with checked(turn()) as db:
chat = db.get(ChatMessage, ident).chat_id
@@ -124,7 +131,8 @@ def saved(data):
with SessionLocal() as db: AgentContext(db, uid).identity.adopt_identity_name(data.get("content") or "")
return response({"path": data["path"], "saved": True})
- async def pa_memory_read(path: str) -> ToolResponse:
+ @off_loop
+ def pa_memory_read(path: str) -> ToolChunk:
"""Read a workspace persona or memory file, for example USER.md, MEMORY.md or memory/2026-10-10.md."""
data = read_file(workspace(), path)
versions[data["path"]] = data["etag"]
@@ -133,7 +141,8 @@ async def pa_memory_read(path: str) -> ToolResponse:
return response({"path": data["path"], "content": text[-50000:] if daily else text[:50000],
"truncated": data["truncated"] or len(text) > 50000, "slice": "tail" if daily else "head"})
- async def pa_memory_edit(path: str, old_string: str, new_string: str) -> ToolResponse:
+ @off_loop
+ def pa_memory_edit(path: str, old_string: str, new_string: str) -> ToolChunk:
"""Replace one exact, unique passage of a persona or memory file. Read the file first."""
agent = workspace()
current = read_file(agent, path)
@@ -143,7 +152,8 @@ async def pa_memory_edit(path: str, old_string: str, new_string: str) -> ToolRes
versions[data["path"]] = data["etag"]
return saved(data)
- async def pa_memory_write(path: str, content: str) -> ToolResponse:
+ @off_loop
+ def pa_memory_write(path: str, content: str) -> ToolChunk:
"""Create or replace a persona or memory file, for example a new daily note memory/YYYY-MM-DD.md."""
from personal_agent.workspace.files import relative
target = WORKSPACE + "/" + relative(path)
@@ -151,25 +161,29 @@ async def pa_memory_write(path: str, content: str) -> ToolResponse:
versions[target] = data["etag"]
return saved(data)
- async def pa_set_name(name: str) -> ToolResponse:
+ @off_loop
+ def pa_set_name(name: str) -> ToolChunk:
"""Change your own name when the user gives you a new one."""
with checked(turn()) as db:
return response({"name": AgentContext(db, uid).identity.configure({"name": name})["config"]["name"]})
- async def pa_schedule_create(title: str, instruction: str, cron: str = "", run_at: str = "", timezone: str = "") -> ToolResponse:
+ @off_loop
+ def pa_schedule_create(title: str, instruction: str, cron: str = "", run_at: str = "", timezone: str = "") -> ToolChunk:
"""Create a scheduled task that later runs as a background task. cron: five fields in local time, such as
"0 8 * * *" for 08:00 daily; or run_at: one ISO local time. timezone defaults to the user's."""
with checked(turn(changes_tasks=True)) as db:
return response(create(db, uid, title, instruction, cron, run_at, timezone))
- async def pa_schedule_list() -> ToolResponse:
+ @off_loop
+ def pa_schedule_list() -> ToolChunk:
"""List the scheduled tasks and their next run times."""
with checked(turn()) as db:
return response(listing(db.scalar(select(PersonalAgent.config).where(PersonalAgent.user_id == uid))))
- async def pa_schedule_update(schedule_id: str, title: str | None = None, instruction: str | None = None,
+ @off_loop
+ def pa_schedule_update(schedule_id: str, title: str | None = None, instruction: str | None = None,
cron: str | None = None, run_at: str | None = None,
- timezone: str | None = None, enabled: bool | None = None) -> ToolResponse:
+ timezone: str | None = None, enabled: bool | None = None) -> ToolChunk:
"""Edit an existing schedule in place. List schedules first to identify it. Omit unchanged fields;
use enabled=false to pause. Give cron for repeats or run_at for a one-time ISO local time."""
from personal_agent.scheduling.schedules import update_schedule
@@ -178,12 +192,14 @@ async def pa_schedule_update(schedule_id: str, title: str | None = None, instruc
with checked(turn(changes_tasks=True)) as db:
return response(update_schedule(db, uid, schedule_id, patch))
- async def pa_schedule_delete(schedule_id: str) -> ToolResponse:
+ @off_loop
+ def pa_schedule_delete(schedule_id: str) -> ToolChunk:
"""Delete one scheduled task when the user asks to stop it."""
with checked(turn(changes_tasks=True)) as db:
return response(delete(db, uid, schedule_id))
- async def pa_stay_quiet(reason: str) -> ToolResponse:
+ @off_loop
+ def pa_stay_quiet(reason: str) -> ToolChunk:
"""Send nothing to the user for this scheduled result or heartbeat check, because it has nothing new or noteworthy."""
state = turn()
if state["kind"] == "event":
@@ -206,4 +222,4 @@ async def pa_stay_quiet(reason: str) -> ToolResponse:
# The user's HugAgentOS projects, chats and runs: reads only in a conversation turn.
from personal_agent.sessions.session_tool import build as session_tool
tools.append(session_tool(uid))
- for fn in tools: toolkit.register_tool_function(fn)
+ for fn in tools: toolkit.append(function_tool(fn))
diff --git a/src/backend/personal_agent/tools/responses.py b/src/backend/personal_agent/tools/responses.py
index 0b262d6e..40d04bef 100644
--- a/src/backend/personal_agent/tools/responses.py
+++ b/src/backend/personal_agent/tools/responses.py
@@ -4,7 +4,7 @@
from agentscope.message import TextBlock
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
def response(value):
- return ToolResponse(content=[TextBlock(type="text", text=json.dumps(value, ensure_ascii=False, default=str))])
+ return ToolChunk(content=[TextBlock(type="text", text=json.dumps(value, ensure_ascii=False, default=str))])
diff --git a/src/backend/personal_agent/tools/tools.py b/src/backend/personal_agent/tools/tools.py
index 1a38fc4e..a272ce98 100644
--- a/src/backend/personal_agent/tools/tools.py
+++ b/src/backend/personal_agent/tools/tools.py
@@ -1,17 +1,19 @@
"""Explicit Personal Agent tool surface; all targets derive from authenticated task."""
+from core.llm.factory.tools.definitions import function_tool
import personal_agent.workspace.files as _pa_files
from personal_agent.tasks.guards import owned_task
from personal_agent.tools.responses import response
from personal_agent.workspace.paths import workspace_path
-from agentscope.tool._response import ToolChunk as ToolResponse
+from agentscope.tool._response import ToolChunk
from core.db.engine import SessionLocal
from sqlalchemy import select
from core.db.models import ChatMessage
from core.sandbox.protocol import ProcessRequest
from personal_agent.models import PersonalWorkItem
from personal_agent.computer.sandbox import get_personal_sandbox_provider
+from core.db.threads import off_loop
def register_personal_tools(toolkit, request, loader=None):
ident, uid = request.profile_ref, str(request.current_user_id)
@@ -20,7 +22,8 @@ def register_personal_tools(toolkit, request, loader=None):
versions = {}
from personal_agent.workspace import persona
- async def pa_task_status() -> ToolResponse:
+ @off_loop
+ def pa_task_status() -> ToolChunk:
"""Read this task's latest user updates and pending actions."""
state = owned_task(ident, uid, allow_pending=True)
with SessionLocal() as db:
@@ -29,7 +32,8 @@ async def pa_task_status() -> ToolResponse:
return response({"goal": task.goal,
"updates": instructions(db, state["chat"], ident, 100), "checkpoint": task.checkpoint})
- async def pa_task_checkpoint(summary: str, next_step: str = ""):
+ @off_loop
+ def pa_task_checkpoint(summary: str, next_step: str = ""):
"""Save bounded task progress before a long step, interruption or approval."""
owned_task(ident, uid, allow_pending=True)
if len(summary) > 4000 or len(next_step) > 1000: raise ValueError("Checkpoint is too long")
@@ -41,7 +45,7 @@ async def pa_task_checkpoint(summary: str, next_step: str = ""):
db.commit()
return response({"saved": True})
- async def pa_bash(command: str, timeout_seconds: int = 60) -> ToolResponse:
+ async def pa_bash(command: str, timeout_seconds: int = 60) -> ToolChunk:
"""Run Bash inside the dedicated cloud computer. Never runs on the user's device."""
state = owned_task(ident, uid)
duration = max(1, min(300, timeout_seconds))
@@ -65,7 +69,7 @@ async def pa_bash(command: str, timeout_seconds: int = 60) -> ToolResponse:
await get_ephemeral_state().drop(key(token))
return response({"stdout": result.stdout[-50000:].replace(token,"[task credential]"), "stderr": result.stderr[-10000:].replace(token,"[task credential]"), "exit_code": result.exit_code})
- async def pa_read_file(path: str) -> ToolResponse:
+ async def pa_read_file(path: str) -> ToolChunk:
"""Read UTF-8 text from the dedicated cloud workspace, with a bounded result."""
state = owned_task(ident, uid)
target = workspace_path(path)
@@ -79,7 +83,7 @@ async def pa_read_file(path: str) -> ToolResponse:
known.add(workspace_path(path))
return response({"path": path, "content": content.decode("utf-8")[:50000]})
- async def pa_write_file(path: str, content: str) -> ToolResponse:
+ async def pa_write_file(path: str, content: str) -> ToolChunk:
"""Write a task file in the dedicated workspace; does not write to MySpace."""
state = owned_task(ident, uid)
if len(content.encode()) > 2*1024*1024: raise ValueError("File too large")
@@ -92,7 +96,7 @@ async def pa_write_file(path: str, content: str) -> ToolResponse:
known.add(workspace_path(path))
return response({"path": path, "saved": True})
- async def pa_edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False) -> ToolResponse:
+ async def pa_edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False) -> ToolChunk:
"""Replace exact text in a workspace file, such as one entry of USER.md or MEMORY.md.
Read the file with pa_read_file first. old_string must match exactly and be unique unless replace_all is true."""
state = owned_task(ident, uid)
@@ -122,7 +126,8 @@ async def pa_edit_file(path: str, old_string: str, new_string: str, replace_all:
await provider.put_file(state["box"], target, updated, user_id=uid)
return response({"path": path, "replacements": count if replace_all else 1})
- async def pa_history_search(query: str, limit: int = 20):
+ @off_loop
+ def pa_history_search(query: str, limit: int = 20):
"""Retrieve earlier final replies from the owner's one lifelong conversation."""
state = owned_task(ident, uid)
with SessionLocal() as db:
@@ -145,7 +150,8 @@ async def pa_skill_read(skill_id: str, relative_path: str = "SKILL.md"):
if path.stat().st_size > 200000: raise ValueError("Skill reference too large")
return response({"skill_id": skill_id, "path": relative_path, "content": path.read_text()})
- async def pa_finish(answer: str) -> ToolResponse:
+ @off_loop
+ def pa_finish(answer: str) -> ToolChunk:
"""Complete the task with final text in the user's language, then stop.
To show an image/file in the main conversation, call pa_deliver before this.
Already delivered outputs remain visible; do not export or send them again."""
@@ -158,7 +164,8 @@ async def pa_finish(answer: str) -> ToolResponse:
db.commit()
return response({"delivered": True, "next": "Stop now. Do not write more text."})
- async def pa_set_name(name: str) -> ToolResponse:
+ @off_loop
+ def pa_set_name(name: str) -> ToolChunk:
"""Change your own name when the user asks for a new one. Updates settings, IDENTITY.md and the name shown in the app."""
owned_task(ident, uid)
from personal_agent.context import AgentContext
@@ -166,7 +173,8 @@ async def pa_set_name(name: str) -> ToolResponse:
view = AgentContext(db, uid).identity.configure({"name": name})
return response({"name": view["config"]["name"]})
- async def pa_schedule_create(title: str, instruction: str, cron: str = "", run_at: str = "", timezone: str = "") -> ToolResponse:
+ @off_loop
+ def pa_schedule_create(title: str, instruction: str, cron: str = "", run_at: str = "", timezone: str = "") -> ToolChunk:
"""Create a scheduled task that runs later as a new task. Use cron (five fields, local time) for repeats,
for example "0 8 * * *" for 08:00 every day, or run_at (ISO local time) for one time. timezone defaults to the user's."""
owned_task(ident, uid)
@@ -174,15 +182,16 @@ async def pa_schedule_create(title: str, instruction: str, cron: str = "", run_a
with SessionLocal() as db:
return response(create(db, uid, title, instruction, cron, run_at, timezone))
- async def pa_schedule_list() -> ToolResponse:
+ async def pa_schedule_list() -> ToolChunk:
"""List the user's scheduled tasks and their next run times."""
state = owned_task(ident, uid)
from personal_agent.scheduling.schedules import listing
return response(listing(state["config"]))
- async def pa_schedule_update(schedule_id: str, title: str | None = None, instruction: str | None = None,
+ @off_loop
+ def pa_schedule_update(schedule_id: str, title: str | None = None, instruction: str | None = None,
cron: str | None = None, run_at: str | None = None,
- timezone: str | None = None, enabled: bool | None = None) -> ToolResponse:
+ timezone: str | None = None, enabled: bool | None = None) -> ToolChunk:
"""Edit an existing schedule in place. List schedules first to identify it. Omit unchanged fields;
use enabled=false to pause. Give cron for repeats or run_at for a one-time ISO local time."""
from personal_agent.scheduling.schedules import update_schedule
@@ -192,7 +201,8 @@ async def pa_schedule_update(schedule_id: str, title: str | None = None, instruc
with SessionLocal() as db:
return response(update_schedule(db, uid, schedule_id, patch))
- async def pa_schedule_delete(schedule_id: str) -> ToolResponse:
+ @off_loop
+ def pa_schedule_delete(schedule_id: str) -> ToolChunk:
"""Delete one scheduled task when the user asks to stop it."""
owned_task(ident, uid)
from personal_agent.scheduling.schedules import delete
@@ -205,12 +215,12 @@ async def pa_schedule_delete(schedule_id: str) -> ToolResponse:
managing = () if scheduled else (pa_schedule_create, pa_schedule_update, pa_schedule_delete)
for fn in (pa_task_status, pa_task_checkpoint, pa_bash, pa_read_file, pa_write_file, pa_edit_file, pa_history_search, pa_skill_read,
pa_set_name, pa_finish, pa_schedule_list, *managing):
- toolkit.register_tool_function(fn)
+ toolkit.append(function_tool(fn))
from personal_agent.delivery.delivery import build as delivery_tool
- toolkit.register_tool_function(delivery_tool(ident, uid))
+ toolkit.append(function_tool(delivery_tool(ident, uid)))
# The user's HugAgentOS sessions; writes wait for the user's approval.
from personal_agent.sessions.session_tool import build as session_tool
- toolkit.register_tool_function(session_tool(uid, ident))
+ toolkit.append(function_tool(session_tool(uid, ident)))
from personal_agent.tools.email import register_email_tools
from personal_agent.computer.computer import register_computer_tools
register_email_tools(toolkit, ident, uid)
diff --git a/src/backend/personal_agent/workspace/dreaming.py b/src/backend/personal_agent/workspace/dreaming.py
index c8f056fa..bb8ea4f6 100644
--- a/src/backend/personal_agent/workspace/dreaming.py
+++ b/src/backend/personal_agent/workspace/dreaming.py
@@ -191,13 +191,15 @@ def failed(sessions, ident, run_id, owner, exc, now):
except (OSError, RuntimeError, ValueError):
pass # Durable status remains visible even when the report volume is unavailable.
+def ready_agents(sessions):
+ with sessions() as db:
+ return list(db.scalars(select(PersonalAgent.id).where(PersonalAgent.status == "ready")))
+
async def tick(sessions=None, now=None, complete=None):
sessions = sessions or SessionLocal
fixed_now = now
complete = complete or dream_plan.complete
- with sessions() as db:
- ids = list(db.scalars(select(PersonalAgent.id).where(PersonalAgent.status == "ready")))
- for ident in ids:
+ for ident in await asyncio.to_thread(ready_agents, sessions):
now = time.time() if fixed_now is None else fixed_now
claimed = await asyncio.to_thread(claim, sessions, ident, now)
if not claimed: continue
diff --git a/src/backend/tests/api/chat_fork_fixtures.py b/src/backend/tests/api/chat_fork_fixtures.py
index 1ebccf8a..1f759cea 100644
--- a/src/backend/tests/api/chat_fork_fixtures.py
+++ b/src/backend/tests/api/chat_fork_fixtures.py
@@ -4,14 +4,16 @@
from core.auth.backend import UserContext, get_current_user
from core.db.engine import Base, get_db
from core.db.models import ChatCompactionState, ChatMessage, ChatRun, ChatSession, Project
+from core.db.models.command import CommandTask
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
+from tests.db_support import share_factory
@pytest.fixture
-def fork_env(tmp_path):
+def fork_env(tmp_path, monkeypatch):
from api.routes.v1.chat_forks import router
from api.routes.v1.chats import router as chats_router
@@ -27,6 +29,7 @@ def fork_env(tmp_path):
ChatRun.__table__,
ChatCompactionState.__table__,
Project.__table__,
+ CommandTask.__table__,
],
)
sessions = sessionmaker(bind=engine)
@@ -99,6 +102,7 @@ def database():
app.include_router(router)
app.include_router(chats_router)
app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, sessions)
app.dependency_overrides[get_current_user] = lambda: user
with TestClient(app) as client:
yield client, sessions, user
diff --git a/src/backend/tests/api/test_agent_api_keys.py b/src/backend/tests/api/test_agent_api_keys.py
index cdf4ac41..4622a076 100644
--- a/src/backend/tests/api/test_agent_api_keys.py
+++ b/src/backend/tests/api/test_agent_api_keys.py
@@ -29,6 +29,7 @@
from core.auth.agent_api import begin_agent_api_call, bind_agent_api_run, list_agent_api_calls, prepare_agent_api_request
from tests.api.test_agent_api_scope import db, _key, _user
from core.chat.schemas import ChatRequest
+from tests.db_support import share_session
def _app(db, monkeypatch, *, user_id="owner", middleware=False):
@@ -47,6 +48,7 @@ def _app(db, monkeypatch, *, user_id="owner", middleware=False):
username=user_id,
)
app.dependency_overrides[get_db] = lambda: db
+ share_session(monkeypatch, db)
if middleware:
from api.middleware import agent_api_scope
diff --git a/src/backend/tests/api/test_agent_response_admission.py b/src/backend/tests/api/test_agent_response_admission.py
index 37972459..78afac30 100644
--- a/src/backend/tests/api/test_agent_response_admission.py
+++ b/src/backend/tests/api/test_agent_response_admission.py
@@ -165,20 +165,18 @@ def gated_accept(self, **kwargs):
)
def send(label):
- with Session() as db:
- try:
- response = asyncio.run(
- agent_response(
- ChatRequest(
- chat_id="chat-1", message=label, stream=transports[label == "second"]
- ),
- user=user,
- db=db,
- )
+ try:
+ response = asyncio.run(
+ agent_response(
+ ChatRequest(
+ chat_id="chat-1", message=label, stream=transports[label == "second"]
+ ),
+ user=user,
)
- return "accepted", response
- except HTTPException as exc:
- return "busy", exc
+ )
+ return "accepted", response
+ except HTTPException as exc:
+ return "busy", exc
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = list(pool.map(send, ("first", "second")))
@@ -224,7 +222,6 @@ def test_non_stream_send_returns_busy_without_running_or_persisting(monkeypatch,
agent_response(
ChatRequest(chat_id="chat-1", message="must be rejected"),
user=_user(),
- db=db,
)
)
@@ -284,7 +281,7 @@ async def fake_wait_run(_run_id):
with Session() as db:
response = asyncio.run(
- agent_response(ChatRequest(chat_id="chat-1", message="hello"), user=_user(), db=db)
+ agent_response(ChatRequest(chat_id="chat-1", message="hello"), user=_user())
)
assert response.response == "world"
@@ -340,7 +337,7 @@ async def start(**kwargs):
with Session() as db:
result = asyncio.run(
admission.start_response_run(
- ChatRequest(chat_id="chat-1", message="hello", stream=stream), _user(), db
+ ChatRequest(chat_id="chat-1", message="hello", stream=stream), _user()
)
)
assert not db.in_transaction()
diff --git a/src/backend/tests/api/test_agent_response_scope.py b/src/backend/tests/api/test_agent_response_scope.py
index 105263d6..5c380fec 100644
--- a/src/backend/tests/api/test_agent_response_scope.py
+++ b/src/backend/tests/api/test_agent_response_scope.py
@@ -66,7 +66,6 @@ async def start(**kwargs):
result = await admission.start_response_run(
ChatRequest(chat_id="api-chat", message=message, stream=stream, agent_id="ua_one"),
_user(key),
- db,
)
assert captured["effective_user_message"] == message
assert captured["raw_user_message"] == message
diff --git a/src/backend/tests/api/test_agent_responses.py b/src/backend/tests/api/test_agent_responses.py
index 74bea21b..9af31406 100644
--- a/src/backend/tests/api/test_agent_responses.py
+++ b/src/backend/tests/api/test_agent_responses.py
@@ -26,7 +26,7 @@ def response_client(monkeypatch):
app.dependency_overrides[get_db] = lambda: db
seen = {}
- async def start(request, user, session):
+ async def start(request, user):
seen["stream"] = request.stream
return SimpleNamespace(run_id="run-1", message_id="message-1", user_message_id="user-message-1", chat_id=request.chat_id)
async def follow(run_id, *, chat_id):
diff --git a/src/backend/tests/api/test_archive_files.py b/src/backend/tests/api/test_archive_files.py
index 096a0471..47e43264 100644
--- a/src/backend/tests/api/test_archive_files.py
+++ b/src/backend/tests/api/test_archive_files.py
@@ -15,6 +15,7 @@
from core.db.engine import Base, get_db
from core.db.models import Artifact
from core.files.storage.local import LocalStorageBackend
+from tests.db_support import share_session
@pytest.fixture
@@ -31,6 +32,7 @@ def archive_client(tmp_path, monkeypatch):
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_db] = lambda: db
+ share_session(monkeypatch, db)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id="owner", user_center_id="owner", username="Owner"
)
diff --git a/src/backend/tests/api/test_artifacts_backfill_once.py b/src/backend/tests/api/test_artifacts_backfill_once.py
index 6d146424..93977bd1 100644
--- a/src/backend/tests/api/test_artifacts_backfill_once.py
+++ b/src/backend/tests/api/test_artifacts_backfill_once.py
@@ -16,6 +16,7 @@
from api.routes.v1 import artifacts as route
from core.auth.backend import UserContext
from core.db.models import Artifact, ChatMessage, ChatSession, UserShadow
+from tests.db_support import share_session
USER_ID = "u-backfill"
@@ -121,6 +122,8 @@ def test_listing_never_runs_the_scan_on_the_request(user_with_history, monkeypat
lambda uid, db: pytest.fail("回填不得跑在请求路径上"),
)
+ share_session(monkeypatch, user_with_history)
+
async def _call():
await route.list_user_artifacts(
type=None,
@@ -131,7 +134,6 @@ async def _call():
page=1,
page_size=50,
user=UserContext(user_id=USER_ID, user_center_id=USER_ID, username="backfill"),
- db=user_with_history,
)
# 扫描被甩到线程里,请求这边不等它
assert route._backfill_tasks
diff --git a/src/backend/tests/api/test_catalog_event_loop.py b/src/backend/tests/api/test_catalog_event_loop.py
index 1932d8f6..5dadc314 100644
--- a/src/backend/tests/api/test_catalog_event_loop.py
+++ b/src/backend/tests/api/test_catalog_event_loop.py
@@ -30,28 +30,31 @@ def _reset_external_cache(monkeypatch):
@pytest.mark.asyncio
async def test_catalog_handler_offloads_the_synchronous_builder(monkeypatch):
user = SimpleNamespace(user_id="user-1")
- db = object()
expected = {"data": {"kb": []}}
- calls: List[tuple[Any, tuple[Any, ...]]] = []
+ built: List[tuple[Any, Any]] = []
+ offloaded: List[Any] = []
async def cache_ready() -> None:
return None
def builder(received_user, received_db):
- pytest.fail("the builder must be passed to the threadpool, not called directly")
-
- async def run_in_threadpool(function, *args):
- calls.append((function, args))
+ built.append((received_user, received_db))
return expected
+ async def run_in_session(function, *args):
+ # The builder only ever runs inside the session step handed to the thread.
+ assert built == []
+ offloaded.append(function)
+ return function("session")
+
monkeypatch.setattr(catalog, "_ensure_external_cache_ready", cache_ready)
monkeypatch.setattr(catalog, "_get_catalog_items_sync", builder)
- monkeypatch.setattr(catalog, "run_in_threadpool", run_in_threadpool)
+ monkeypatch.setattr(catalog, "run_in_session", run_in_session)
- result = await catalog.get_catalog_items(user=user, db=db)
+ result = await catalog.get_catalog_items(user=user)
assert result is expected
- assert calls == [(builder, (user, db))]
+ assert len(offloaded) == 1 and built == [(user, "session")]
@pytest.mark.asyncio
@@ -66,7 +69,7 @@ def slow_builder(_user, _db):
monkeypatch.setattr(catalog, "_ensure_external_cache_ready", cache_ready)
monkeypatch.setattr(catalog, "_get_catalog_items_sync", slow_builder)
- request = asyncio.create_task(catalog.get_catalog_items(user=object(), db=object()))
+ request = asyncio.create_task(catalog.get_catalog_items(user=object()))
ticks = 0
while not request.done():
ticks += 1
@@ -140,8 +143,8 @@ def builder(_user, _db):
monkeypatch.setattr(catalog, "_get_catalog_items_sync", builder)
requests = asyncio.gather(
- catalog.get_catalog_items(user=object(), db=object()),
- catalog.get_catalog_items(user=object(), db=object()),
+ catalog.get_catalog_items(user=object()),
+ catalog.get_catalog_items(user=object()),
)
deadline = asyncio.get_running_loop().time() + 2
while not requests.done():
diff --git a/src/backend/tests/api/test_chat_event_loop_performance.py b/src/backend/tests/api/test_chat_event_loop_performance.py
index 81f8cbc9..0d9b127e 100644
--- a/src/backend/tests/api/test_chat_event_loop_performance.py
+++ b/src/backend/tests/api/test_chat_event_loop_performance.py
@@ -6,7 +6,6 @@
from types import SimpleNamespace
import api.routes.v1.chats.questions as chat_questions
-import core.chat.operations.session_context as chat_session_context
import core.chat.service as chat_services_ChatService_module
import pytest
from core.llm.tools import user_questions
@@ -28,14 +27,13 @@ def get_session(self, *args):
return object()
monkeypatch.setattr(chat_services_ChatService_module, "ChatService", Service)
- monkeypatch.setattr(chat_session_context, 'release_request_session', lambda db: None)
async def pending(chat_id):
return []
monkeypatch.setattr(user_questions, "get_all_pending_shared", pending)
request = asyncio.create_task(
- chat_questions.get_pending_user_questions("chat", SimpleNamespace(user_id="u"), object())
+ chat_questions.get_pending_user_questions("chat", SimpleNamespace(user_id="u"))
)
for _ in range(100):
if entered.is_set():
diff --git a/src/backend/tests/api/test_chat_rerun_options.py b/src/backend/tests/api/test_chat_rerun_options.py
index b1e76d0d..c7a7c910 100644
--- a/src/backend/tests/api/test_chat_rerun_options.py
+++ b/src/backend/tests/api/test_chat_rerun_options.py
@@ -56,7 +56,6 @@ async def follow(*args, **kwargs):
"chat-1",
models.EditAndResendRequest(message_index=0, new_content="short", **options),
user=_user(),
- db=db,
)
)
else:
@@ -65,7 +64,6 @@ async def follow(*args, **kwargs):
"chat-1",
models.RegenerateRequest(message_index=1, **options),
user=_user(),
- db=db,
)
)
with Session() as db:
@@ -82,7 +80,7 @@ async def follow(*args, **kwargs):
with Session() as db:
asyncio.run(
reruns.regenerate_message(
- "chat-1", models.RegenerateRequest(message_index=1), user=_user(), db=db
+ "chat-1", models.RegenerateRequest(message_index=1), user=_user()
)
)
with Session() as db:
diff --git a/src/backend/tests/api/test_chat_stream_sequencer.py b/src/backend/tests/api/test_chat_stream_sequencer.py
index b4ea52fa..3589d2e8 100644
--- a/src/backend/tests/api/test_chat_stream_sequencer.py
+++ b/src/backend/tests/api/test_chat_stream_sequencer.py
@@ -100,7 +100,7 @@ def test_regenerate_busy_does_not_delete_existing_history(monkeypatch, tmp_path)
with pytest.raises(HTTPException) as raised:
asyncio.run(
chat_reruns.regenerate_message(
- "chat-1", chat_models.RegenerateRequest(message_index=1), user=_user(), db=db
+ "chat-1", chat_models.RegenerateRequest(message_index=1), user=_user()
)
)
@@ -153,7 +153,7 @@ async def empty_follow(_run_id, *, chat_id):
with Session() as db:
asyncio.run(
chat_reruns.regenerate_message(
- "chat-1", chat_models.RegenerateRequest(message_index=1), user=_user(), db=db
+ "chat-1", chat_models.RegenerateRequest(message_index=1), user=_user()
)
)
@@ -196,7 +196,6 @@ def test_edit_busy_does_not_delete_existing_history(monkeypatch, tmp_path):
"chat-1",
chat_models.EditAndResendRequest(message_index=2, new_content="new wording"),
user=_user(),
- db=db,
)
)
@@ -256,7 +255,6 @@ def capture_ctx(request, *_args, **_kwargs):
monkeypatch.setattr(chat_request_context, 'build_ctx', capture_ctx)
monkeypatch.setattr(chat_session_context, 'load_session_messages', lambda *_args: [])
- monkeypatch.setattr(chat_session_context, 'release_request_session', lambda _db: None)
monkeypatch.setattr(responses, "sse_response", lambda stream: stream)
monkeypatch.setattr(run_sse, "follow_run_as_sse", lambda *_a, **_k: None)
@@ -272,7 +270,6 @@ async def fake_start_run(**kwargs):
"chat-1",
chat_models.EditAndResendRequest(message_index=0, new_content="new wording"),
user=_user(),
- db=db,
)
)
diff --git a/src/backend/tests/api/test_file_open_browser_e2e.py b/src/backend/tests/api/test_file_open_browser_e2e.py
index e757d1e0..21b6a25f 100644
--- a/src/backend/tests/api/test_file_open_browser_e2e.py
+++ b/src/backend/tests/api/test_file_open_browser_e2e.py
@@ -22,6 +22,7 @@
from core.config import settings as settings_module
from core.db.engine import Base, get_db
from core.db.models import Artifact
+from tests.db_support import share_factory
@pytest.mark.skipif(sys.platform != "linux" or not shutil.which("node"),
@@ -98,6 +99,7 @@ def fixture_db():
app.dependency_overrides[get_current_user] = fixture_user
app.dependency_overrides[get_db] = fixture_db
+ share_factory(monkeypatch, sessions)
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
diff --git a/src/backend/tests/api/test_loop_cancel_scope.py b/src/backend/tests/api/test_loop_cancel_scope.py
index bdd7ed1e..2e24f2f2 100644
--- a/src/backend/tests/api/test_loop_cancel_scope.py
+++ b/src/backend/tests/api/test_loop_cancel_scope.py
@@ -4,6 +4,7 @@
import pytest
from api.routes.v1 import loops
+from tests.db_support import share_session
@pytest.mark.asyncio
@@ -28,7 +29,8 @@ async def cancel(run_id, *, user_id):
monkeypatch.setattr(loops.runtime, "get_active_run_for_chat", get_active)
monkeypatch.setattr(loops.runtime, "cancel_run", cancel)
db = SimpleNamespace(commit=lambda: observed.append(("commit",)))
- result = await loops.cancel_loop("loop", db=db, user=SimpleNamespace(user_id="owner"))
+ share_session(monkeypatch, db)
+ result = await loops.cancel_loop("loop", user=SimpleNamespace(user_id="owner"))
assert result["data"]["cancelled"] is True
assert observed == (
[("lookup", "chat", "owner"), ("cancel", "run", "owner")]
diff --git a/src/backend/tests/api/test_me_system.py b/src/backend/tests/api/test_me_system.py
index 097ae596..6cf047b5 100644
--- a/src/backend/tests/api/test_me_system.py
+++ b/src/backend/tests/api/test_me_system.py
@@ -246,7 +246,7 @@ def test_ce_excludes_shared_knowledge_base_connector(monkeypatch):
def test_whitelist_groups_exist_in_seed_configs():
"""Every whitelist group must have config items in SEED_CONFIGS (prevents the whitelist dangling after a seed regroup)."""
from api.routes.v1.me_system import PERSONAL_GROUPS
- from core.config.store.system_config_state import SEED_CONFIGS
+ from core.config.store.system_config_constants import SEED_CONFIGS
seed_groups = {group for _, _, _, _, group, _ in SEED_CONFIGS}
missing = set(PERSONAL_GROUPS) - seed_groups
diff --git a/src/backend/tests/api/test_model_reasoning_settings.py b/src/backend/tests/api/test_model_reasoning_settings.py
index ad80276a..88ec5fea 100644
--- a/src/backend/tests/api/test_model_reasoning_settings.py
+++ b/src/backend/tests/api/test_model_reasoning_settings.py
@@ -6,6 +6,7 @@
from api.deps import require_system_settings
from api.routes.v1.models import router
from core.db.engine import get_db
+from tests.db_support import share_session
def test_model_settings_save_read_and_reject_invalid_default(db_session, monkeypatch):
@@ -23,6 +24,7 @@ def reply(request):
app.include_router(router)
app.dependency_overrides[require_system_settings] = lambda: None
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
payload = {
"display_name": "Vision",
"provider_type": "chat",
diff --git a/src/backend/tests/api/test_page_config_backfill.py b/src/backend/tests/api/test_page_config_backfill.py
index 0ab104f0..1356148c 100644
--- a/src/backend/tests/api/test_page_config_backfill.py
+++ b/src/backend/tests/api/test_page_config_backfill.py
@@ -12,7 +12,7 @@
from unittest.mock import MagicMock
-from core.files.content.content_blocks_state import DEFAULT_PAGE_CONFIG
+from core.files.content.content_blocks_constants import DEFAULT_PAGE_CONFIG
from core.files.content.branding import backfill_navigation_entries
diff --git a/src/backend/tests/api/test_plugin_delete_isolation.py b/src/backend/tests/api/test_plugin_delete_isolation.py
index 664e9a5c..d8016281 100644
--- a/src/backend/tests/api/test_plugin_delete_isolation.py
+++ b/src/backend/tests/api/test_plugin_delete_isolation.py
@@ -7,6 +7,7 @@
from core.db.engine import get_db
from core.infra.exceptions import ResourceNotFoundError
from fastapi.responses import JSONResponse
+from tests.db_support import share_session
def test_foreign_local_plugin_delete_is_not_found(monkeypatch, db_session):
@@ -20,6 +21,7 @@ def foreign(*args):
user_id="other", user_center_id="other", username="Other"
)
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
@app.exception_handler(ResourceNotFoundError)
async def missing(request, exc):
diff --git a/src/backend/tests/api/test_project_environments.py b/src/backend/tests/api/test_project_environments.py
index 72b00d83..7422161f 100644
--- a/src/backend/tests/api/test_project_environments.py
+++ b/src/backend/tests/api/test_project_environments.py
@@ -5,6 +5,7 @@
from core.db.models import Project, UserShadow
from fastapi import FastAPI
from fastapi.testclient import TestClient
+from tests.db_support import share_session
def test_environment_endpoint_enforces_project_permissions_and_empty_custom(
@@ -30,6 +31,7 @@ def test_environment_endpoint_enforces_project_permissions_and_empty_custom(
app.include_router(router)
actor = {"id": "outsider"}
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id=actor["id"], user_center_id=actor["id"], username=actor["id"]
)
diff --git a/src/backend/tests/api/test_public_chats.py b/src/backend/tests/api/test_public_chats.py
index d24eb64a..516f6f9d 100644
--- a/src/backend/tests/api/test_public_chats.py
+++ b/src/backend/tests/api/test_public_chats.py
@@ -31,6 +31,7 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from starlette.requests import Request
+from tests.db_support import share_session
@pytest.fixture
@@ -83,6 +84,7 @@ def db(tmp_path, monkeypatch):
module, "decrypt_secret", lambda value: value.removeprefix("encrypted:")
)
monkeypatch.setattr("core.auth.public_identity.decrypt_secret", lambda value: value.removeprefix("encrypted:"))
+ share_session(monkeypatch, session)
yield session
session.close()
engine.dispose()
@@ -138,13 +140,13 @@ def test_link_lifecycle_and_visitor_isolation(db, kind, resource):
with pytest.raises(HTTPException):
authenticate_public_chat(request(link["token"], "b" * 48, path), db)
# Supplying the public credential must beat even an existing owner cookie.
- resolved = asyncio.run(get_current_user(allowed, None, db))
+ resolved = asyncio.run(get_current_user(allowed, None))
assert resolved.public_chat_visitor == first.public_chat_visitor
- optional = asyncio.run(require_auth(False)(allowed, db))
+ optional = asyncio.run(require_auth(False)(allowed))
assert optional.public_chat_visitor == first.public_chat_visitor
service.set_enabled(db, "owner", kind, resource, False)
with pytest.raises(HTTPException):
- asyncio.run(get_current_user(allowed, None, db))
+ asyncio.run(get_current_user(allowed, None))
assert service.get_link(db, "owner", kind, resource)["enabled"] is False
service.set_enabled(db, "owner", kind, resource, True)
assert resolve_public_user(db, link["token"], "a" * 48)
diff --git a/src/backend/tests/auth/test_auth_dependencies_off_loop.py b/src/backend/tests/auth/test_auth_dependencies_off_loop.py
new file mode 100644
index 00000000..025c595d
--- /dev/null
+++ b/src/backend/tests/auth/test_auth_dependencies_off_loop.py
@@ -0,0 +1,176 @@
+"""Authentication and console dependencies keep database work off the event loop."""
+
+import threading
+
+import pytest
+from fastapi import HTTPException
+from starlette.requests import Request
+
+from api import deps
+from core.auth import backend
+from core.harness.registry import authentication
+
+
+def _request(method="GET", headers=None):
+ raw = [(key.lower().encode(), value.encode()) for key, value in (headers or {}).items()]
+ return Request({"type": "http", "method": method, "path": "/v1/x", "headers": raw, "query_string": b""})
+
+
+@pytest.fixture
+def threads():
+ """Record which thread each patched database step ran on."""
+ seen = []
+
+ def record(result=None):
+ def step(*args, **kwargs):
+ seen.append(threading.get_ident())
+ return result(*args, **kwargs) if callable(result) else result
+
+ return step
+
+ record.seen = seen
+ return record
+
+
+def _off_loop(threads):
+ loop_thread = threading.get_ident()
+ assert threads.seen, "the database step never ran"
+ assert loop_thread not in threads.seen
+
+
+def _user(**changes):
+ return backend.UserContext(user_id="u1", user_center_id="c1", username="u", **changes)
+
+
+async def test_api_key_resolution_runs_in_a_worker_thread(monkeypatch, threads):
+ monkeypatch.setattr(backend, "_resolve_api_key_context", threads(_user(api_key_id="k")))
+ user = await backend.get_current_user(
+ _request(headers={"Authorization": "Bearer sk-jx-test"}), None
+ )
+ assert user.api_key_id == "k"
+ _off_loop(threads)
+
+
+async def test_public_chat_resolution_runs_in_a_worker_thread(monkeypatch, threads):
+ from core.auth import public_chat
+
+ monkeypatch.setattr(public_chat, "authenticate_public_chat", threads(_user()))
+ for dependency in (backend.get_current_user, backend.require_auth(False)):
+ request = _request(headers={"X-Public-Chat": "token"})
+ if dependency is backend.get_current_user:
+ await dependency(request, None)
+ else:
+ await dependency(request)
+ assert len(threads.seen) == 2
+ _off_loop(threads)
+
+
+async def test_unclaimed_requests_never_reach_the_host_resolver(monkeypatch, threads):
+ calls = []
+
+ async def counting_run_in_session(fn, *args, **kwargs):
+ calls.append(fn)
+ return fn(None, *args, **kwargs)
+
+ monkeypatch.setattr(backend, "run_in_session", counting_run_in_session)
+ state = authentication.registration_state()
+ monkeypatch.setattr(state, "claims", None)
+ monkeypatch.setattr(state, "resolver", None)
+ assert await backend._resolve_host_user(_request()) is None
+
+ monkeypatch.setattr(state, "claims", lambda request: False)
+ monkeypatch.setattr(state, "resolver", threads(_user()))
+ assert await backend._resolve_host_user(_request()) is None
+ assert calls == [] and threads.seen == []
+
+
+async def test_a_claimed_request_is_resolved_in_a_worker_thread(monkeypatch, threads):
+ state = authentication.registration_state()
+ monkeypatch.setattr(state, "claims", lambda request: request.headers.get("x-host") == "1")
+ monkeypatch.setattr(state, "resolver", threads(_user()))
+ assert (await backend._resolve_host_user(_request(headers={"X-Host": "1"}))).user_id == "u1"
+ _off_loop(threads)
+
+
+def test_desktop_bridge_claims_only_requests_with_its_credential(monkeypatch):
+ from features.desktop.auth import bridge
+
+ monkeypatch.delenv(bridge.BRIDGE_SECRET_ENV, raising=False)
+ assert not bridge.claims_request(_request(headers={bridge.BRIDGE_SECRET_HEADER: "s"}))
+ monkeypatch.setenv(bridge.BRIDGE_SECRET_ENV, "s")
+ assert not bridge.claims_request(_request())
+ assert bridge.claims_request(_request(headers={bridge.BRIDGE_SECRET_HEADER: "s"}))
+
+
+class _Audit:
+ def __init__(self, seen):
+ self.seen = seen
+
+ def __call__(self, db):
+ return self
+
+ def log_denial(self, **kwargs):
+ self.seen.append(threading.get_ident())
+
+
+async def test_console_token_grant_is_audited_in_a_worker_thread(monkeypatch, threads):
+ monkeypatch.setenv("ADMIN_TOKEN", "admin-token")
+ monkeypatch.setattr(deps, "_audit_grant", threads())
+ await deps.require_admin(
+ _request("POST", {"Authorization": "Bearer admin-token"}), "Bearer admin-token"
+ )
+ _off_loop(threads)
+
+
+async def test_console_session_flag_and_denial_run_in_a_worker_thread(monkeypatch, threads):
+ monkeypatch.setenv("CONFIG_TOKEN", "config-token")
+
+ async def session_user(request):
+ return "u1"
+
+ monkeypatch.setattr(deps, "_resolve_session_user_id", session_user)
+ monkeypatch.setattr(deps, "_user_has_meta_flag", threads(False))
+ monkeypatch.setattr(deps, "AuditLogRepository", _Audit(threads.seen))
+ with pytest.raises(HTTPException) as error:
+ await deps.require_config(_request(headers={"Authorization": "Bearer wrong"}), "Bearer wrong")
+ assert error.value.status_code == 401
+ assert len(threads.seen) == 2 # flag check + denial audit
+ _off_loop(threads)
+
+
+async def test_prompt_snapshot_gate_audits_denial_in_a_worker_thread(monkeypatch, threads):
+ monkeypatch.setenv("ADMIN_TOKEN", "admin-token")
+ monkeypatch.setattr(deps, "AuditLogRepository", _Audit(threads.seen))
+ with pytest.raises(HTTPException):
+ await deps.require_admin_or_config(_request(), "Bearer wrong")
+ _off_loop(threads)
+
+
+async def test_delegated_gates_check_capabilities_in_a_worker_thread(monkeypatch, threads):
+ monkeypatch.setenv("CONFIG_TOKEN", "")
+ monkeypatch.setenv("ADMIN_TOKEN", "")
+ monkeypatch.setattr(deps, "user_can_manage_system_settings", threads(True))
+ monkeypatch.setattr(deps, "user_can_manage_ontology_governance", threads(True))
+ monkeypatch.setattr(deps, "_audit_grant", threads())
+ assert await deps.require_system_settings(_request(), _user()) == "u1"
+ assert await deps.require_ontology_governance(_request(), _user()) == "u1"
+ _off_loop(threads)
+
+
+async def test_delegated_gate_denies_in_a_worker_thread(monkeypatch, threads):
+ monkeypatch.setenv("CONFIG_TOKEN", "")
+ monkeypatch.setattr(deps, "user_can_manage_system_settings", threads(False))
+ monkeypatch.setattr(deps, "AuditLogRepository", _Audit(threads.seen))
+ with pytest.raises(HTTPException) as error:
+ await deps.require_system_settings(_request(), _user())
+ assert error.value.status_code == 403
+ _off_loop(threads)
+
+
+async def test_super_admin_gate_runs_in_a_worker_thread(monkeypatch, threads):
+ monkeypatch.setenv("ADMIN_TOKEN", "")
+ monkeypatch.setattr(deps, "AuditLogRepository", _Audit(threads.seen))
+ with pytest.raises(HTTPException) as error:
+ await deps.require_super_admin(_request())
+ assert error.value.status_code == 401
+ _off_loop(threads)
diff --git a/src/backend/tests/auth/test_bearer_identity.py b/src/backend/tests/auth/test_bearer_identity.py
new file mode 100644
index 00000000..6a778141
--- /dev/null
+++ b/src/backend/tests/auth/test_bearer_identity.py
@@ -0,0 +1,180 @@
+"""Bearer tokens are verified off the event loop and once per token per window."""
+
+import asyncio
+from dataclasses import replace
+
+import httpx
+import pytest
+from fastapi import HTTPException
+
+from core.auth import backend, bearer
+from core.config.settings import settings
+from core.db.models import UserShadow
+from core.infra.ephemeral import LocalEphemeralState
+from tests.db_support import share_session
+
+_AsyncClient = httpx.AsyncClient
+
+
+def _serve(monkeypatch, handler):
+ transport = httpx.MockTransport(handler)
+ monkeypatch.setattr(
+ bearer.httpx, "AsyncClient", lambda **kwargs: _AsyncClient(transport=transport, **kwargs)
+ )
+
+
+@pytest.fixture
+def auth_settings():
+ """Swap the frozen auth settings for one test."""
+ original = settings.auth
+
+ def apply(**changes):
+ object.__setattr__(settings, "auth", replace(original, **changes))
+
+ yield apply
+ object.__setattr__(settings, "auth", original)
+
+
+@pytest.fixture
+def user_center(monkeypatch, auth_settings):
+ """A remote-mode user center that accepts ``good`` and counts its requests."""
+ calls = []
+
+ def respond(request):
+ calls.append(request.headers["authorization"])
+ token = request.headers["authorization"]
+ if request.url.path == "/verify" and token == "Bearer moved":
+ return httpx.Response(307, headers={"location": "http://uc/v2/verify"})
+ if token in ("Bearer good", "Bearer moved"):
+ return httpx.Response(
+ 200, json={"user_center_id": "uc-1", "username": "alice", "secret": "s"}
+ )
+ if token == "Bearer nobody":
+ return httpx.Response(200, json={})
+ return httpx.Response(401)
+
+ _serve(monkeypatch, respond)
+ auth_settings(mode="remote", api_url="http://uc", retry_count=2)
+ state = LocalEphemeralState()
+ monkeypatch.setattr(bearer, "get_ephemeral_state", lambda: state)
+ return calls
+
+
+@pytest.fixture
+def login_audits(monkeypatch):
+ """Login audit actions written through the backend, in order (edition independent)."""
+ actions = []
+
+ class Recorder:
+ def __init__(self, db):
+ pass
+
+ def create(self, entry):
+ # audit_logs only accepts these statuses, and user_id must name a real user.
+ assert entry["status"] in ("success", "failure", "error")
+ assert entry["user_id"] is None or entry["user_id"].startswith("user_")
+ actions.append(entry["action"])
+
+ monkeypatch.setattr(backend, "AuditLogRepository", Recorder)
+ return actions
+
+
+def test_verified_token_is_reused_without_asking_again(user_center):
+ first = asyncio.run(bearer.verify_bearer("good"))
+ second = asyncio.run(bearer.verify_bearer("good"))
+
+ alice = {"user_center_id": "uc-1", "username": "alice", "email": None, "avatar_url": None}
+ assert first == (alice, True)
+ assert second == (alice, False)
+ assert user_center == ["Bearer good"]
+
+
+def test_only_the_identity_fields_reach_the_shared_store(user_center):
+ asyncio.run(bearer.verify_bearer("good"))
+ state = bearer.get_ephemeral_state()
+ (key,) = asyncio.run(state.keys("auth:bearer:"))
+ assert "good" not in key
+ assert "secret" not in asyncio.run(state.get(key))
+
+
+def test_redirected_verify_endpoint_is_followed(user_center):
+ identity, checked = asyncio.run(bearer.verify_bearer("moved"))
+ assert identity["user_center_id"] == "uc-1" and checked
+ assert user_center == ["Bearer moved", "Bearer moved"]
+
+
+def test_reply_naming_nobody_is_a_rejection(user_center):
+ assert asyncio.run(bearer.verify_bearer("nobody")) == (None, True)
+ assert asyncio.run(bearer.verify_bearer("nobody")) == (None, True)
+
+
+def test_rejected_token_is_not_remembered(user_center):
+ assert asyncio.run(bearer.verify_bearer("bad")) == (None, True)
+ assert asyncio.run(bearer.verify_bearer("bad")) == (None, True)
+ assert user_center == ["Bearer bad", "Bearer bad"]
+
+
+def test_unreachable_user_center_is_a_502(monkeypatch, user_center):
+ def fail(request):
+ raise httpx.ConnectError("down", request=request)
+
+ _serve(monkeypatch, fail)
+ with pytest.raises(HTTPException) as caught:
+ asyncio.run(bearer.verify_bearer("good"))
+ assert caught.value.status_code == 502
+
+
+def test_login_is_audited_once_per_verification(db_session, user_center, login_audits, monkeypatch):
+ share_session(monkeypatch, db_session)
+ for _ in range(3):
+ user = asyncio.run(backend._resolve_bearer_user("good", audit=True))
+ assert user.username == "alice"
+ assert db_session.query(UserShadow).filter_by(user_center_id="uc-1").count() == 1
+ assert login_audits == ["auth.login.success"]
+
+ with pytest.raises(HTTPException) as caught:
+ asyncio.run(backend._resolve_bearer_user("bad", audit=True))
+ assert caught.value.status_code == 401
+ assert login_audits == ["auth.login.success", "auth.login.failed"]
+
+
+def test_mock_mode_never_calls_out_or_audits(db_session, auth_settings, login_audits, monkeypatch):
+ share_session(monkeypatch, db_session)
+ auth_settings(mode="mock")
+ user = asyncio.run(backend._resolve_bearer_user("bob", audit=True))
+ assert user.user_center_id == "bob"
+ assert login_audits == []
+
+
+def test_concurrent_requests_share_one_verification(user_center):
+ async def burst():
+ return await asyncio.gather(*[bearer.verify_bearer("good") for _ in range(10)])
+
+ results = asyncio.run(burst())
+ assert user_center == ["Bearer good"]
+ assert [checked for _, checked in results].count(True) == 1
+ assert all(identity["user_center_id"] == "uc-1" for identity, _ in results)
+
+
+def test_cancelled_first_caller_does_not_cancel_the_shared_verification(monkeypatch, user_center):
+ release = asyncio.Event()
+ ask = bearer._ask_user_center
+
+ async def slow_ask(token):
+ await release.wait()
+ return await ask(token)
+
+ monkeypatch.setattr(bearer, "_ask_user_center", slow_ask)
+
+ async def scenario():
+ first = asyncio.create_task(bearer.verify_bearer("good"))
+ await asyncio.sleep(0)
+ second = asyncio.create_task(bearer.verify_bearer("good"))
+ await asyncio.sleep(0)
+ first.cancel()
+ release.set()
+ return await second
+
+ identity, checked = asyncio.run(scenario())
+ assert identity["user_center_id"] == "uc-1" and checked is False
+ assert user_center == ["Bearer good"]
diff --git a/src/backend/tests/browser_automation_server.py b/src/backend/tests/browser_automation_server.py
index 9eedb296..f586cc5a 100644
--- a/src/backend/tests/browser_automation_server.py
+++ b/src/backend/tests/browser_automation_server.py
@@ -24,7 +24,7 @@
from core.capabilities.plugins.management.installation import install_plugin
from api.routes.v1 import plugin_resources
from features.workbench.api import plugin_ui
-from mcp.client.streamable_http import streamablehttp_client
+from mcp.client.streamable_http import streamable_http_client
from mcp import ClientSession
Base.metadata.create_all(engine)
@@ -71,7 +71,7 @@ def health():
@app.post("/test/tool/{name}")
async def tool(name: str, arguments: dict):
from core.llm.factory.tools.mcp_config import inject_runtime_headers as _inject_runtime_headers
- from core.llm.mcp.pool import make_client
+ from core.llm.mcp.client import make_client
from core.capabilities.catalog.resolver import _owned_enabled_ids
from features.desktop.bridge.gateway import cloud_gateway_mcp_configs
sid = modern["components"]["mcp"][0]
@@ -83,10 +83,9 @@ async def tool(name: str, arguments: dict):
assert sid in choices[0].chosen
config = _inject_runtime_headers(local_plugin_runtime.configs("browser-owner"),
current_user_id="browser-owner", chat_id="browser-chat")[sid]
- client = make_client(sid, config, is_stateful=False)
- method = await client.get_tool(name)
- result = await method(**arguments)
- if result.metadata.get("is_error") or result.metadata.get("isError"):
+ async with make_client(sid, config) as client:
+ result = await client.call_tool(name, arguments)
+ if result.is_error:
return __import__("fastapi").responses.JSONResponse(status_code=409, content={"error": result.content[0].text})
try:
return json.loads(result.content[0].text)
@@ -99,7 +98,7 @@ async def cloud_proof():
from features.desktop.capability.mcp import invoke_gateway_tool
from features.desktop.capability import mcp as gateway
gateway.guard_capability_content = lambda uid, data, **kw: data
- async with streamablehttp_client(os.environ["BROWSER_E2E_MCP"]) as (read, write, _):
+ async with streamable_http_client(os.environ["BROWSER_E2E_MCP"]) as (read, write, _):
async with ClientSession(read, write) as client:
await client.initialize()
tools = {t.name: t.model_dump() for t in (await client.list_tools()).tools}
@@ -196,7 +195,7 @@ async def synced_tool(name: str, arguments: dict):
from features.desktop.capability.entities import _plugin_files
from core.capabilities.manifest_protocol import entity_content_hash
from core.llm.factory.tools.mcp_config import inject_runtime_headers as _inject_runtime_headers
- from core.llm.mcp.pool import make_client
+ from core.llm.mcp.client import make_client
from features.desktop.bridge import client as bridge
profile = "browser-cloud-fixture"
with SessionLocal() as db:
@@ -214,7 +213,7 @@ async def synced_tool(name: str, arguments: dict):
skill_account.account_authorized_for = lambda uid: uid == "browser-owner"
device_catalog._profile = lambda: profile
# The cloud side supplies its authorized schema; its URL must never be used.
- async with streamablehttp_client(os.environ["BROWSER_E2E_MCP"]) as (read, write, _):
+ async with streamable_http_client(os.environ["BROWSER_E2E_MCP"]) as (read, write, _):
async with ClientSession(read, write) as client:
await client.initialize()
schemas = [t.model_dump() for t in (await client.list_tools()).tools]
@@ -225,12 +224,12 @@ async def synced_tool(name: str, arguments: dict):
assert cfg["execution_scope"] == "local" and "gateway_invoke_url" not in cfg
chat_id = arguments.pop("_chat_id", "browser-chat")
cfg = _inject_runtime_headers({sid: cfg}, current_user_id="browser-owner", chat_id=chat_id)[sid]
- client = make_client(sid, cfg, is_stateful=False)
- result = await (await client.get_tool(name))(**arguments)
+ async with make_client(sid, cfg) as client:
+ result = await client.call_tool(name, arguments)
text = result.content[0].text
if "conversation_unavailable" in text:
return {"error": "conversation_unavailable"}
- if result.metadata.get("is_error") or result.metadata.get("isError"):
+ if result.is_error:
raise HTTPException(409, text)
payload = json.loads(text)
if name == "browser_open":
diff --git a/src/backend/tests/ce_release/test_ce_release_regressions.py b/src/backend/tests/ce_release/test_ce_release_regressions.py
index f5e84f68..7e79466e 100644
--- a/src/backend/tests/ce_release/test_ce_release_regressions.py
+++ b/src/backend/tests/ce_release/test_ce_release_regressions.py
@@ -29,89 +29,92 @@ def initialized_ce_database():
return user_id
-def test_ce_login_ticket_exchange_and_session_check(initialized_ce_database):
+@pytest.fixture(scope="module")
+def ce_client(initialized_ce_database):
+ # A deployed app owns one event loop for its entire lifespan.
from api.app import app
with TestClient(app) as client:
- login = client.post(
- "/login",
- data={"username": "admin", "password": "admin", "redirect": "/"},
- follow_redirects=False,
- )
- assert login.status_code == 303, login.text
- ticket = parse_qs(urlparse(login.headers["location"]).query)["ticket"][0]
+ yield client
- exchange = client.post("/v1/auth/ticket/exchange", json={"code": ticket})
- assert exchange.status_code == 200, exchange.text
- assert exchange.json()["data"]["user_id"] == initialized_ce_database
- session = client.get("/v1/auth/session/check")
- assert session.status_code == 200, session.text
- assert session.json()["data"]["username"] == "admin"
+def test_ce_login_ticket_exchange_and_session_check(initialized_ce_database, ce_client):
+ client = ce_client
+ login = client.post(
+ "/login",
+ data={"username": "admin", "password": "admin", "redirect": "/"},
+ follow_redirects=False,
+ )
+ assert login.status_code == 303, login.text
+ ticket = parse_qs(urlparse(login.headers["location"]).query)["ticket"][0]
+ exchange = client.post("/v1/auth/ticket/exchange", json={"code": ticket})
+ assert exchange.status_code == 200, exchange.text
+ assert exchange.json()["data"]["user_id"] == initialized_ce_database
-def test_ce_api_key_crud_is_reachable_from_personal_settings(initialized_ce_database):
- from api.app import app
+ session = client.get("/v1/auth/session/check")
+ assert session.status_code == 200, session.text
+ assert session.json()["data"]["username"] == "admin"
- with TestClient(app) as client:
- login = client.post(
- "/login",
- data={"username": "admin", "password": "admin", "redirect": "/"},
- follow_redirects=False,
- )
- assert login.status_code == 303, login.text
- ticket = parse_qs(urlparse(login.headers["location"]).query)["ticket"][0]
- exchange = client.post("/v1/auth/ticket/exchange", json={"code": ticket})
- assert exchange.status_code == 200, exchange.text
-
- empty_or_existing = client.get("/v1/me/api-keys")
- assert empty_or_existing.status_code == 200, empty_or_existing.text
-
- created = client.post(
- "/v1/me/api-keys",
- json={
- "name": "desktop-test",
- "expires_in_days": 30,
- # The shared frontend always sends this field. CE intentionally
- # ignores it because it has no enterprise model gateway.
- "for_gateway": False,
- },
- )
- assert created.status_code == 201, created.text
- created_data = created.json()["data"]
- key_id = created_data["id"]
- assert created_data["api_key"].startswith("sk-jx-")
- revealed = client.get(f"/v1/me/api-keys/{key_id}/reveal")
- assert revealed.status_code == 200, revealed.text
- assert revealed.json()["data"]["api_key"] == created_data["api_key"]
+def test_ce_api_key_crud_is_reachable_from_personal_settings(initialized_ce_database, ce_client):
+ client = ce_client
+ login = client.post(
+ "/login",
+ data={"username": "admin", "password": "admin", "redirect": "/"},
+ follow_redirects=False,
+ )
+ assert login.status_code == 303, login.text
+ ticket = parse_qs(urlparse(login.headers["location"]).query)["ticket"][0]
+ exchange = client.post("/v1/auth/ticket/exchange", json={"code": ticket})
+ assert exchange.status_code == 200, exchange.text
+
+ empty_or_existing = client.get("/v1/me/api-keys")
+ assert empty_or_existing.status_code == 200, empty_or_existing.text
+
+ created = client.post(
+ "/v1/me/api-keys",
+ json={
+ "name": "desktop-test",
+ "expires_in_days": 30,
+ # The shared frontend always sends this field. CE intentionally
+ # ignores it because it has no enterprise model gateway.
+ "for_gateway": False,
+ },
+ )
+ assert created.status_code == 201, created.text
+ created_data = created.json()["data"]
+ key_id = created_data["id"]
+ assert created_data["api_key"].startswith("sk-jx-")
- disabled = client.patch(f"/v1/me/api-keys/{key_id}", json={"enabled": False})
- assert disabled.status_code == 200, disabled.text
- assert disabled.json()["data"]["enabled"] is False
+ revealed = client.get(f"/v1/me/api-keys/{key_id}/reveal")
+ assert revealed.status_code == 200, revealed.text
+ assert revealed.json()["data"]["api_key"] == created_data["api_key"]
- revoked = client.delete(f"/v1/me/api-keys/{key_id}")
- assert revoked.status_code == 200, revoked.text
+ disabled = client.patch(f"/v1/me/api-keys/{key_id}", json={"enabled": False})
+ assert disabled.status_code == 200, disabled.text
+ assert disabled.json()["data"]["enabled"] is False
+ revoked = client.delete(f"/v1/me/api-keys/{key_id}")
+ assert revoked.status_code == 200, revoked.text
-def test_ce_mcp_marketplace_is_registered_and_returns_items(initialized_ce_database):
- from api.app import app
- with TestClient(app) as client:
- login = client.post(
- "/login",
- data={"username": "admin", "password": "admin", "redirect": "/"},
- follow_redirects=False,
- )
- assert login.status_code == 303, login.text
- ticket = parse_qs(urlparse(login.headers["location"]).query)["ticket"][0]
- exchange = client.post("/v1/auth/ticket/exchange", json={"code": ticket})
- assert exchange.status_code == 200, exchange.text
+def test_ce_mcp_marketplace_is_registered_and_returns_items(initialized_ce_database, ce_client):
+ client = ce_client
+ login = client.post(
+ "/login",
+ data={"username": "admin", "password": "admin", "redirect": "/"},
+ follow_redirects=False,
+ )
+ assert login.status_code == 303, login.text
+ ticket = parse_qs(urlparse(login.headers["location"]).query)["ticket"][0]
+ exchange = client.post("/v1/auth/ticket/exchange", json={"code": ticket})
+ assert exchange.status_code == 200, exchange.text
- response = client.get("/v1/mcp-market/items")
+ response = client.get("/v1/mcp-market/items")
- assert response.status_code == 200, response.text
- assert response.json()["data"]
+ assert response.status_code == 200, response.text
+ assert response.json()["data"]
def test_ce_registers_all_local_auth_routes():
@@ -172,7 +175,8 @@ def test_ce_self_service_skill_and_mcp_do_not_import_admin_routes(
assert "api.routes.v1.admin_" not in inspect.getsource(me_capabilities)
- async def probe_ok(row, db):
+ async def probe_ok(row, *, validate):
+ assert validate is True
row.tools_json = [{"name": "example_tool", "description": "", "inputSchema": {}}]
return True, ""
@@ -180,6 +184,10 @@ async def validate_ok(url, *, allow_private_network, require_https):
assert allow_private_network is False
assert require_https is False
+ monkeypatch.setattr(
+ me_capabilities, "settings",
+ SimpleNamespace(server=SimpleNamespace(mcp_self_service_allow_private_network=False)),
+ )
monkeypatch.setattr(me_capabilities, "probe_mcp_connectivity", probe_ok)
monkeypatch.setattr(me_capabilities, "validate_remote_mcp_url", validate_ok)
user = SimpleNamespace(user_id=initialized_ce_database)
@@ -199,7 +207,7 @@ async def validate_ok(url, *, allow_private_network, require_https):
display_name="CE release MCP",
url="https://example.invalid/mcp",
)
- asyncio.run(me_capabilities.create_my_mcp_server(mcp_body, user, db))
+ asyncio.run(me_capabilities.create_my_mcp_server(mcp_body, user))
mcp = (
db.query(AdminMcpServer)
.filter(AdminMcpServer.owner_user_id == initialized_ce_database)
diff --git a/src/backend/tests/chat/test_user_questions_api.py b/src/backend/tests/chat/test_user_questions_api.py
index fc4e6f02..dfa03e41 100644
--- a/src/backend/tests/chat/test_user_questions_api.py
+++ b/src/backend/tests/chat/test_user_questions_api.py
@@ -3,7 +3,6 @@
import asyncio
from types import SimpleNamespace
-import core.chat.operations.session_context as chat_session_context
import core.auth.backend as auth_backend
import core.db.engine as db_engine
import core.chat.service as chat_services_ChatService_module
@@ -39,7 +38,6 @@ def get_session(self, chat_id, user_id):
return None
monkeypatch.setattr(chat_services_ChatService_module, "ChatService", FakeChatService)
- monkeypatch.setattr(chat_session_context, 'release_request_session', lambda db: None)
app = FastAPI()
app.include_router(chats.router)
app.dependency_overrides[auth_backend.get_current_user] = lambda: SimpleNamespace(
diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py
index 2c9a38fc..aac533b9 100644
--- a/src/backend/tests/conftest.py
+++ b/src/backend/tests/conftest.py
@@ -4,18 +4,41 @@
import pytest
import os
+import shutil
import tempfile
+
+# Every pytest session gets its own SQLite files. A fixed name in the system
+# temp dir is shared by concurrent runs (other checkouts, other sessions), which
+# drop each other's tables mid-test. Set before core.config reads the env;
+# explicit DATABASE_URL / SQLITE_FALLBACK_URL / TEST_DATABASE_URL still win.
+SESSION_DB_DIR = tempfile.mkdtemp(prefix=f"hugagent-tests-{os.getpid()}-")
+DATABASE_URL_PRESET = "DATABASE_URL" in os.environ
+os.environ.setdefault("DATABASE_URL", f"sqlite:///{SESSION_DB_DIR}/hugagent.db")
+os.environ.setdefault("SQLITE_FALLBACK_URL", f"sqlite:///{SESSION_DB_DIR}/hugagent_dev.db")
+
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from core.db.engine import Base
+
+def pytest_unconfigure(config):
+ from core.harness.observation_writer import flush_observations
+
+ flush_observations()
+ shutil.rmtree(SESSION_DB_DIR, ignore_errors=True)
+
+
+@pytest.fixture(scope="session")
+def session_db_dir():
+ return SESSION_DB_DIR
+
+
@pytest.fixture(scope="session")
def test_database_url():
- """Test database URL."""
- # Default to the system temp dir: a relative path would leave a stray test.db in pytest's CWD (repo root, etc.)
- return os.getenv("TEST_DATABASE_URL", f"sqlite:///{tempfile.gettempdir()}/hugagent_test.db")
+ """Test database URL, private to this pytest session unless TEST_DATABASE_URL is set."""
+ return os.getenv("TEST_DATABASE_URL", f"sqlite:///{SESSION_DB_DIR}/hugagent_test.db")
@pytest.fixture(scope="function")
diff --git a/src/backend/tests/content/security_selftest.py b/src/backend/tests/content/security_selftest.py
index c036e444..f93e62c3 100755
--- a/src/backend/tests/content/security_selftest.py
+++ b/src/backend/tests/content/security_selftest.py
@@ -27,7 +27,8 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.db.engine import Base, get_db
-from core.auth.backend import AuthService, get_current_user
+from core.auth.backend import get_current_user
+from core.auth.bearer import MOCK_TOKEN, mock_identity
from core.infra.data_masking import (
mask_phone, mask_email, mask_api_key, mask_password,
mask_sensitive_data, mask_log_data
@@ -74,27 +75,12 @@ class TestAuthentication:
"""Test authentication mechanisms."""
def test_mock_auth_mode(self):
- """Test that mock authentication works in development."""
- os.environ["AUTH_MODE"] = "mock"
- auth_service = AuthService()
-
- user_info = auth_service.verify_token("any_token")
+ """Mock mode maps the default token to the configured dev user and any other token to itself."""
+ user_info = mock_identity(MOCK_TOKEN)
assert "user_center_id" in user_info
- assert "username" in user_info
assert user_info["username"] == os.getenv("AUTH_MOCK_USERNAME", "Developer")
-
- def test_auth_mode_detection(self):
- """Test authentication mode detection."""
- # Test mock mode
- os.environ["AUTH_MODE"] = "mock"
- auth_service = AuthService()
- assert auth_service.auth_mode == "mock"
-
- # Test remote mode
- os.environ["AUTH_MODE"] = "remote"
- auth_service = AuthService()
- assert auth_service.auth_mode == "remote"
+ assert mock_identity("any_token")["user_center_id"] == "any_token"
class TestAuthorization:
diff --git a/src/backend/tests/content/test_extra_files.py b/src/backend/tests/content/test_extra_files.py
index b5b02fc5..b239e464 100644
--- a/src/backend/tests/content/test_extra_files.py
+++ b/src/backend/tests/content/test_extra_files.py
@@ -371,7 +371,7 @@ def test_loader_get_skill_dir_db_materializes():
def test_register_skills_to_toolkit():
from agentscope.tool import Toolkit
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.capabilities.skills.loader import MultiSourceSkillLoader
with tempfile.TemporaryDirectory() as tmpdir:
@@ -389,9 +389,7 @@ def test_register_skills_to_toolkit():
backend = CompositeBackend([FilesystemBackend(root_dir=tmpdir, source_name="test")])
loader = MultiSourceSkillLoader(backend=backend)
- # AgentScope 2.0: register_skills_to_toolkit writes into ToolCollector (register_agent_skill);
- # the collected skill directories live in collector.skill_loaders, passed to Toolkit(skills_or_loaders=).
- c = ToolCollector()
+ c = ToolAssembly()
n = loader.register_skills_to_toolkit(c)
assert n == 1
assert len(c.skill_loaders) == 1
diff --git a/src/backend/tests/content/test_local_file_location.py b/src/backend/tests/content/test_local_file_location.py
index 801e6ab5..4293af3a 100644
--- a/src/backend/tests/content/test_local_file_location.py
+++ b/src/backend/tests/content/test_local_file_location.py
@@ -15,6 +15,7 @@
from core.config import settings as settings_module
from core.db.engine import Base, get_db
from core.db.models import Artifact, Project, UserShadow
+from tests.db_support import share_session
@pytest.fixture
@@ -66,6 +67,7 @@ def local_files(tmp_path, monkeypatch):
app.include_router(files.router)
app.include_router(projects.router)
app.dependency_overrides[get_db] = lambda: db
+ share_session(monkeypatch, db)
def authenticate(request: Request):
user_id = request.headers.get("x-test-user")
diff --git a/src/backend/tests/db_support.py b/src/backend/tests/db_support.py
new file mode 100644
index 00000000..f2897805
--- /dev/null
+++ b/src/backend/tests/db_support.py
@@ -0,0 +1,43 @@
+"""Point the sessions async code opens at a test's database.
+
+Async routes and workers never take ``Depends(get_db)``; their database steps
+run through ``core.db.threads.run_in_session``, which opens
+``core.db.engine.SessionLocal``. A test that overrides ``get_db`` overrides
+this too, so both kinds of route see the same data.
+"""
+
+from sqlalchemy.orm import sessionmaker
+
+
+class _Borrowed:
+ """The test's session, handed out as if new; closing it leaves the test's copy open."""
+
+ def __init__(self, session):
+ self._session = session
+
+ def __enter__(self):
+ return self._session
+
+ def __exit__(self, *exc):
+ return False
+
+ def close(self):
+ pass
+
+ def __getattr__(self, name):
+ return getattr(self._session, name)
+
+
+def share_session(monkeypatch, session):
+ """Every step reuses the test's own session, left open like the ``get_db`` override."""
+ monkeypatch.setattr("core.db.engine.SessionLocal", lambda: _Borrowed(session))
+
+
+def share_factory(monkeypatch, factory):
+ """Every step opens its own session from the test's factory."""
+ monkeypatch.setattr("core.db.engine.SessionLocal", factory)
+
+
+def share_bind(monkeypatch, bind):
+ """Every step opens its own session on the test's engine."""
+ share_factory(monkeypatch, sessionmaker(bind=bind))
diff --git a/src/backend/tests/evolution/test_candidate_reasoning.py b/src/backend/tests/evolution/test_candidate_reasoning.py
index 3072fed4..283113cc 100644
--- a/src/backend/tests/evolution/test_candidate_reasoning.py
+++ b/src/backend/tests/evolution/test_candidate_reasoning.py
@@ -14,7 +14,7 @@
hashed payload carried the evidence count.
"""
from core.evolution.profiles import agent_profile as agent_profile_module
-from core.evolution.profiles import agent_profile_state as agent_profile_state_module
+from core.evolution.profiles import agent_profile_constants as agent_profile_state_module
import pytest
diff --git a/src/backend/tests/evolution/test_personal_approval.py b/src/backend/tests/evolution/test_personal_approval.py
index f889d2a9..7ff200cd 100644
--- a/src/backend/tests/evolution/test_personal_approval.py
+++ b/src/backend/tests/evolution/test_personal_approval.py
@@ -8,7 +8,7 @@
"""
from core.evolution.cycle import activation as activation_module
from core.evolution.profiles import personal_review as personal_review_module
-from core.evolution.profiles import user_settings_state as user_settings_state_module
+from core.evolution.profiles import user_settings_constants as user_settings_state_module
import pytest
from sqlalchemy import create_engine
diff --git a/src/backend/tests/evolution/test_personal_cycle_and_prefs.py b/src/backend/tests/evolution/test_personal_cycle_and_prefs.py
index 7650850f..deb8ab3b 100644
--- a/src/backend/tests/evolution/test_personal_cycle_and_prefs.py
+++ b/src/backend/tests/evolution/test_personal_cycle_and_prefs.py
@@ -74,7 +74,7 @@ def test_an_unknown_user_gets_nothing(db):
def test_personal_support_threshold_is_lower_than_fleet_wide():
- from core.evolution.cycle.promotion_state import MIN_SUPPORT
+ from core.evolution.cycle.promotion_constants import MIN_SUPPORT
# One person's blast radius is one person; demanding fleet-scale evidence
# would mean personal evolution never fires.
diff --git a/src/backend/tests/evolution/test_policies_and_memory_ops.py b/src/backend/tests/evolution/test_policies_and_memory_ops.py
index 76d2e351..a02ea641 100644
--- a/src/backend/tests/evolution/test_policies_and_memory_ops.py
+++ b/src/backend/tests/evolution/test_policies_and_memory_ops.py
@@ -7,7 +7,7 @@
bounded — against the asset that actually governs behaviour.
"""
from core.evolution.profiles import agent_profile as agent_profile_module
-from core.evolution.profiles import agent_profile_state as agent_profile_state_module
+from core.evolution.profiles import agent_profile_constants as agent_profile_state_module
from core.evolution.profiles import profile_selection as profile_selection_module
import pytest
diff --git a/src/backend/tests/evolution/test_promotion_chain.py b/src/backend/tests/evolution/test_promotion_chain.py
index 7e0e6a1a..1eff325f 100644
--- a/src/backend/tests/evolution/test_promotion_chain.py
+++ b/src/backend/tests/evolution/test_promotion_chain.py
@@ -4,7 +4,7 @@
the tests cover both directions: what must be promoted, and — more importantly —
what must not be.
"""
-from core.evolution.cycle import patterns as patterns_module, promotion as promotion_module, promotion_state as promotion_state_module, retirement as retirement_module, promotion as P
+from core.evolution.cycle import patterns as patterns_module, promotion as promotion_module, promotion_constants as promotion_state_module, retirement as retirement_module, promotion as P
import pytest
diff --git a/src/backend/tests/evolution/test_react_intervention.py b/src/backend/tests/evolution/test_react_intervention.py
index 25104d4a..3429714e 100644
--- a/src/backend/tests/evolution/test_react_intervention.py
+++ b/src/backend/tests/evolution/test_react_intervention.py
@@ -14,7 +14,7 @@
import pytest
from agentscope.message import ToolResultState
-from core.evolution.profiles.agent_profile_state import ACTION_CHANGE_STRATEGY, SIGNAL_REPEATED_ACTIONS, SIGNAL_TOOL_ERROR_STREAK
+from core.evolution.profiles.agent_profile_constants import ACTION_CHANGE_STRATEGY, SIGNAL_REPEATED_ACTIONS, SIGNAL_TOOL_ERROR_STREAK
from core.evolution.profiles.agent_profile import InterventionRule
from core.evolution.profiles.stall_hook import stall_hooks
from tests.orchestration.hook_support import make_adapter
diff --git a/src/backend/tests/evolution/test_realistic_corpus.py b/src/backend/tests/evolution/test_realistic_corpus.py
index d66b96b3..ca517c18 100644
--- a/src/backend/tests/evolution/test_realistic_corpus.py
+++ b/src/backend/tests/evolution/test_realistic_corpus.py
@@ -16,7 +16,7 @@
from core.evolution.cycle import candidate_records as candidate_records_module
from core.evolution.cycle import ordering as ordering_module
from core.evolution.cycle import patterns as patterns_module
-from core.evolution.cycle import promotion_state as promotion_state_module
+from core.evolution.cycle import promotion_constants as promotion_state_module
from core.evolution.cycle import promotion_types as promotion_types_module
import pytest
diff --git a/src/backend/tests/evolution/test_three_engines.py b/src/backend/tests/evolution/test_three_engines.py
index 0251f2e0..63dd7ab7 100644
--- a/src/backend/tests/evolution/test_three_engines.py
+++ b/src/backend/tests/evolution/test_three_engines.py
@@ -5,7 +5,7 @@
suite, because the code runs and produces output nobody checks against reality.
"""
from core.evolution.cycle import cycle_extras as cycle_extras_module
-from core.evolution.cycle import cycle_extras_state as cycle_extras_state_module
+from core.evolution.cycle import cycle_extras_constants as cycle_extras_state_module
from core.evolution.cycle import extra_findings as extra_findings_module
from core.evolution.cycle import memory_findings as memory_findings_module
from core.evolution.cycle import usage_evidence as usage_evidence_module
diff --git a/src/backend/tests/evolution/test_user_contribution.py b/src/backend/tests/evolution/test_user_contribution.py
index 644fff00..3eb4337d 100644
--- a/src/backend/tests/evolution/test_user_contribution.py
+++ b/src/backend/tests/evolution/test_user_contribution.py
@@ -8,7 +8,7 @@
from core.evolution.cycle import episode_views as episode_views_module
from core.evolution.profiles import personal_review as personal_review_module
from core.evolution.profiles import user_settings as user_settings_module
-from core.evolution.profiles import user_settings_state as user_settings_state_module
+from core.evolution.profiles import user_settings_constants as user_settings_state_module
import pytest
from sqlalchemy import create_engine
diff --git a/src/backend/tests/features/automation/run_fixtures.py b/src/backend/tests/features/automation/run_fixtures.py
new file mode 100644
index 00000000..84e00d71
--- /dev/null
+++ b/src/backend/tests/features/automation/run_fixtures.py
@@ -0,0 +1,15 @@
+"""Construct interrupted execution state for ownership/fault-injection tests."""
+from uuid import uuid4
+from core.infra.time import utc_now
+from features.automation.models import ScheduledTaskRun
+
+
+def running_run(db, task_id, lease_token=None):
+ run = ScheduledTaskRun(
+ run_id="run_" + uuid4().hex[:16], task_id=task_id,
+ status="running", lease_token=lease_token, started_at=utc_now(),
+ )
+ db.add(run)
+ db.commit()
+ db.refresh(run)
+ return run
diff --git a/src/backend/tests/features/automation/test_automation_delivery_deadline.py b/src/backend/tests/features/automation/test_automation_delivery_deadline.py
index 703dc49c..4f47a2db 100644
--- a/src/backend/tests/features/automation/test_automation_delivery_deadline.py
+++ b/src/backend/tests/features/automation/test_automation_delivery_deadline.py
@@ -1,4 +1,5 @@
"""Delivery deadlines cannot rewrite business outcomes or start later sends."""
+from tests.orchestration.automation_test_support import execute_queued
import asyncio
import threading
@@ -107,7 +108,7 @@ async def fail(*args):
monkeypatch.setattr(automation_conversation, "execute_prompt", prompt)
scheduler = AutomationScheduler()
monkeypatch.setattr(scheduler, "_send_notification", fail)
- await scheduler.execute_task(task_id, "owner")
+ await execute_queued(scheduler, task_id, "owner")
with factory() as db:
service = AutomationService(db)
assert service.get_task_runs(task_id, "owner")[0].status == "success"
@@ -138,7 +139,7 @@ async def notify(*args):
monkeypatch.setattr(automation_conversation, "execute_prompt", prompt)
scheduler = AutomationScheduler()
monkeypatch.setattr(scheduler, "_send_notification", notify)
- await scheduler.execute_task(task_id, "owner")
+ await execute_queued(scheduler, task_id, "owner")
with factory() as db:
service = AutomationService(db)
run = service.get_task_runs(task_id, "owner")[0]
diff --git a/src/backend/tests/features/automation/test_automation_lease_close.py b/src/backend/tests/features/automation/test_automation_lease_close.py
index 8268aa5f..26baac03 100644
--- a/src/backend/tests/features/automation/test_automation_lease_close.py
+++ b/src/backend/tests/features/automation/test_automation_lease_close.py
@@ -63,11 +63,11 @@ async def test_swallowed_cancel_cannot_block_release_or_resurrect_lock(state):
await bounded(state.entered.wait())
await bounded(lease.release())
assert state.cancelled.is_set()
- assert not lease.renewal.done()
+ assert lease._release_task.done()
assert await state.get(lease.key) is None
assert await state.hold(lease.key, "new-owner", ttl=60)
state.finish.set()
- await bounded(lease.renewal)
+ await bounded(asyncio.gather(lease.renewal, return_exceptions=True))
assert state.renewals == 1
assert await state.get(lease.key) == "new-owner"
@@ -84,7 +84,7 @@ async def test_cancelled_close_caller_does_not_abort_shared_cleanup(state):
await bounded(lease.release())
assert state.deletions == 1
state.finish.set()
- await bounded(lease.renewal)
+ await bounded(asyncio.gather(lease.renewal, return_exceptions=True))
async def test_finished_execution_stops_renewal_without_release(state):
@@ -95,7 +95,7 @@ async def work():
return "success"
assert await lease.run(work()) == "success"
- await bounded(lease.renewal)
+ await bounded(asyncio.gather(lease.renewal, return_exceptions=True))
assert state.renewals == 0
# No explicit release: the unrenewed key will expire at its original TTL.
assert await state.get(lease.key) == lease.token
@@ -112,7 +112,7 @@ async def work():
assert await lease.run(work()) == "success"
state.finish.set()
- await bounded(lease.renewal)
+ await bounded(asyncio.gather(lease.renewal, return_exceptions=True))
assert state.renewals == 1
await lease.release()
@@ -157,7 +157,7 @@ async def test_stop_is_bounded_with_cancellation_swallowing_renewal(state):
assert not scheduler._leases
assert await state.get(lease.key) is None
state.finish.set()
- await bounded(lease.renewal)
+ await bounded(asyncio.gather(lease.renewal, return_exceptions=True))
async def test_redis_cas_cannot_recreate_deleted_lock_or_replace_new_owner(monkeypatch):
@@ -177,3 +177,23 @@ async def test_redis_cas_cannot_recreate_deleted_lock_or_replace_new_owner(monke
assert await store.get("lease") == "new"
finally:
await client.aclose()
+
+
+async def test_stalled_renewal_stops_execution_even_if_cas_ignores_cancel(state):
+ lease = module.AutomationLease("stalled")
+ assert await lease.acquire()
+ stopped = asyncio.Event()
+ async def work():
+ try:
+ await asyncio.Event().wait()
+ finally:
+ stopped.set()
+ with pytest.raises(RuntimeError, match="锁已失效"):
+ await bounded(lease.run(work()))
+ assert stopped.is_set()
+ await bounded(lease.release())
+ assert await state.get(lease.key) is None
+ assert await state.hold(lease.key, "successor", ttl=60)
+ state.finish.set()
+ await asyncio.sleep(0.02)
+ assert await state.get(lease.key) == "successor"
diff --git a/src/backend/tests/features/automation/test_automation_manual_trigger.py b/src/backend/tests/features/automation/test_automation_manual_trigger.py
index 9ed8d974..7f9b4da8 100644
--- a/src/backend/tests/features/automation/test_automation_manual_trigger.py
+++ b/src/backend/tests/features/automation/test_automation_manual_trigger.py
@@ -1,77 +1,81 @@
-"""Regression: 「立即执行」必须真的执行。
-
-路由原本直接 `asyncio.create_task(scheduler.execute_task(...))`,在生产的多 worker +
-同步路由下 100% 失败(503 或 `no running event loop`),而定时触发照常——所以这条路径
-必须单独有测试。改成经 `manual_trigger_at` 交接后,这里盯的就是交接本身。
-"""
-
+"""Triggers and durable run acceptance commit or roll back together."""
+import pytest
+from datetime import timedelta
+from sqlalchemy import event
+from core.infra.time import utc_now
+from features.automation.admission import admit
+from features.automation.models import ScheduledTaskRun
from features.automation.service import AutomationService
def _task(db, **kw):
svc = AutomationService(db)
- return svc, svc.create_task(
- user_id="u1",
- task_type="prompt",
- prompt="跑一次",
- cron_expression="30 10 29 6 *",
- **kw,
- )
+ return svc, svc.create_task(user_id="u1", task_type="prompt", prompt="run",
+ cron_expression="30 10 29 6 *", **kw)
-def test_request_marks_task_and_drain_takes_it_once(db_session):
+def test_manual_acceptance_is_durable_and_does_not_move_schedule(db_session):
svc, task = _task(db_session)
- assert task.manual_trigger_at is None
-
+ before = task.next_run_at
svc.request_manual_trigger(task)
- db_session.refresh(task)
- assert task.manual_trigger_at is not None
-
- assert svc.take_manual_triggers() == [(task.task_id, "u1")]
-
- # 取走即清空:第二次排空不能把同一次点击再发一遍。
- db_session.refresh(task)
+ svc.request_manual_trigger(task)
+ ids = admit(db_session, 8)
+ assert len(ids) == 1
+ run = db_session.get(ScheduledTaskRun, ids[0])
+ assert run.status == "pending"
assert task.manual_trigger_at is None
- assert svc.take_manual_triggers() == []
+ assert task.run_count == 1
+ assert task.next_run_at == before
+ assert admit(db_session, 8) == []
-def test_repeated_clicks_queue_once(db_session):
+def test_busy_task_retains_manual_trigger_for_after_completion(db_session):
svc, task = _task(db_session)
svc.request_manual_trigger(task)
+ run_id = admit(db_session, 8)[0]
svc.request_manual_trigger(task)
- svc.request_manual_trigger(task)
-
- assert svc.take_manual_triggers() == [(task.task_id, "u1")]
+ assert admit(db_session, 8) == []
+ assert task.manual_trigger_at is not None
+ db_session.get(ScheduledTaskRun, run_id).status = "success"
+ db_session.commit()
+ assert len(admit(db_session, 8)) == 1
+ assert task.run_count == 2
-def test_paused_task_can_still_be_queued(db_session):
- """暂停中的任务允许手动跑一次——状态校验在路由,这里确认交接本身不挑状态。"""
+def test_paused_task_manual_trigger_is_accepted(db_session):
svc, task = _task(db_session)
svc.pause_task(task.task_id, "u1")
-
svc.request_manual_trigger(task)
- assert svc.take_manual_triggers() == [(task.task_id, "u1")]
+ assert len(admit(db_session, 8)) == 1
-def test_manual_trigger_does_not_move_the_schedule(db_session):
- """手动跑一次不该打乱 cron 排期——next_run_at 由调度器推进,这条路径不碰它。"""
- svc, task = _task(db_session)
+@pytest.mark.parametrize("manual", [True, False])
+def test_failed_acceptance_does_not_consume_trigger(db_session, manual):
+ svc, task = _task(db_session, schedule_type="once")
+ task.next_run_at = utc_now() - timedelta(seconds=5)
+ if manual:
+ svc.request_manual_trigger(task)
+ db_session.commit()
before = task.next_run_at
-
- svc.request_manual_trigger(task)
- svc.take_manual_triggers()
+ def fail(*args):
+ raise RuntimeError("storage failed")
+ event.listen(db_session, "before_commit", fail)
+ try:
+ with pytest.raises(RuntimeError):
+ admit(db_session, 8)
+ finally:
+ event.remove(db_session, "before_commit", fail)
+ db_session.rollback()
db_session.refresh(task)
-
+ assert task.run_count == 0
assert task.next_run_at == before
+ assert bool(task.manual_trigger_at) == manual
+ assert db_session.query(ScheduledTaskRun).count() == 0
-def test_bump_run_count_increments_in_place(db_session):
- """排空时补的计数是原地自增,不是读-改-写,免得和 advance_next_run 互相覆盖。"""
- svc, task = _task(db_session)
- assert (task.run_count or 0) == 0
-
- svc.bump_run_count(task.task_id)
- svc.bump_run_count(task.task_id)
- db_session.refresh(task)
-
- assert task.run_count == 2
+def test_admission_is_bounded(db_session):
+ for _ in range(4):
+ svc, task = _task(db_session)
+ svc.request_manual_trigger(task)
+ assert len(admit(db_session, 2)) == 2
+ assert len(admit(db_session, 2)) == 2
diff --git a/src/backend/tests/features/automation/test_automation_oneshot_finalize.py b/src/backend/tests/features/automation/test_automation_oneshot_finalize.py
index c9b2d3dc..9de6f319 100644
--- a/src/backend/tests/features/automation/test_automation_oneshot_finalize.py
+++ b/src/backend/tests/features/automation/test_automation_oneshot_finalize.py
@@ -1,109 +1,49 @@
-"""Regression: one-shot scheduled tasks must actually execute (and deliver).
-
-Bug: the scheduler pre-advances a task (run_count++, schedule moved on) BEFORE
-firing execute_task, to avoid a death-spiral on crashed runs. For one-shot
-tasks the pre-advance used to set status="completed". But execute_task's first
-guard refuses any task whose status isn't active/paused — so the executor
-skipped the very run it had just fired: no run record, no last_run_at, and the
-channel delivery (Feishu, etc.) at the end never ran. The task *looked* executed
-(run_count=1, status=completed) while its body never ran.
-
-Fix: advance_next_run only clears next_run_at for one-shot tasks (stops
-re-firing); terminal "completed" status is set AFTER the run via
-finalize_after_run().
-"""
-
+"""One-shot acceptance records work before removing the schedule."""
+from datetime import timedelta
+from core.infra.time import utc_now
+from features.automation.admission import admit
+from features.automation.models import ScheduledTaskRun
from features.automation.service import AutomationService
-def _task(db, **kw):
+def _accept(db, *, schedule_type="once", max_runs=None):
svc = AutomationService(db)
- return svc, svc.create_task(
- user_id="u1",
- task_type="prompt",
- prompt="发送一条测试消息",
- cron_expression="30 10 29 6 *",
- **kw,
- )
+ task = svc.create_task(user_id="u1", task_type="prompt", prompt="run",
+ cron_expression="30 10 29 6 *",
+ schedule_type=schedule_type, max_runs=max_runs)
+ task.next_run_at = utc_now() - timedelta(seconds=5)
+ db.commit()
+ run_id = admit(db, 1)[0]
+ return svc, task, db.get(ScheduledTaskRun, run_id)
-def test_oneshot_pre_advance_keeps_task_runnable(db_session):
- """After pre-advance a one-shot task must stay active (so execute_task's
- guard passes) yet have next_run_at cleared (so it is never re-fired)."""
- svc, task = _task(db_session, schedule_type="once")
+def test_oneshot_is_pending_after_schedule_is_consumed(db_session):
+ svc, task, run = _accept(db_session)
assert task.status == "active"
-
- svc.advance_next_run(task.task_id)
- db_session.refresh(task)
-
- # The exact pair the old code got wrong:
- assert task.status == "active", "pre-advance must NOT mark one-shot completed"
- assert task.next_run_at is None, "pre-advance must clear next_run_at"
+ assert task.next_run_at is None
assert task.run_count == 1
-
- # ...and the guard execute_task uses would now let it run.
- assert task.status in ("active", "paused")
-
- # get_due_tasks must NOT re-select it (next_run_at is NULL).
- from datetime import datetime, timezone
- due = svc.get_due_tasks(datetime.now(timezone.utc))
- assert task.task_id not in {t.task_id for t in due}
-
-
-def test_finalize_marks_oneshot_completed_after_run(db_session):
- svc, task = _task(db_session, schedule_type="once")
- svc.advance_next_run(task.task_id)
-
+ assert run.status == "pending"
+ assert admit(db_session, 1) == []
+ run.status = "success"
+ db_session.commit()
svc.finalize_after_run(task.task_id)
- db_session.refresh(task)
-
assert task.status == "completed"
- assert task.next_run_at is None
-def test_finalize_leaves_recurring_active(db_session):
- svc, task = _task(db_session, schedule_type="recurring")
- svc.advance_next_run(task.task_id)
- db_session.refresh(task)
- # recurring: schedule moved forward, still active
- assert task.status == "active"
- assert task.next_run_at is not None
-
- svc.finalize_after_run(task.task_id)
- db_session.refresh(task)
- assert task.status == "active", "recurring task must stay active after a run"
- assert task.next_run_at is not None
-
-
-def test_finalize_completes_when_max_runs_reached(db_session):
- svc, task = _task(db_session, schedule_type="recurring", max_runs=2)
- # first run
- svc.advance_next_run(task.task_id)
+def test_recurring_continues_after_completed_invocation(db_session):
+ svc, task, run = _accept(db_session, schedule_type="recurring")
+ assert task.next_run_at > utc_now()
+ run.status = "success"
+ db_session.commit()
svc.finalize_after_run(task.task_id)
- db_session.refresh(task)
- assert task.status == "active" and task.run_count == 1
-
- # second run hits max_runs
- svc.advance_next_run(task.task_id)
- svc.finalize_after_run(task.task_id)
- db_session.refresh(task)
- assert task.run_count == 2
- assert task.status == "completed"
+ assert task.status == "active"
-def test_finalize_never_revives_disabled(db_session):
- svc, task = _task(db_session, schedule_type="once")
- svc.advance_next_run(task.task_id)
+def test_max_runs_and_disabled_are_respected(db_session):
+ svc, task, run = _accept(db_session, schedule_type="recurring", max_runs=1)
+ assert task.next_run_at is None
svc.update_task_system(task.task_id, status="disabled")
-
- svc.finalize_after_run(task.task_id)
- db_session.refresh(task)
- assert task.status == "disabled", "auto-disabled task must not be overridden"
-
-
-def test_manual_task_never_finalized(db_session):
- svc, task = _task(db_session, schedule_type="manual")
- svc.advance_next_run(task.task_id)
+ run.status = "failed"
+ db_session.commit()
svc.finalize_after_run(task.task_id)
- db_session.refresh(task)
- assert task.status == "active", "manual tasks stay active across runs"
+ assert task.status == "disabled"
diff --git a/src/backend/tests/features/automation/test_automation_ownership_postgres.py b/src/backend/tests/features/automation/test_automation_ownership_postgres.py
index bdedcc5e..84f9f3f2 100644
--- a/src/backend/tests/features/automation/test_automation_ownership_postgres.py
+++ b/src/backend/tests/features/automation/test_automation_ownership_postgres.py
@@ -1,5 +1,6 @@
"""Opt-in ownership races in a disposable schema on a local PostgreSQL."""
+from tests.features.automation.run_fixtures import running_run
import os
import threading
from concurrent.futures import ThreadPoolExecutor
@@ -51,7 +52,7 @@ def pg():
def test_two_recovery_claimants_cannot_both_take_the_same_snapshot(pg):
with pg() as db:
- run_id = AutomationRunRepository(db).start("task", "old").run_id
+ run_id = running_run(db, "task", "old").run_id
barrier = threading.Barrier(2)
def claim(token):
@@ -71,7 +72,7 @@ def claim(token):
def test_old_cancellation_waits_for_takeover_and_cannot_cancel_its_child(pg):
with pg() as db:
- run_id = AutomationRunRepository(db).start("task", "old").run_id
+ run_id = running_run(db, "task", "old").run_id
journal = RunJournal(pg)
child = journal.accept(
run_id="child",
@@ -109,7 +110,7 @@ def takeover():
def test_completion_waiting_on_takeover_rechecks_the_token(pg):
with pg() as db:
- run_id = AutomationRunRepository(db).start("task", "old").run_id
+ run_id = running_run(db, "task", "old").run_id
updated, commit = threading.Event(), threading.Event()
def takeover():
@@ -153,7 +154,7 @@ def test_additive_migration_preserves_rows_and_roundtrips_on_postgres(pg):
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
with pg() as db:
- run_id = AutomationRunRepository(db).start("task", "original").run_id
+ run_id = running_run(db, "task", "original").run_id
with pg.kw["bind"].begin() as conn:
migration.op = Operations(MigrationContext.configure(conn))
migration.downgrade()
diff --git a/src/backend/tests/features/automation/test_automation_remote_effect.py b/src/backend/tests/features/automation/test_automation_remote_effect.py
index c6316678..3529b57d 100644
--- a/src/backend/tests/features/automation/test_automation_remote_effect.py
+++ b/src/backend/tests/features/automation/test_automation_remote_effect.py
@@ -44,7 +44,7 @@ async def test_device_gateway_maps_local_intent_before_cloud_mcp(effect_env, tmp
from agentscope.message import TextBlock, ToolCallBlock, ToolResultState
from agentscope.tool import Toolkit
from agentscope.tool._response import ToolChunk
- from core.llm.mcp.manager import GatewayMCPTool
+ from core.llm.tools.cloud_gateway import GatewayMCPTool
from core.llm.runtime.state import AgentRuntimeState
from tests.orchestration.hook_support import make_adapter, durable_adapter
from features.desktop.capability.service import invoke_gateway_tool
@@ -60,15 +60,16 @@ async def test_device_gateway_maps_local_intent_before_cloud_mcp(effect_env, tmp
raw = {"name": "create_scheduled_task", "inputSchema": {"type": "object", "properties": {}}}
class CloudClient:
- execution_timeout = 10
- async def get_tool(self, name):
- async def invoke(**args):
- result = impl.create_task(user_id="user-1", **args)
- return ToolChunk(content=[TextBlock(text=json.dumps(result))],
- state=ToolResultState.SUCCESS if result["ok"] else ToolResultState.ERROR)
- return invoke
- monkeypatch.setattr('core.llm.mcp.pool.make_client', lambda *a, **kw: CloudClient())
-
+ async def __aenter__(self): return self
+ async def __aexit__(self, *exc): pass
+ async def call_tool(self, name, args):
+ from mcp.types import CallToolResult, TextContent
+ result = impl.create_task(user_id="user-1", **args)
+ return CallToolResult(content=[TextContent(text=json.dumps(result))], is_error=not result["ok"])
+ monkeypatch.setattr('core.llm.mcp.client.make_client', lambda *a, **kw: CloudClient())
+
+ # This ledger fixture has no configured connector/model credentials.
+ monkeypatch.setattr("features.desktop.capability.security._known_cloud_secrets", lambda *_a, **_kw: set())
received = []
async def endpoint(request):
body = json.loads(request.content)
@@ -81,7 +82,7 @@ async def endpoint(request):
)
return httpx.Response(200, json={"code": 200, "data": result})
- tool = GatewayMCPTool(mcp_name="automation", tool=mcp.types.Tool.model_validate(raw),
+ tool = GatewayMCPTool(mcp_name="automation", tool=raw,
invoke_url="https://cloud.invalid/api/v1/desktop/capability/gateway/automation/call",
schema_hash="a"*64, headers={}, timeout=10, transport=httpx.MockTransport(endpoint))
toolkit = Toolkit(tools=[tool])
@@ -93,7 +94,7 @@ async def invoke(**kwargs):
request = {"tool_call": ToolCallBlock(id="gateway-call", name=tool.name,
input=json.dumps({"cron_expression": "0 18 * * *", "prompt": "report"}))}
first = [item async for item in middleware.on_acting(SimpleNamespace(state=state), request, invoke)]
- assert first[-1].state == ToolResultState.SUCCESS
+ assert first[-1].state == ToolResultState.SUCCESS, first[-1]
second = [item async for item in middleware.on_acting(SimpleNamespace(state=state), request, invoke)]
assert second[-1].state == ToolResultState.SUCCESS
assert len(received) == 1
diff --git a/src/backend/tests/features/automation/test_automation_run_ownership.py b/src/backend/tests/features/automation/test_automation_run_ownership.py
index 1d9e1935..bb325e50 100644
--- a/src/backend/tests/features/automation/test_automation_run_ownership.py
+++ b/src/backend/tests/features/automation/test_automation_run_ownership.py
@@ -1,5 +1,6 @@
"""An obsolete executor cannot overwrite an adopted or terminal run."""
+from tests.features.automation.run_fixtures import running_run
import importlib.util
from pathlib import Path
@@ -13,8 +14,8 @@
@pytest.fixture
-def factory():
- engine = create_engine("sqlite://")
+def factory(tmp_path):
+ engine = create_engine(f"sqlite:///{tmp_path / 'ownership.sqlite'}")
ScheduledTaskRun.__table__.create(engine)
yield sessionmaker(bind=engine)
engine.dispose()
@@ -23,7 +24,7 @@ def factory():
def test_takeover_rejects_old_completion_and_terminal_rewrites(factory):
with factory() as db:
repository = AutomationRunRepository(db)
- run_id = repository.start("task", "old").run_id
+ run_id = running_run(db, "task", "old").run_id
assert repository.claim(run_id, "new", expected_token="old")
assert repository.complete(run_id, lease_token="old", status="failed") is None
completed = repository.complete(
@@ -36,10 +37,13 @@ def test_takeover_rejects_old_completion_and_terminal_rewrites(factory):
assert db.get(ScheduledTaskRun, run_id).result_summary == "accepted"
-def test_legacy_running_row_requires_claim_before_completion(factory):
+def test_pending_row_requires_claim_before_completion(factory):
with factory() as db:
repository = AutomationRunRepository(db)
- run_id = repository.start("legacy").run_id
+ run = ScheduledTaskRun(run_id="pending", task_id="task", status="pending")
+ db.add(run)
+ db.commit()
+ run_id = run.run_id
assert repository.complete(run_id, lease_token="new", status="success") is None
assert repository.claim(run_id, "new", expected_token=None)
assert repository.complete(run_id, lease_token="new", status="success") is not None
@@ -48,7 +52,7 @@ def test_legacy_running_row_requires_claim_before_completion(factory):
def test_conditional_result_can_rollback_with_task_bookkeeping(factory):
with factory() as db:
repository = AutomationRunRepository(db)
- run_id = repository.start("task", "owner").run_id
+ run_id = running_run(db, "task", "owner").run_id
assert (
repository.complete(run_id, lease_token="owner", status="success", commit=False)
is not None
@@ -94,7 +98,7 @@ def test_migration_preserves_existing_rows_and_roundtrips(relative):
def test_delayed_claim_cannot_replace_a_newer_claim(factory):
with factory() as db:
repository = AutomationRunRepository(db)
- run_id = repository.start("task", "original").run_id
+ run_id = running_run(db, "task", "original").run_id
assert repository.claim(run_id, "current", expected_token="original")
assert not repository.claim(run_id, "late", expected_token="original")
assert repository.complete(run_id, lease_token="current", status="success") is not None
@@ -108,7 +112,7 @@ async def test_obsolete_observer_does_not_cancel_adopted_child(factory, monkeypa
with factory() as db:
repository = AutomationRunRepository(db)
- run_id = repository.start("task", "old").run_id
+ run_id = running_run(db, "task", "old").run_id
monkeypatch.setattr(conversation.db_engine, "SessionLocal", factory)
entered = asyncio.Event()
cancelled = []
@@ -152,7 +156,7 @@ async def test_expired_recovery_claimant_leaves_new_ownership_untouched(factory,
state = ephemeral.LocalEphemeralState()
monkeypatch.setattr(ephemeral, "get_ephemeral_state", lambda: state)
with factory() as db:
- run_id = AutomationRunRepository(db).start("task", "original").run_id
+ run_id = running_run(db, "task", "original").run_id
scheduler = AutomationScheduler()
assert await scheduler._acquire_lock("task")
lease = scheduler._leases["task"]
@@ -181,7 +185,7 @@ async def test_recovery_stops_before_claiming_next_run_after_lease_loss(factory,
with factory() as db:
repository = AutomationRunRepository(db)
for i in range(2):
- run = repository.start("task", "original")
+ run = running_run(db, "task", "original")
run.chat_id = f"chat-{i}"
db.commit()
scheduler = AutomationScheduler()
@@ -190,12 +194,12 @@ async def test_recovery_stops_before_claiming_next_run_after_lease_loss(factory,
calls = []
adopted = []
- async def resume(*args, resume_run_id):
- calls.append(resume_run_id)
+ async def resume(*args, run_id):
+ calls.append(run_id)
await state.put(lease.key, "replacement", ttl=60)
with factory() as db:
other = (
- db.query(ScheduledTaskRun).filter(ScheduledTaskRun.run_id != resume_run_id).one()
+ db.query(ScheduledTaskRun).filter(ScheduledTaskRun.run_id != run_id).one()
)
adopted.append(other.run_id)
assert AutomationRunRepository(db).claim(
@@ -224,7 +228,7 @@ async def test_recovery_reloads_child_link_created_before_claim(factory, monkeyp
state = ephemeral.LocalEphemeralState()
monkeypatch.setattr(ephemeral, "get_ephemeral_state", lambda: state)
with factory() as db:
- run_id = AutomationRunRepository(db).start("task", "original").run_id
+ run_id = running_run(db, "task", "original").run_id
original_claim = AutomationRunRepository.claim
def link_then_claim(self, run_id, token, *, expected_token):
@@ -237,8 +241,8 @@ def link_then_claim(self, run_id, token, *, expected_token):
resumed = []
- async def resume(*args, resume_run_id):
- resumed.append(resume_run_id)
+ async def resume(*args, run_id):
+ resumed.append(run_id)
monkeypatch.setattr(AutomationRunRepository, "claim", link_then_claim)
monkeypatch.setattr(automation_dispatch, "execute_task", resume)
diff --git a/src/backend/tests/features/automation/test_delivery_target_migration.py b/src/backend/tests/features/automation/test_delivery_target_migration.py
new file mode 100644
index 00000000..5edfb7a4
--- /dev/null
+++ b/src/backend/tests/features/automation/test_delivery_target_migration.py
@@ -0,0 +1,31 @@
+"""Stored destinations survive removal of the flat runtime representation."""
+import pytest
+from sqlalchemy import Column, JSON, MetaData, String, Table, create_engine, select
+from features.automation.schema import upgrade_delivery_targets
+from features.automation.delivery_targets import resolve_delivery_targets
+
+@pytest.mark.parametrize("targets", [None, [], [{"type": "inapp"}],
+ [{"type": "channel", "channel_id": "chosen", "conversation_id": "chosen-room"}]])
+def test_migration_preserves_destination_precedence_and_metadata(targets):
+ engine = create_engine("sqlite://")
+ table = Table("scheduled_tasks", MetaData(), Column("task_id", String, primary_key=True),
+ Column("metadata", JSON))
+ table.metadata.create_all(engine)
+ data = {"channel_id": "old", "conversation_id": "room", "loop_id": "keep"}
+ if targets is not None:
+ data["delivery_targets"] = targets
+ with engine.begin() as conn:
+ conn.execute(table.insert().values(task_id="task", metadata=data))
+ upgrade_delivery_targets(conn)
+ first = conn.execute(select(table.c.metadata)).scalar_one()
+ upgrade_delivery_targets(conn)
+ assert conn.execute(select(table.c.metadata)).scalar_one() == first
+ assert first["loop_id"] == "keep"
+ assert "channel_id" not in first and "conversation_id" not in first
+ expected = targets or [{"type": "inapp"}, {"type": "channel", "channel_id": "old", "conversation_id": "room"}]
+ assert resolve_delivery_targets(first) == expected
+ engine.dispose()
+
+
+def test_runtime_does_not_interpret_flat_destinations():
+ assert resolve_delivery_targets({"channel_id": "old", "conversation_id": "room"}) == [{"type": "inapp"}]
diff --git a/src/backend/tests/features/automation/test_durable_admission.py b/src/backend/tests/features/automation/test_durable_admission.py
new file mode 100644
index 00000000..90d52616
--- /dev/null
+++ b/src/backend/tests/features/automation/test_durable_admission.py
@@ -0,0 +1,96 @@
+"""Concurrency and migration regressions for durable automation admission."""
+import importlib.util
+import threading
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+import pytest
+from sqlalchemy import create_engine, event, text
+from sqlalchemy.exc import OperationalError
+from sqlalchemy.orm import sessionmaker
+
+from core.db.engine import Base
+from features.automation.admission import admit
+from features.automation.models import ScheduledTaskRun
+from features.automation.schema import upgrade_queue
+from features.automation.service import AutomationService
+
+
+def test_two_connections_cannot_consume_same_trigger(tmp_path):
+ engine = create_engine(f"sqlite:///{tmp_path / 'admit.sqlite'}")
+ Base.metadata.create_all(engine)
+ factory = sessionmaker(bind=engine)
+ with factory() as db:
+ svc = AutomationService(db)
+ task = svc.create_task(user_id="u", task_type="prompt", prompt="run",
+ cron_expression="0 18 * * *")
+ svc.request_manual_trigger(task)
+ task_id = task.task_id
+ rendezvous = threading.Barrier(2)
+ def after_read(conn, cursor, statement, parameters, context, many):
+ if statement.startswith("SELECT scheduled_tasks.") and "EXISTS" in statement:
+ rendezvous.wait(timeout=5)
+ event.listen(engine, "after_cursor_execute", after_read)
+ def accept():
+ with factory() as db:
+ try:
+ return admit(db, 1)
+ except OperationalError as exc:
+ assert "locked" in str(exc).lower()
+ return []
+ try:
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ futures = [pool.submit(accept), pool.submit(accept)]
+ results = [future.result(timeout=10) for future in futures]
+ finally:
+ event.remove(engine, "after_cursor_execute", after_read)
+ assert sum(map(len, results)) == 1
+ with factory() as db:
+ assert db.query(ScheduledTaskRun).count() == 1
+ assert AutomationService(db).get_task(task_id, "u").run_count == 1
+ assert admit(db, 1) == []
+ engine.dispose()
+
+
+def test_sqlite_queue_upgrade_retains_history_and_is_repeatable():
+ engine = create_engine("sqlite://")
+ with engine.begin() as connection:
+ connection.execute(text("""CREATE TABLE scheduled_task_runs (
+ run_id TEXT PRIMARY KEY, status TEXT NOT NULL,
+ CONSTRAINT scheduled_task_runs_status_check CHECK
+ (status IN ('running', 'success', 'failed')))"""))
+ connection.execute(text("INSERT INTO scheduled_task_runs VALUES ('old', 'success')"))
+ upgrade_queue(connection)
+ upgrade_queue(connection)
+ connection.execute(text("INSERT INTO scheduled_task_runs VALUES ('queued', 'pending')"))
+ assert connection.execute(text(
+ "SELECT run_id, status FROM scheduled_task_runs ORDER BY run_id"
+ )).all() == [("old", "success"), ("queued", "pending")]
+ engine.dispose()
+
+
+@pytest.mark.parametrize("path", [
+ "src/backend/alembic/versions/automationqueue01_pending.py",
+ "ce/overlay/src/backend/alembic/versions/ce_0032_automation_queue.py",
+])
+def test_downgrade_refuses_to_erase_pending_work(path):
+ from alembic.migration import MigrationContext
+ from alembic.operations import Operations
+ root = Path(__file__).resolve().parents[5]
+ spec = importlib.util.spec_from_file_location("queue_migration", root / path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ engine = create_engine("sqlite://")
+ with engine.begin() as connection:
+ connection.execute(text("""CREATE TABLE scheduled_task_runs (
+ run_id TEXT PRIMARY KEY, status TEXT NOT NULL,
+ CONSTRAINT scheduled_task_runs_status_check CHECK
+ (status IN ('running', 'success', 'failed')))"""))
+ with Operations.context(MigrationContext.configure(connection)):
+ module.upgrade()
+ connection.execute(text("INSERT INTO scheduled_task_runs VALUES ('q', 'pending')"))
+ with pytest.raises(RuntimeError, match="Drain pending"):
+ module.downgrade()
+ connection.execute(text("DELETE FROM scheduled_task_runs"))
+ module.downgrade()
+ engine.dispose()
diff --git a/src/backend/tests/features/automation/test_queue_postgres.py b/src/backend/tests/features/automation/test_queue_postgres.py
new file mode 100644
index 00000000..b8f91117
--- /dev/null
+++ b/src/backend/tests/features/automation/test_queue_postgres.py
@@ -0,0 +1,94 @@
+"""Queue admission by separate OS processes and migration with real PostgreSQL."""
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import pytest
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy import text
+from features.automation.models import ScheduledTask, ScheduledTaskRun
+from core.infra.time import utc_now
+from tests.features.automation.test_automation_ownership_postgres import pg, POSTGRES_URL
+
+pytestmark = pytest.mark.skipif(not POSTGRES_URL, reason="local PostgreSQL opt-in required")
+
+
+def test_processes_admit_each_trigger_once(pg, tmp_path):
+ with pg() as db:
+ task = db.get(ScheduledTask, "task")
+ task.manual_trigger_at = utc_now()
+ db.commit()
+ schema = db.execute(text("SELECT current_schema()")).scalar()
+ script = """
+import json, os, time
+from pathlib import Path
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from features.automation.admission import admit
+engine=create_engine(os.environ['AUTOMATION_TEST_POSTGRES_URL'],
+ connect_args={'options':'-csearch_path='+os.environ['QUEUE_TEST_SCHEMA']})
+barrier=Path(os.environ['QUEUE_TEST_BARRIER'])
+(barrier/str(os.getpid())).touch()
+while not (barrier/'release').exists(): time.sleep(.01)
+with sessionmaker(engine)() as db: print('RESULT:'+json.dumps(admit(db,8)),flush=True)
+"""
+ env = {**os.environ, "QUEUE_TEST_SCHEMA": schema, "QUEUE_TEST_BARRIER": str(tmp_path)}
+ processes = []
+ accepted = []
+ try:
+ for _ in range(4):
+ processes.append(subprocess.Popen(
+ [sys.executable, "-c", script], env=env,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
+ ))
+ deadline = time.monotonic() + 30
+ while len(list(tmp_path.iterdir())) < 4:
+ assert all(process.poll() is None for process in processes)
+ assert time.monotonic() < deadline, "admission workers failed to reach barrier"
+ time.sleep(.01)
+ (tmp_path / "release").touch()
+ for process in processes:
+ output, error = process.communicate(timeout=30)
+ assert process.returncode == 0, error
+ accepted.extend(json.loads(next(
+ line[7:] for line in output.splitlines() if line.startswith("RESULT:")
+ )))
+ finally:
+ for process in processes:
+ if process.poll() is None:
+ process.kill()
+ for process in processes:
+ process.communicate()
+ assert len(accepted) == 1
+ with pg() as db:
+ assert db.query(ScheduledTaskRun).count() == 1
+ assert db.get(ScheduledTask, "task").run_count == 1
+
+
+@pytest.mark.parametrize("path", [
+ "src/backend/alembic/versions/automationqueue01_pending.py",
+ "ce/overlay/src/backend/alembic/versions/ce_0032_automation_queue.py",
+])
+def test_queue_migration_keeps_history_and_refuses_pending_downgrade(pg, path):
+ root = Path(__file__).resolve().parents[5]
+ spec = importlib.util.spec_from_file_location("queue_migration", root/path)
+ migration = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(migration)
+ with pg.kw["bind"].begin() as conn:
+ with Operations.context(MigrationContext.configure(conn)):
+ migration.downgrade()
+ conn.execute(text("INSERT INTO scheduled_task_runs (run_id,task_id,status,started_at) VALUES ('history','task','success',now())"))
+ migration.upgrade()
+ migration.upgrade()
+ conn.execute(text("INSERT INTO scheduled_task_runs (run_id,task_id,status,started_at) VALUES ('pending','task','pending',now())"))
+ with pytest.raises(RuntimeError, match="Drain pending"):
+ migration.downgrade()
+ conn.execute(text("UPDATE scheduled_task_runs SET status='success' WHERE run_id='pending'"))
+ migration.downgrade()
+ assert conn.execute(text("SELECT count(*) FROM scheduled_task_runs")).scalar() == 2
+ migration.upgrade()
diff --git a/src/backend/tests/features/desktop/capabilities/conftest.py b/src/backend/tests/features/desktop/capabilities/conftest.py
index a34db214..8c2f8105 100644
--- a/src/backend/tests/features/desktop/capabilities/conftest.py
+++ b/src/backend/tests/features/desktop/capabilities/conftest.py
@@ -14,6 +14,7 @@
from fastapi.testclient import TestClient
from tests._capability_index import bind_capability_index
from tests.features.desktop.capabilities._cloud_identity import CLOUD_BASE, CLOUD_USER, cloud_token
+from tests.db_support import share_session
@pytest.fixture
@@ -55,6 +56,7 @@ def hybrid_catalog_client(tmp_path, index_db, caps_root, monkeypatch):
app = FastAPI()
app.include_router(catalog_router)
app.dependency_overrides[get_db] = lambda: session
+ share_session(monkeypatch, session)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id=CLOUD_USER, user_center_id=CLOUD_USER, username=CLOUD_USER
)
diff --git a/src/backend/tests/features/desktop/capabilities/management_browser_server.py b/src/backend/tests/features/desktop/capabilities/management_browser_server.py
index 0fa738ae..4962dbcf 100644
--- a/src/backend/tests/features/desktop/capabilities/management_browser_server.py
+++ b/src/backend/tests/features/desktop/capabilities/management_browser_server.py
@@ -24,7 +24,7 @@
from api.routes.v1 import catalog, plugins, me_capabilities
from features.desktop.local.plugins import service as local_plugin_service
from features.desktop.capabilities.local_plugin_runtime import configs
-from core.llm.mcp.pool import make_client
+from core.llm.mcp.client import make_client
engine = create_engine(os.environ["DATABASE_URL"], connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
@@ -63,9 +63,8 @@ def health():
@app.post("/test/tool/{manager}/{operation}")
async def tool(manager: str, operation: str, arguments: dict):
server = next((sid, cfg) for sid, cfg in configs(user).items() if cfg.get("gateway_plugin") == manager)
- client = make_client(*server, is_stateful=False)
- handler = await client.get_tool(operation)
- result = await handler(**arguments)
+ async with make_client(*server) as client:
+ result = await client.call_tool(operation, arguments)
return json.loads(result.content[0].text)
@app.post("/test/package/{kind}")
diff --git a/src/backend/tests/features/desktop/capabilities/test_agent_rerun_identity.py b/src/backend/tests/features/desktop/capabilities/test_agent_rerun_identity.py
index aa9c5ded..fddcb279 100644
--- a/src/backend/tests/features/desktop/capabilities/test_agent_rerun_identity.py
+++ b/src/backend/tests/features/desktop/capabilities/test_agent_rerun_identity.py
@@ -112,7 +112,6 @@ def get_by_id(self, agent_id, user_id=None):
)
monkeypatch.setattr(chat_context, "resolve_enabled_capabilities", lambda *a: ([], [], []))
monkeypatch.setattr(chat_session_context, 'load_session_messages', lambda *a: [])
- monkeypatch.setattr(chat_session_context, 'release_request_session', lambda *a: None)
monkeypatch.setattr(responses, "sse_response", lambda value: value)
pass
@@ -155,20 +154,18 @@ def source_snapshot():
db.commit()
async def call(operation):
- with index_db() as db:
- user = UserContext(
- user_id="owner", username="synthetic", user_center_id="center-cloud-a"
- )
- if operation == "regenerate":
- return await chat_reruns.regenerate_message(
- "rerun", chat_models.RegenerateRequest(message_index=1), user=user, db=db
- )
- return await chat_reruns.edit_and_resend(
- "rerun",
- chat_models.EditAndResendRequest(message_index=0, new_content="edited task"),
- user=user,
- db=db,
+ user = UserContext(
+ user_id="owner", username="synthetic", user_center_id="center-cloud-a"
+ )
+ if operation == "regenerate":
+ return await chat_reruns.regenerate_message(
+ "rerun", chat_models.RegenerateRequest(message_index=1), user=user
)
+ return await chat_reruns.edit_and_resend(
+ "rerun",
+ chat_models.EditAndResendRequest(message_index=0, new_content="edited task"),
+ user=user,
+ )
return SimpleNamespace(
call=call,
diff --git a/src/backend/tests/features/desktop/capabilities/test_available_runtime.py b/src/backend/tests/features/desktop/capabilities/test_available_runtime.py
index 18626954..d9f454d3 100644
--- a/src/backend/tests/features/desktop/capabilities/test_available_runtime.py
+++ b/src/backend/tests/features/desktop/capabilities/test_available_runtime.py
@@ -34,6 +34,8 @@ def get_skill_dir(self, *args): return None
def register_skills_to_toolkit(self, *args, **kwargs): return 0
class Model:
+ from agentscope.formatter import OpenAIChatFormatter
+ formatter = OpenAIChatFormatter()
model, context_size = "offline-test", 32768
calls = 0
async def __call__(self, messages, tools=None, **kwargs):
@@ -201,18 +203,18 @@ def test_edited_required_dependency_is_included_and_usable(local_skill):
@pytest.mark.asyncio
async def test_revoked_skill_returns_tool_error_and_other_skill_stays_readable(local_skill):
from features.desktop.capabilities import registry
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools.skill_tool import register_sandboxed_view_text_file
local_skill("good")
local_skill("disabled")
run = runtime.prepare("reader", "owner", skill_ids=["good", "disabled"])
loader = runtime.frozen_loader(run)
- collector = ToolCollector()
+ collector = ToolAssembly()
register_sandboxed_view_text_file(
collector, [str(run.view_dir / n) for n in run.bindings], loader
)
- tool = next(t for t in collector.function_tools if t.name == "view_text_file")
+ tool = next(t for t in list(collector) if t.name == "view_text_file")
registry.set_enabled("skill:local:disabled", False)
bad = await tool(file_path="/workspace/skills/disabled/SKILL.md")
assert "Original instructions" not in str(bad)
@@ -249,7 +251,9 @@ async def test_connector_disabled_midrun_never_calls_underlying_tool(local_skill
from agentscope.tool import ToolBase, ToolChunk, Toolkit
from agentscope.permission import PermissionBehavior, PermissionDecision
from agentscope.message import TextBlock
- from core.llm.mcp.capability_guards import AvailableMCPClient
+ from core.llm.tools.ontology_toolkit import OntologyFilteredToolkit
+ from core.llm.runtime.state import AgentRuntimeState
+ from agentscope.message import ToolCallBlock
from types import SimpleNamespace
from dataclasses import replace
from features.desktop.capabilities import mcp_json
@@ -263,7 +267,7 @@ class Tool(ToolBase):
async def check_permissions(self, *a, **kw):
return PermissionDecision(behavior=PermissionBehavior.ALLOW)
- async def __call__(self, **kw):
+ async def call(self, **kw):
calls.append("called")
return ToolChunk(content=[TextBlock(text="result")])
@@ -275,12 +279,10 @@ async def list_tools():
run,
mcp_bindings={"one": {"install_id": "mcp:local-json:one", "authorization_checked": True}},
)
- client = AvailableMCPClient(
- SimpleNamespace(name="one", list_tools=list_tools, is_stateful=False), run
- )
- toolkit = Toolkit(mcps=[client])
+ toolkit = OntologyFilteredToolkit(tools=[Tool()])
+ toolkit.capability_guard = run.validate_tool_call
assert (await toolkit.get_tool_schemas())[0]["function"]["name"] == "lookup"
monkeypatch.setattr(mcp_json, "local_server_configs", lambda: {})
- result = await (await client.list_tools())[0]()
+ result = [chunk async for chunk in toolkit.call_tool(ToolCallBlock(id="call", name="lookup", input="{}"), AgentRuntimeState())]
assert not calls
- assert "result" not in "".join(block.text for block in result.content)
+ assert "result" not in str(result)
diff --git a/src/backend/tests/features/desktop/capabilities/test_cloud_sticky_plugins.py b/src/backend/tests/features/desktop/capabilities/test_cloud_sticky_plugins.py
index 74540583..c2b35518 100644
--- a/src/backend/tests/features/desktop/capabilities/test_cloud_sticky_plugins.py
+++ b/src/backend/tests/features/desktop/capabilities/test_cloud_sticky_plugins.py
@@ -125,7 +125,6 @@ def test_raw_explicit_cloud_id_is_saved_with_its_account_scope(selected):
def test_soft_run_connector_honors_parent_plugin_disable(selected, monkeypatch):
from dataclasses import replace
from features.desktop.capabilities.runtime import save
- from core.llm.mcp.capability_guards import connector_available
from features.desktop.bridge import client as bridge
_, plugin, _, skill = selected
@@ -148,9 +147,9 @@ def test_soft_run_connector_honors_parent_plugin_disable(selected, monkeypatch):
)
save(run)
monkeypatch.setattr(bridge_gateway, 'cloud_gateway_mcp_configs', lambda *a, **kw: {"pack-search": {}})
- assert connector_available(run, "pack-search")
+ assert run.validate_tool_call("pack-search")
registry.set_enabled(plugin.install_id, False)
- assert not connector_available(run, "pack-search")
+ assert not run.validate_tool_call("pack-search")
@pytest.mark.asyncio
diff --git a/src/backend/tests/features/desktop/capabilities/test_desktop_content_safety.py b/src/backend/tests/features/desktop/capabilities/test_desktop_content_safety.py
index bdde6223..0d67d866 100644
--- a/src/backend/tests/features/desktop/capabilities/test_desktop_content_safety.py
+++ b/src/backend/tests/features/desktop/capabilities/test_desktop_content_safety.py
@@ -280,18 +280,14 @@ def unavailable(*args, **kwargs):
async def test_actual_invocation_checks_successful_result(monkeypatch):
from types import SimpleNamespace
- from core.llm.mcp import pool as mcp_pool
-
- async def fake_tool(**kwargs):
- return SimpleNamespace(
- metadata={}, model_dump=lambda **kwargs: {"content": [{"text": CANARY}]}
- )
+ from core.llm.mcp import client as mcp_pool
class Client:
- execution_timeout = 1
-
- async def get_tool(self, name):
- return fake_tool
+ async def __aenter__(self): return self
+ async def __aexit__(self, *exc): pass
+ async def call_tool(self, name, arguments):
+ from mcp.types import CallToolResult, TextContent
+ return CallToolResult(content=[TextContent(text=CANARY)])
monkeypatch.setattr(mcp_pool, "make_client", lambda *args, **kwargs: Client())
with pytest.raises(cap.CapabilityContentRejected):
@@ -365,20 +361,16 @@ def test_userinfo_token_without_password_is_guarded():
async def test_actual_invocation_checks_custom_header_echo(monkeypatch):
from types import SimpleNamespace
- from core.llm.mcp import pool as mcp_pool
+ from core.llm.mcp import client as mcp_pool
monkeypatch.setattr(security, "_known_cloud_secrets", lambda uid: set())
- async def fake_tool(**kwargs):
- return SimpleNamespace(
- metadata={}, model_dump=lambda **kwargs: {"content": [{"text": CANARY}]}
- )
-
class Client:
- execution_timeout = 1
-
- async def get_tool(self, name):
- return fake_tool
+ async def __aenter__(self): return self
+ async def __aexit__(self, *exc): pass
+ async def call_tool(self, name, arguments):
+ from mcp.types import CallToolResult, TextContent
+ return CallToolResult(content=[TextContent(text=CANARY)])
monkeypatch.setattr(mcp_pool, "make_client", lambda *args, **kwargs: Client())
with pytest.raises(cap.CapabilityContentRejected):
diff --git a/src/backend/tests/features/desktop/capabilities/test_desktop_resource_plugins.py b/src/backend/tests/features/desktop/capabilities/test_desktop_resource_plugins.py
index b4507f1c..d25e8d0e 100644
--- a/src/backend/tests/features/desktop/capabilities/test_desktop_resource_plugins.py
+++ b/src/backend/tests/features/desktop/capabilities/test_desktop_resource_plugins.py
@@ -169,7 +169,6 @@ def test_unrelated_uploaded_plugin_is_byte_for_byte_unchanged():
def test_native_client_keeps_the_cloud_authorized_tool_scope(synced):
- from core.llm.mcp.pool import make_client
resources, _, _ = synced
cfg = resources.local_config(
@@ -177,5 +176,4 @@ def test_native_client_keeps_the_cloud_authorized_tool_scope(synced):
"device-user",
"account",
)
- client = make_client("account-browser", cfg, is_stateful=False)
- assert client.enable_tools == ["browser_open"]
+ assert cfg["enable_tools"] == ["browser_open"]
diff --git a/src/backend/tests/features/desktop/capabilities/test_gateway_invocation_proof.py b/src/backend/tests/features/desktop/capabilities/test_gateway_invocation_proof.py
index 9d356cfa..095538d1 100644
--- a/src/backend/tests/features/desktop/capabilities/test_gateway_invocation_proof.py
+++ b/src/backend/tests/features/desktop/capabilities/test_gateway_invocation_proof.py
@@ -1,7 +1,7 @@
"""A desktop gateway reissues native MCP proofs with its authenticated identity."""
from types import SimpleNamespace
import pytest
-from core.llm.mcp import invocation as mcp_invocation, pool as mcp_pool
+from core.llm.mcp import invocation as mcp_invocation, client as mcp_pool
from features.desktop.capability import mcp as gateway
@pytest.mark.asyncio
@@ -11,12 +11,12 @@ async def test_gateway_replaces_device_proof_only_for_trusted_native_target(monk
from core.config.mcp_config import mcp_http_url as _mcp_http_url
url = _mcp_http_url("browser_runtime") if native else "https://third-party.example/mcp"
captured = {}
- async def invoke(**arguments):
- return SimpleNamespace(metadata={}, model_dump=lambda **kwargs: {"content": []})
class Client:
- execution_timeout = 1
- async def get_tool(self, name):
- return invoke
+ async def __aenter__(self): return self
+ async def __aexit__(self, *exc): pass
+ async def call_tool(self, name, arguments):
+ from mcp.types import CallToolResult
+ return CallToolResult(content=[])
def make_client(sid, config, **kwargs):
captured.update(config["headers"])
return Client()
diff --git a/src/backend/tests/features/desktop/capabilities/test_gateway_unknown_outcome.py b/src/backend/tests/features/desktop/capabilities/test_gateway_unknown_outcome.py
index 68d33f28..377ed8d0 100644
--- a/src/backend/tests/features/desktop/capabilities/test_gateway_unknown_outcome.py
+++ b/src/backend/tests/features/desktop/capabilities/test_gateway_unknown_outcome.py
@@ -7,7 +7,7 @@
from agentscope.tool import Toolkit
from core.llm.runtime.state import AgentRuntimeState
from tests.orchestration.hook_support import durable_adapter
-from core.llm.mcp.pool import make_client
+from core.llm.tools.cloud_manifest import manifest_tools
from core.db.models import ChatRun, ToolEffectLedger
from core.runs.journal import RunJournal
from core.runs.tool_effects.errors import ToolOutcomeUnknown
@@ -36,8 +36,8 @@ async def upstream(request):
return httpx.Response(200, content=b"lost-result")
return httpx.Response(int(failure), json={"detail": "upstream result unavailable"})
- client = make_client("search", _config(httpx.MockTransport(upstream)), is_stateful=False)
- toolkit = Toolkit(mcps=[client])
+ client = manifest_tools("search", _config(httpx.MockTransport(upstream)))
+ toolkit = Toolkit(tools=client)
state = AgentRuntimeState(run_id="cloud-write", journal_owner="worker")
middleware = durable_adapter(sessions)
@@ -80,9 +80,9 @@ async def test_rejected_write_does_not_claim_an_unknown_effect(monkeypatch, stat
async def rejected(_request):
return httpx.Response(status, json={"detail": "not authorized or stale schema"})
- client = make_client("search", _config(httpx.MockTransport(rejected)), is_stateful=False)
+ client = manifest_tools("search", _config(httpx.MockTransport(rejected)))
with pytest.raises(RuntimeError):
- await (await client.get_tool("search"))(query="write")
+ await next(t for t in client if t.name == "search")(query="write")
@pytest.mark.asyncio
@@ -94,9 +94,9 @@ async def timeout(request):
config = _config(httpx.MockTransport(timeout))
config["manifest_tools"][0]["annotations"] = {"readOnlyHint": True}
- client = make_client("search", config, is_stateful=False)
+ client = manifest_tools("search", config)
with pytest.raises(RuntimeError, match="调用超时"):
- await (await client.get_tool("search"))(query="read")
+ await next(t for t in client if t.name == "search")(query="read")
@pytest.mark.asyncio
@@ -116,7 +116,7 @@ async def no_local_result(*args, **kwargs):
monkeypatch.setattr(bridge_client, "notify_cloud_changed", lambda: refreshes.append(True))
async def stale(request):
return httpx.Response(409, json={"detail": "schema changed"})
- client = make_client("search", _config(httpx.MockTransport(stale)), is_stateful=False)
+ client = manifest_tools("search", _config(httpx.MockTransport(stale)))
with pytest.raises(RuntimeError, match="正在同步最新能力"):
- await (await client.get_tool("search"))(query="write")
+ await next(t for t in client if t.name == "search")(query="write")
assert refreshes == [True]
diff --git a/src/backend/tests/features/desktop/capabilities/test_management_tools.py b/src/backend/tests/features/desktop/capabilities/test_management_tools.py
index 106ae98d..114436ac 100644
--- a/src/backend/tests/features/desktop/capabilities/test_management_tools.py
+++ b/src/backend/tests/features/desktop/capabilities/test_management_tools.py
@@ -5,16 +5,16 @@
from pathlib import Path
import pytest
from core.capabilities.plugins.management.contract import tools, localize
-from core.llm.mcp.pool import make_client
+from core.llm.tools.cloud_manifest import manifest_tools
from core.capabilities.manifest_protocol import canonical_hash
def manifest_client(manager, specs, user="local-u1", remote=False):
- return make_client("test-manager", {"transport": "streamable_http", "url": "http://localhost/inert",
+ return manifest_tools("test-manager", {"transport": "streamable_http", "url": "http://localhost/inert",
"schema_source": "cloud_manifest" if remote else "plugin_manifest", "origin": "local_plugin", "manifest_tools": specs,
"gateway_invoke_url": "https://cloud.example/api/v1/desktop/capability/gateway/test-manager/call" if remote else "",
"schema_hash": canonical_hash(specs), "gateway_plugin": manager,
- "headers": {"X-Current-User-Id": user}}, is_stateful=False)
+ "headers": {"X-Current-User-Id": user}})
@pytest.mark.asyncio
@@ -23,7 +23,7 @@ async def test_declared_local_install_updates_page_projection(client, tmp_path,
from core.harness.registry import execution_catalog
monkeypatch.setattr(execution_catalog, "local_plugin_configs", lambda user, enabled_only=True: {"test-manager": {"gateway_plugin": "skill-manager"}} if user == "local-u1" else {})
mcp = manifest_client("skill-manager", tools("skill-manager", "cloud"))
- tool = await mcp.get_tool("install_skill")
+ tool = next(t for t in mcp if t.name == "install_skill")
assert "artifact_id" not in json.dumps(tool.input_schema)
root = tmp_path / "from-tool"
root.mkdir()
@@ -34,7 +34,7 @@ async def test_declared_local_install_updates_page_projection(client, tmp_path,
assert body["install_id"] in {x["install_id"] for x in device_catalog.catalog_overlay(user_id="local-u1")["skills"]}
rejected = await tool(source={"kind": "artifact", "artifact_id": "not-local"})
assert json.loads(rejected.content[0].text)["ok"] is False
- assert "upload_skill_to_cloud" not in [t.name for t in await mcp.list_tools()]
+ assert "upload_skill_to_cloud" not in [t.name for t in mcp]
@pytest.mark.asyncio
@@ -47,8 +47,8 @@ async def test_offline_plugin_package_supplies_manager_without_remote_discovery(
servers = configs("local-u1")
sid = installed["components"]["mcp"][0]
assert sid in servers
- mcp = make_client(sid, servers[sid], is_stateful=False)
- names = [t.name for t in await mcp.list_tools()]
+ mcp = manifest_tools(sid, servers[sid])
+ names = [t.name for t in mcp]
assert "install_skill" in names
assert "upload_skill_to_cloud" not in names
assert not configs("other")
@@ -71,8 +71,8 @@ async def test_installed_local_manager_exposes_upload_only_to_bound_cloud_user(c
root = Path(__file__).resolve().parents[4] / "extensions/plugins/marketplace/skill-manager"
installed = local_plugin_service.install("local-u1", str(root))
sid = installed["components"]["mcp"][0]
- mcp = make_client(sid, configs("local-u1")[sid], is_stateful=False)
- assert "upload_skill_to_cloud" in [t.name for t in await mcp.list_tools()]
+ mcp = manifest_tools(sid, configs("local-u1")[sid])
+ assert "upload_skill_to_cloud" in [t.name for t in mcp]
@pytest.mark.asyncio
@@ -84,13 +84,13 @@ async def test_cloud_delivered_manager_contract_executes_locally(client, tmp_pat
monkeypatch.setattr(bridge_gateway, 'bridge_context', lambda: {"servers": [{"server_id": "test-manager", "source_plugin": "skill-manager", "tools": specs}]})
monkeypatch.setattr('features.desktop.capabilities.skill_account.current_local_user_id', lambda: "local-u1")
mcp = manifest_client("skill-manager", specs, remote=True)
- tool = await mcp.get_tool("install_skill")
+ tool = next(t for t in mcp if t.name == "install_skill")
root = tmp_path / "hybrid-skill"
root.mkdir()
(root / "SKILL.md").write_text("---\nname: hybrid-skill\ndescription: Hybrid\n---\nBody")
result = json.loads((await tool(source={"kind": "local_path", "path": str(root)})).content[0].text)
assert result["ok"] and result["source"] == "local"
- assert "upload_skill_to_cloud" in [t.name for t in await mcp.list_tools()]
+ assert "upload_skill_to_cloud" in [t.name for t in mcp]
monkeypatch.setattr(bridge_gateway, 'bridge_context', lambda: {"servers": []})
revoked = json.loads((await tool(source={"kind": "local_path", "path": str(root)})).content[0].text)
assert not revoked["ok"]
diff --git a/src/backend/tests/features/desktop/capabilities/test_manifest_tool_schema.py b/src/backend/tests/features/desktop/capabilities/test_manifest_tool_schema.py
index 54ec9269..2e6432bf 100644
--- a/src/backend/tests/features/desktop/capabilities/test_manifest_tool_schema.py
+++ b/src/backend/tests/features/desktop/capabilities/test_manifest_tool_schema.py
@@ -13,8 +13,8 @@
from agentscope.mcp import HttpMCPConfig
-from core.llm.mcp.manager import ManifestMCPClient, has_usable_schema
-from core.llm.mcp.pool import uses_manifest_schema
+from core.capabilities.mcp.tool_schema import has_usable_schema
+from core.llm.tools.cloud_manifest import manifest_tools
from core.capabilities.plugins.management.components import merge_tool_metadata as _merge_tool_metadata
REAL_SCHEMA = {
@@ -46,26 +46,19 @@ def test_argument_less_tool_is_still_a_captured_schema():
"""A server may describe an argument-less tool tersely; that is not a gap."""
assert has_usable_schema({"name": "list_sites", "inputSchema": ARGLESS_SCHEMA})
assert has_usable_schema({"name": "list_sites", "inputSchema": {"type": "object"}})
- assert uses_manifest_schema(_cfg([{"name": "list_sites", "inputSchema": {"type": "object"}}]))
+ assert manifest_tools("s", _cfg([{"name": "list_sites", "inputSchema": {"type": "object"}}]))
def test_manifest_without_any_schema_is_rejected():
- assert not uses_manifest_schema(_cfg([{"name": "publish_site", "description": "..."}]))
+ assert manifest_tools("s", _cfg([{"name": "publish_site", "description": "..."}])) == []
def test_client_withholds_the_tool_it_cannot_describe():
- client = ManifestMCPClient(
- name="sites-site_publish",
- is_stateful=False,
- mcp_config=HttpMCPConfig(url="https://cloud.example/call"),
- manifest_tools=[
- {"name": "publish_site", "description": "发布站点"},
- {"name": "list_sites", "description": "查询站点", "inputSchema": ARGLESS_SCHEMA},
- ],
- gateway_invoke_url="https://cloud.example/call",
- schema_hash="hash",
- )
- names = [tool.name for tool in client._raw_manifest_tools()]
+ tools = manifest_tools("sites-site_publish", _cfg([
+ {"name": "publish_site", "description": "发布站点"},
+ {"name": "list_sites", "description": "查询站点", "inputSchema": ARGLESS_SCHEMA},
+ ]))
+ names = [tool.name for tool in tools]
assert names == ["list_sites"], "参数丢失的工具不能交给模型"
diff --git a/src/backend/tests/features/desktop/capabilities/test_owned_connector_assembly.py b/src/backend/tests/features/desktop/capabilities/test_owned_connector_assembly.py
index 9733f87d..184b2d11 100644
--- a/src/backend/tests/features/desktop/capabilities/test_owned_connector_assembly.py
+++ b/src/backend/tests/features/desktop/capabilities/test_owned_connector_assembly.py
@@ -62,3 +62,15 @@ def test_modern_connector_cannot_revive_revoked_installation(installed, revocati
assert catalog_resolver.resolve_explicit_runtime_capabilities(
db, user, mcp_ids=[sid]
)[1] == []
+
+
+def test_packaged_compose_host_reaches_the_local_plane(client, index_db, monkeypatch):
+ from dataclasses import replace
+ from core.config.settings import settings
+ from extensions.mcp_servers._ports import PORTS
+ monkeypatch.setattr("core.config.settings.settings",
+ replace(settings, server=replace(settings.server, mcp_host="127.0.0.1")))
+ root = Path(__file__).resolve().parents[4] / "extensions/plugins/marketplace/automation"
+ sid = service.install("local-u1", str(root))["components"]["mcp"][0]
+ # The manifest names the compose service ``mcp``; a local plane listens on loopback.
+ assert local_plugin_runtime.configs("local-u1")[sid]["url"] == f"http://127.0.0.1:{PORTS['automation_task']}/mcp"
diff --git a/src/backend/tests/features/desktop/capabilities/test_plugin_connections.py b/src/backend/tests/features/desktop/capabilities/test_plugin_connections.py
index 33713dfe..fbf85b52 100644
--- a/src/backend/tests/features/desktop/capabilities/test_plugin_connections.py
+++ b/src/backend/tests/features/desktop/capabilities/test_plugin_connections.py
@@ -12,6 +12,7 @@
from core.db.engine import get_db
from features.desktop.capability import entities as entities
from tests.features.desktop.capabilities.test_agents_plugins import cloud_db # noqa: F401
+from tests.db_support import share_session
@pytest.mark.parametrize("connection", ["yida", "lark", "dingtalk", "email"])
@@ -37,6 +38,7 @@ def test_device_detail_exposes_connection(index_db, caps_root, monkeypatch, conn
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_db] = lambda: None
+ share_session(monkeypatch, None)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id="owner", user_center_id="owner", username="owner")
with TestClient(app) as client:
diff --git a/src/backend/tests/features/desktop/capabilities/test_progressive_plugins.py b/src/backend/tests/features/desktop/capabilities/test_progressive_plugins.py
index 695d72be..aa9ae126 100644
--- a/src/backend/tests/features/desktop/capabilities/test_progressive_plugins.py
+++ b/src/backend/tests/features/desktop/capabilities/test_progressive_plugins.py
@@ -11,7 +11,7 @@
from features.desktop.capabilities import plugins, skills, runtime
from core.capabilities.plugins import runtime as plugin_loader
from core.capabilities.manifest_protocol import skill_content_hash
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools import plugin_activation
@@ -42,8 +42,8 @@ def test_desktop_directory_defers_skill_until_load_plugin(index_db, caps_root, m
"run", "owner", skill_ids=["report"], plugin_ids=[plan.directory[0].install_id]
)
run = runtime.preflight(run, plugin_ids=[plan.directory[0].install_id])
- basic = SimpleNamespace(mcps=[], skills_or_loaders=[])
- collector = ToolCollector()
+ basic = SimpleNamespace(tools=[], skills_or_loaders=[])
+ collector = ToolAssembly()
context = {
"toolkit": SimpleNamespace(tool_groups=[basic]),
"loader": runtime.frozen_loader(run),
@@ -53,7 +53,7 @@ def test_desktop_directory_defers_skill_until_load_plugin(index_db, caps_root, m
}
plugin_activation.register_load_plugin(collector, plan.deferred_by_slug(), context)
assert basic.skills_or_loaders == []
- tool = next(t for t in collector.function_tools if t.name == "load_plugin")
+ tool = next(t for t in list(collector) if t.name == "load_plugin")
asyncio.run(tool(plugin="reports"))
assert len(basic.skills_or_loaders) == 1
@@ -119,8 +119,8 @@ def test_load_plugin_rechecks_definition_before_exposing_skills(
run = runtime.preflight(
run)
loader = runtime.frozen_loader(run)
- basic = SimpleNamespace(mcps=[], skills_or_loaders=[])
- collector = ToolCollector()
+ basic = SimpleNamespace(tools=[], skills_or_loaders=[])
+ collector = ToolAssembly()
plugin_activation.register_load_plugin(
collector,
plan.deferred_by_slug(),
@@ -137,7 +137,7 @@ def test_load_plugin_rechecks_definition_before_exposing_skills(
else:
component.entry_file.write_text("{}")
with pytest.raises(CapabilityError):
- asyncio.run(collector.get_tool("load_plugin")._func("reports"))
+ asyncio.run(next(t for t in collector if t.name == "load_plugin")._func("reports"))
assert basic.skills_or_loaders == []
@@ -224,7 +224,7 @@ async def test_load_plugin_adds_frozen_mcp_schema_to_live_toolkit(
runtime.bind_mcp(run, configs, None)
run = runtime.preflight(
run, available_mcp={"lookup-mcp"})
- collector = ToolCollector()
+ collector = ToolAssembly()
context = {
"prepared_run": run,
"prepared_servers": configs,
@@ -233,11 +233,11 @@ async def test_load_plugin_adds_frozen_mcp_schema_to_live_toolkit(
"close_list": [],
}
plugin_activation.register_load_plugin(collector, plan.deferred_by_slug(), context)
- toolkit = Toolkit(tools=collector.function_tools)
+ toolkit = Toolkit(tools=list(collector))
context["toolkit"] = toolkit
before = await toolkit.get_tool_schemas()
assert "find_record" not in str(before)
- await collector.get_tool("load_plugin")(plugin="lookup")
+ await next(t for t in collector if t.name == "load_plugin")(plugin="lookup")
assert "find_record" in str(await toolkit.get_tool_schemas())
for client in context["close_list"]:
await client.close()
@@ -427,7 +427,7 @@ async def test_available_namespaced_plugin_publishes_tools_after_activation(
runtime.bind_mcp(run, configs, None)
run = runtime.preflight(
run, available_mcp=set(configs))
- collector = ToolCollector()
+ collector = ToolAssembly()
context = {
"prepared_run": run,
"prepared_servers": configs,
@@ -436,10 +436,10 @@ async def test_available_namespaced_plugin_publishes_tools_after_activation(
"close_list": [],
}
plugin_activation.register_load_plugin(collector, plan.deferred_by_slug(), context)
- toolkit = Toolkit(tools=collector.function_tools)
+ toolkit = Toolkit(tools=list(collector))
context["toolkit"] = toolkit
assert "publish_site" not in str(await toolkit.get_tool_schemas())
- await collector.get_tool("load_plugin")(plugin="sites-private")
+ await next(t for t in collector if t.name == "load_plugin")(plugin="sites-private")
assert "publish_site" in str(await toolkit.get_tool_schemas())
for client in context["close_list"]:
await client.close()
diff --git a/src/backend/tests/features/desktop/capabilities/test_runtime_frozen_bindings.py b/src/backend/tests/features/desktop/capabilities/test_runtime_frozen_bindings.py
index f55cdd07..c3408bfd 100644
--- a/src/backend/tests/features/desktop/capabilities/test_runtime_frozen_bindings.py
+++ b/src/backend/tests/features/desktop/capabilities/test_runtime_frozen_bindings.py
@@ -188,7 +188,9 @@ async def test_skill_reader_uses_frozen_loader_and_cannot_read_other_profile(
loader = runtime.frozen_loader(run)
class Toolkit:
- def register_tool_function(self, fn, **_):
+ def append(self, tool):
+ fn = tool._func
+ _ = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.read = fn
toolkit = Toolkit()
diff --git a/src/backend/tests/features/desktop/capabilities/test_runtime_scope_consumers.py b/src/backend/tests/features/desktop/capabilities/test_runtime_scope_consumers.py
index ad545ee3..ac5bd723 100644
--- a/src/backend/tests/features/desktop/capabilities/test_runtime_scope_consumers.py
+++ b/src/backend/tests/features/desktop/capabilities/test_runtime_scope_consumers.py
@@ -136,12 +136,14 @@ async def start_process(req, yield_time_ms=10000):
)
class Toolkit:
- def register_tool_function(self, fn, **kwargs):
+ def append(self, tool):
+ fn = tool._func
+ kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.fn = fn
toolkit = Toolkit()
loader = SimpleNamespace(capability_run=SimpleNamespace(run_id="run-1", scope_id="step:two"))
- sandbox_tool.register_bash(toolkit, loader=loader, loaded_skill_ids=set(), chat_id="chat-1", user_id="user-1")
+ sandbox_tool.register_bash(toolkit, loader=loader, chat_id="chat-1", user_id="user-1")
await toolkit.fn(command="echo ok")
assert captured[0].capability_run_id == "run-1"
assert captured[0].capability_scope == "step:two"
diff --git a/src/backend/tests/features/desktop/capabilities/test_runtime_scopes.py b/src/backend/tests/features/desktop/capabilities/test_runtime_scopes.py
index 2ff1a78f..3a03886b 100644
--- a/src/backend/tests/features/desktop/capabilities/test_runtime_scopes.py
+++ b/src/backend/tests/features/desktop/capabilities/test_runtime_scopes.py
@@ -4,7 +4,6 @@
from features.desktop.capabilities import skill_candidates
from features.desktop.capabilities import skill_publication
-import core.runs.tool_effect_recovery as runs_tool_effect_recovery
import orchestration.plans.mode as mode
import pytest
from features.desktop.capabilities import registry, runtime, skills
@@ -15,6 +14,7 @@
from features.desktop.bridge import skills as cloud_skills
from core.capabilities.manifest_protocol import skill_content_hash
from tests.features.desktop.capabilities.test_runtime_recovery import _zip, durable_index, state
+from tests.db_support import share_session
@pytest.fixture
@@ -227,6 +227,7 @@ async def create(**kw):
monkeypatch.setattr(mode, "_build_plan_header_lines", lambda *a: ([], []))
monkeypatch.setattr(mode, "_build_step_instruction", lambda step, *a, **kw: step.step_id)
monkeypatch.setattr(mode, "is_run_cancelled", lambda *a: False)
+ monkeypatch.setattr(mode, "_plan_status", lambda db, plan_id: plan.status)
monkeypatch.setattr(plan_mode.log_writer, "start_subagent_log", noop)
monkeypatch.setattr(plan_mode.log_writer, "finish_subagent_log", noop)
monkeypatch.setattr("core.harness.log_context.subagent_scope", lambda *a, **kw: nullcontext())
@@ -236,6 +237,10 @@ async def create(**kw):
monkeypatch.setattr(
'core.ontology.runtime_policy.build_user_ontology_runtime', lambda **kw: (False, {})
)
+ noop_db = lambda *_a, **_k: None # noqa: E731 - the faked PlanService owns all reads
+ share_session(monkeypatch, SimpleNamespace(
+ refresh=noop_db, expunge=noop_db, expunge_all=noop_db, commit=noop_db, rollback=noop_db
+ ))
binding = CURRENT_RUN_BINDING.set(("root-run", "ledger-owner"))
try:
for _ in range(2):
@@ -245,7 +250,6 @@ async def create(**kw):
async for event in plan_mode.astream_execute_plan(
plan_id="plan-1",
user_id="owner",
- db=SimpleNamespace(refresh=lambda *_: None),
run_id="root-run",
)
]
@@ -367,7 +371,7 @@ async def create(**kw):
[],
)
- monkeypatch.setattr(runs_tool_effect_recovery, "SessionLocal", Db)
+ monkeypatch.setattr("core.db.engine.SessionLocal", Db)
monkeypatch.setattr("core.llm.factory.create_agent_executor", create)
intent = SimpleNamespace(
run_id="root-run",
@@ -395,7 +399,7 @@ async def test_registered_child_dispatch_has_scope_even_without_sse(setup, monke
from core.harness.tool_call_context import CURRENT_TOOL_CALL_ID
registered = {}
- toolkit = SimpleNamespace(register_tool_function=lambda fn, **kw: registered.update(call=fn))
+ toolkit = SimpleNamespace(append=lambda tool: registered.update(call=tool._func))
captured = []
def worker(*args):
diff --git a/src/backend/tests/features/desktop/capabilities/test_runtime_session_recovery.py b/src/backend/tests/features/desktop/capabilities/test_runtime_session_recovery.py
index 3755b6fe..cd49d14b 100644
--- a/src/backend/tests/features/desktop/capabilities/test_runtime_session_recovery.py
+++ b/src/backend/tests/features/desktop/capabilities/test_runtime_session_recovery.py
@@ -205,14 +205,14 @@ async def test_revoked_grant_is_decided_by_the_gateway_call(index_db, caps_root,
"""撤权由云端在真实网关调用时裁决:401 立刻清掉本机的桥状态。"""
import httpx
import mcp.types
- from core.llm.mcp.manager import GatewayMCPTool
+ from core.llm.tools.cloud_gateway import GatewayMCPTool
current = [_state_v2("a")]
monkeypatch.setattr(bridge_identity, 'get_state', lambda: current[0])
monkeypatch.setattr(bridge_identity, 'clear_state', lambda: current.__setitem__(0, None))
tool = GatewayMCPTool(
mcp_name="search",
- tool=mcp.types.Tool(name="search", inputSchema={"type": "object"}),
+ tool={"name": "search", "inputSchema": {"type": "object"}},
invoke_url="https://cloud.example/api/v1/desktop/capability/gateway/search/call",
schema_hash="frozen",
headers=bridge_identity.cloud_headers(current[0]),
@@ -230,7 +230,7 @@ async def test_running_gateway_uses_rotated_token_but_rejects_new_account(
):
import httpx
import mcp.types
- from core.llm.mcp.manager import GatewayMCPTool
+ from core.llm.tools.cloud_gateway import GatewayMCPTool
from core.harness.capability_errors import CloudUnavailable
current = [_state_v2("a")]
@@ -243,7 +243,7 @@ def handler(request):
tool = GatewayMCPTool(
mcp_name="search",
- tool=mcp.types.Tool(name="search", inputSchema={"type": "object"}),
+ tool={"name": "search", "inputSchema": {"type": "object"}},
invoke_url="https://cloud.example/api/v1/desktop/capability/gateway/search/call",
schema_hash="frozen",
headers=bridge_identity.cloud_headers(current[0]),
diff --git a/src/backend/tests/features/desktop/capabilities/test_toolkit_runtime_alias.py b/src/backend/tests/features/desktop/capabilities/test_toolkit_runtime_alias.py
index 53dd636b..dfb06d27 100644
--- a/src/backend/tests/features/desktop/capabilities/test_toolkit_runtime_alias.py
+++ b/src/backend/tests/features/desktop/capabilities/test_toolkit_runtime_alias.py
@@ -10,7 +10,7 @@
from agentscope.state import AgentState
from agentscope.tool import Toolkit
from features.desktop.capabilities import registry, runtime, skills
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools.skill_tool import register_sandboxed_view_text_file
from core.capabilities.manifest_protocol import skill_content_hash
@@ -47,7 +47,7 @@ def create(names, physical=False):
if physical:
original_get_dir = loader.get_skill_dir
loader.get_skill_dir = lambda sid: str(Path(original_get_dir(sid)).resolve())
- collector = ToolCollector()
+ collector = ToolAssembly()
assert loader.register_skills_to_toolkit(collector, list(names)) == len(names)
return prepared, loader, collector
@@ -75,7 +75,7 @@ async def test_real_factory_prompt_and_native_registry_preserve_two_custom_alias
Path(loader.get_skill_dir(alias)).resolve()
)
assert native[alias].markdown == marker
- loaded = await toolkit.builtin_skill_viewer.tool(alias, AgentState())
+ loaded = await toolkit.builtin_skill_viewer.tool.call(alias, AgentState())
assert "\n".join(block.text for block in loaded.content) == marker
assert "name: original-cloud-name" in (prepared.view_dir / alias / "SKILL.md").read_text()
assert "- `original-cloud-name`" not in prompt
@@ -95,7 +95,7 @@ async def test_native_projection_is_detached_and_custom_alias_reads_frozen_bytes
assert again["custom-local"].name == "custom-local"
assert again["custom-local"].dir == str(Path(loader.get_skill_dir("custom-local")).resolve())
register_sandboxed_view_text_file(collector, [loader.get_skill_dir("custom-local")], loader)
- tool = collector.get_tool("view_text_file")._func
+ tool = next(t for t in collector if t.name == "view_text_file")._func
response = await tool(str(Path(loader.get_skill_dir("custom-local")).resolve() / "SKILL.md"))
assert "FROZEN_CONTENT" in "\n".join(block.text for block in response.content)
denied = await tool(
@@ -143,7 +143,7 @@ def test_explicit_skill_hint_uses_authorized_alias_instead_of_store_revision(fro
async def test_parallel_runtime_loader_order_bounds_and_failure():
import asyncio
- from core.llm.runtime.tool_collector import _ParallelRuntimeSkillLoader
+ from core.capabilities.skills.runtime_loader import ParallelRuntimeSkillLoader
active = 0
peak = 0
@@ -166,13 +166,13 @@ async def list_skills(self):
finally:
active -= 1
- assert await _ParallelRuntimeSkillLoader([Loader(i) for i in range(8)]).list_skills() == list(
+ assert await ParallelRuntimeSkillLoader([Loader(i) for i in range(8)]).list_skills() == list(
range(8)
)
assert peak <= 4
seen.clear()
with pytest.raises(RuntimeError, match="validation rejected"):
- await _ParallelRuntimeSkillLoader([Loader(i, i == 2) for i in range(8)]).list_skills()
+ await ParallelRuntimeSkillLoader([Loader(i, i == 2) for i in range(8)]).list_skills()
assert sorted(seen) == list(range(8))
assert active == 0
@@ -180,12 +180,12 @@ async def list_skills(self):
@pytest.mark.asyncio
@pytest.mark.parametrize("mutation", ["revoke", "corrupt"])
async def test_desktop_batched_loader_matches_native_and_rechecks(frozen, monkeypatch, mutation):
- from core.llm.runtime import tool_collector
+ from core.capabilities.skills import runtime_loader
prepared, loader, collector = frozen({"alias": "BODY"})
instance = collector.skill_loaders[0]
expected = await instance.list_skills()
- monkeypatch.setattr(tool_collector, "os", SimpleNamespace(name="nt"))
+ monkeypatch.setattr(runtime_loader, "os", SimpleNamespace(name="nt"))
actual = await instance.list_skills()
assert actual == expected
actual[0].markdown = "poison"
diff --git a/src/backend/tests/features/desktop/observability_cases_1.py b/src/backend/tests/features/desktop/observability_cases_1.py
index 97fc1413..49247471 100644
--- a/src/backend/tests/features/desktop/observability_cases_1.py
+++ b/src/backend/tests/features/desktop/observability_cases_1.py
@@ -1,4 +1,5 @@
from tests.features.desktop.observability_support import (bridge_identity, pytest, create_engine, sessionmaker)
+from tests.db_support import share_factory
def test_duplicate_and_stale_delivery_keep_newest_chat(store):
from features.desktop.observability.projection import ingest, list_records
@@ -156,6 +157,7 @@ def database():
app.include_router(route.router, prefix="/api")
app.include_router(route.admin_router, prefix="/api")
app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, store)
client = TestClient(app)
assert (
client.post("/api/v1/desktop/observability/batch", json={"events": []}).status_code == 401
diff --git a/src/backend/tests/features/desktop/test_desktop_capability.py b/src/backend/tests/features/desktop/test_desktop_capability.py
index f8caf655..a0cc2413 100644
--- a/src/backend/tests/features/desktop/test_desktop_capability.py
+++ b/src/backend/tests/features/desktop/test_desktop_capability.py
@@ -276,7 +276,6 @@ def test_capability_manifest_contains_current_sanitized_schemas(monkeypatch, db_
transport="streamable_http",
url="https://mcp.example/mcp?api_key=must-not-leak",
headers={"Authorization": "encrypted-secret"},
- is_stable=False,
is_enabled=True,
tools_json=[
{
@@ -345,7 +344,6 @@ def test_resolve_gateway_tool_rejects_a_stale_schema(monkeypatch, db_session):
description="",
transport="streamable_http",
url="https://mcp.example/mcp",
- is_stable=False,
is_enabled=True,
tools_json=tools,
)
diff --git a/src/backend/tests/features/desktop/test_desktop_sync_locking.py b/src/backend/tests/features/desktop/test_desktop_sync_locking.py
index 0f2801ec..8d404fb0 100644
--- a/src/backend/tests/features/desktop/test_desktop_sync_locking.py
+++ b/src/backend/tests/features/desktop/test_desktop_sync_locking.py
@@ -121,10 +121,11 @@ def authenticate(request, db):
return backend.UserContext(user_id="alice", user_center_id="center", username="Alice")
from core.harness.registry import authentication
+ monkeypatch.setattr(authentication.registration_state(), "claims", lambda request: True)
monkeypatch.setattr(authentication.registration_state(), "resolver", authenticate)
try:
result = await backend.get_current_user(
- Request({"type": "http", "headers": []}), None, None
+ Request({"type": "http", "headers": []}), None
)
assert result.user_id == "alice"
assert progressed == [True], "authentication blocked the event loop"
diff --git a/src/backend/tests/features/evaluation/test_evaluation_runtime.py b/src/backend/tests/features/evaluation/test_evaluation_runtime.py
index 230361ea..36f573fd 100644
--- a/src/backend/tests/features/evaluation/test_evaluation_runtime.py
+++ b/src/backend/tests/features/evaluation/test_evaluation_runtime.py
@@ -64,12 +64,10 @@ def test_api_and_eval_scope_cannot_be_combined():
"agent_api_scope": {"version": 1}})
-class Collector:
- def __init__(self):
- self.tools = {}
-
- def register_tool_function(self, func, **kwargs):
- self.tools[func.__name__] = func
+class Collector(list):
+ @property
+ def tools(self):
+ return {tool.name: tool._func for tool in self}
@pytest.fixture
@@ -169,7 +167,8 @@ async def test_artifact_registration_is_rejected_before_io(evaluation_tools):
async def test_paths_are_task_local_during_concurrent_calls_and_reset_afterward(monkeypatch):
import asyncio
from features.evaluation.runtime.runtime import CURRENT_EVALUATION_SCOPE
- from features.evaluation.runtime.tools import _EvaluationCollector
+ from features.evaluation.runtime.tools import scoped_tool
+ from core.llm.factory.tools.definitions import function_tool
from core.sandbox.workspace.resolution import validate_workspace_path
collector = Collector()
scope = parse_evaluation_scope(owner_user_id="owner", chat_id=SESSION)
@@ -182,7 +181,7 @@ async def probe():
assert validate_workspace_path("/app/file") is None
raise RuntimeError("intentional")
- _EvaluationCollector(collector, scope).register_tool_function(probe)
+ collector.append(scoped_tool(function_tool(probe), scope))
task = asyncio.create_task(collector.tools["probe"]())
await entered.wait()
assert CURRENT_EVALUATION_SCOPE.get() is None
@@ -210,7 +209,6 @@ def test_evaluation_command_registration_respects_disabled_tools(monkeypatch):
from features.evaluation.runtime.process_tools import register_evaluation_process_tools
from features.evaluation.runtime.runtime import EvaluationExecutionScope
monkeypatch.setenv("SANDBOX_TOOLS_ENABLED", "false")
- registered = []
- toolkit = SimpleNamespace(register_tool_function=lambda *args, **kw: registered.append(args))
+ toolkit = []
register_evaluation_process_tools(toolkit, EvaluationExecutionScope("u", SESSION))
- assert registered == []
+ assert toolkit == []
diff --git a/src/backend/tests/features/evaluation/test_evaluation_service.py b/src/backend/tests/features/evaluation/test_evaluation_service.py
index 496b1df1..c23ef4d0 100644
--- a/src/backend/tests/features/evaluation/test_evaluation_service.py
+++ b/src/backend/tests/features/evaluation/test_evaluation_service.py
@@ -39,6 +39,7 @@ async def test_one_sandbox_with_no_personal_mounts(sdk):
assert create.call_count == 1
assert create.call_args.kwargs["volumes"] is None
assert create.call_args.kwargs["env"] is None
+ assert create.call_args.kwargs["skip_health_check"] is False
await service.upload(item["lease_id"], "owner", "/attempt/workspace/input.bin", b"data")
await service.execute(item["lease_id"], "owner", argv=["python3", "-c", "print(1)"])
assert all(call.args[0] == sandbox.id for call in connect.call_args_list)
diff --git a/src/backend/tests/features/hosted_apps/application_hosting_support.py b/src/backend/tests/features/hosted_apps/application_hosting_support.py
index b9c93fcd..733c802a 100644
--- a/src/backend/tests/features/hosted_apps/application_hosting_support.py
+++ b/src/backend/tests/features/hosted_apps/application_hosting_support.py
@@ -12,6 +12,7 @@
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
+from tests.db_support import share_factory
@pytest.fixture(params=["sqlite", "postgres"])
@@ -67,6 +68,7 @@ def platform_dependency():
app = FastAPI()
app.dependency_overrides[get_db] = platform_dependency
+ share_factory(monkeypatch, platform_sessions)
app.include_router(applications.router)
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(user_id="owner")
with TestClient(app) as client:
diff --git a/src/backend/tests/features/hosted_apps/test_application_agent_contract.py b/src/backend/tests/features/hosted_apps/test_application_agent_contract.py
index ecdf63a3..3baad4f5 100644
--- a/src/backend/tests/features/hosted_apps/test_application_agent_contract.py
+++ b/src/backend/tests/features/hosted_apps/test_application_agent_contract.py
@@ -3,6 +3,7 @@
import asyncio
from tests.features.hosted_apps.test_application_hosting import hosted
+from tests.db_support import share_session
def test_mcp_exposes_typed_application_payload():
@@ -10,7 +11,7 @@ def test_mcp_exposes_typed_application_payload():
tools = asyncio.run(mcp.list_tools())
tool = next(item for item in tools if item.name == "manage_application")
- definitions = tool.inputSchema.get("$defs", {})
+ definitions = tool.input_schema.get("$defs", {})
assert definitions["ColumnDefinition"]["properties"]["type"]["enum"] == [
"text",
"integer",
@@ -21,8 +22,8 @@ def test_mcp_exposes_typed_application_payload():
]
assert "TableDefinition" in definitions
assert "IndustryImport" not in definitions
- assert "import_industry" not in tool.inputSchema["properties"]["action"]["enum"]
- assert "app_id" in tool.inputSchema["properties"]
+ assert "import_industry" not in tool.input_schema["properties"]["action"]["enum"]
+ assert "app_id" in tool.input_schema["properties"]
def test_internal_validation_returns_field_errors_without_values(hosted, monkeypatch):
@@ -32,6 +33,7 @@ def test_internal_validation_returns_field_errors_without_values(hosted, monkeyp
client, _ = hosted
client.app.include_router(application_transports.internal_router)
client.app.dependency_overrides[get_db] = lambda: None
+ share_session(monkeypatch, None)
monkeypatch.setattr('features.sites.api.internal_site_auth._check_internal_token', lambda _: None)
response = client.post(
"/v1/internal/applications/operation",
diff --git a/src/backend/tests/features/hosted_apps/test_application_desktop.py b/src/backend/tests/features/hosted_apps/test_application_desktop.py
index 07ca2bd6..b890201e 100644
--- a/src/backend/tests/features/hosted_apps/test_application_desktop.py
+++ b/src/backend/tests/features/hosted_apps/test_application_desktop.py
@@ -96,7 +96,7 @@ async def forward(tool, arguments, **identity):
action="create",
payload={"title": "Form", "site_id": "cloud-site"},
)
- result = await routes.internal_operation(body, x_internal_token="test", db=None)
+ result = await routes.internal_operation(body, x_internal_token="test")
assert result["data"]["id"] == "cloud-app"
assert calls == [
(
@@ -183,6 +183,7 @@ def test_real_local_bootstrap_retains_unmigrated_and_provisions(tmp_path):
code = """
import asyncio
from sqlalchemy import inspect
+import api.app # compose the host (schema lifecycles) as uvicorn does before startup
from api.startup.bootstrap import _startup_ensure_tables
from core.db.engine import engine
from features.hosted_apps.data import ApplicationDataService
@@ -227,8 +228,7 @@ def test_real_local_bootstrap_retains_unmigrated_and_provisions(tmp_path):
@pytest.mark.asyncio
async def test_cloud_gateway_mcp_receipt_is_localized(monkeypatch):
import httpx
- import mcp.types
- from core.llm.mcp.gateway_tool import GatewayMCPTool
+ from core.llm.tools.cloud_gateway import GatewayMCPTool
from features.desktop.bridge import client as bridge
from tests.features.desktop.capabilities.test_runtime_recovery import state
@@ -255,7 +255,7 @@ def cloud(request):
tool = GatewayMCPTool(
mcp_name="sites-site_publish",
source_plugin="sites",
- tool=mcp.types.Tool(name="manage_application", inputSchema={"type": "object"}),
+ tool={"name": "manage_application", "inputSchema": {"type": "object"}},
invoke_url=st["cloud_base"] + "/api/v1/desktop/capability/gateway/sites-site_publish/call",
schema_hash="a" * 64,
timeout=10,
@@ -293,7 +293,7 @@ async def forward(tool, arguments, **identity):
app_id="a" * 32,
tools=[{"name": "find", "description": "Find names", "table": "names", "fields": ["name"]}],
)
- result = await routes.internal_publish_mcp(body, x_internal_token="test", db=None)
+ result = await routes.internal_publish_mcp(body, x_internal_token="test")
assert result["data"]["installed"] is True
assert calls == [
(
diff --git a/src/backend/tests/features/hosted_apps/test_application_hosting.py b/src/backend/tests/features/hosted_apps/test_application_hosting.py
index d2f7eb55..e01ebbf1 100644
--- a/src/backend/tests/features/hosted_apps/test_application_hosting.py
+++ b/src/backend/tests/features/hosted_apps/test_application_hosting.py
@@ -8,6 +8,7 @@
from fastapi import FastAPI
from sqlalchemy import create_engine, inspect, text
from tests.features.hosted_apps.application_hosting_support import hosted
+from tests.db_support import share_factory
def data(response):
@@ -121,7 +122,7 @@ def test_mcp_publish_projection_and_revoke(hosted):
@pytest.mark.asyncio
async def test_external_mcp_protocol_field_allowlist_and_revocation(hosted):
- import httpx
+ import httpx2
from features.hosted_apps.mcp import ApplicationMCPGateway
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
@@ -155,26 +156,26 @@ async def test_external_mcp_protocol_field_allowlist_and_revocation(hosted):
)
gateway = FastAPI()
gateway.mount("/applications-mcp", ApplicationMCPGateway())
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=gateway), base_url="http://testserver"
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=gateway), base_url="http://testserver"
) as anonymous:
assert (await anonymous.post(published["url"], json={})).status_code == 401
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=gateway),
+ async with httpx2.AsyncClient(
+ transport=httpx2.ASGITransport(app=gateway),
headers={"Authorization": "Bearer " + published["token"]},
) as http:
async with streamable_http_client(
"http://testserver" + published["url"], http_client=http
- ) as (read, write, _):
+ ) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
assert [tool.name for tool in tools.tools] == ["query_entries"]
result = await session.call_tool("query_entries", {"name": "Aaron"})
- assert not result.isError
- assert result.structuredContent["items"] == [{"name": "Aaron"}]
+ assert not result.is_error
+ assert result.structured_content["items"] == [{"name": "Aaron"}]
result = await session.call_tool("query_entries", {"email": "private@example.test"})
- assert result.isError
+ assert result.is_error
data(client.delete(f"/v1/applications/{app_id}/mcp"))
assert (await http.post("http://testserver" + published["url"], json={})).status_code == 401
@@ -223,6 +224,7 @@ def database():
yield db
client.app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, factory)
client.app.include_router(application_transports.public_router)
client.app.include_router(sites_serve.router)
yield client, ids
diff --git a/src/backend/tests/features/hosted_apps/test_application_personal_mcp.py b/src/backend/tests/features/hosted_apps/test_application_personal_mcp.py
index 1faaa3f3..0af69188 100644
--- a/src/backend/tests/features/hosted_apps/test_application_personal_mcp.py
+++ b/src/backend/tests/features/hosted_apps/test_application_personal_mcp.py
@@ -8,6 +8,7 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from tests.features.hosted_apps.test_application_hosting import create, data, hosted, table
+from tests.db_support import share_factory
@pytest.fixture
@@ -35,6 +36,7 @@ def db_dependency():
yield db
client.app.dependency_overrides[get_db] = db_dependency
+ share_factory(monkeypatch, factory)
client.app.include_router(application_transports.internal_router)
client.app.include_router(application_transports.mcp_router)
monkeypatch.setattr('features.sites.api.internal_site_auth._check_internal_token', lambda _: None)
@@ -219,9 +221,9 @@ def test_publish_tool_has_typed_tools_and_requires_injected_identity(monkeypatch
from extensions.mcp_servers.site_publish_mcp.server import mcp, publish_mcp
tool = next(item for item in asyncio.run(mcp.list_tools()) if item.name == "publish_mcp")
- assert set(tool.inputSchema["required"]) == {"app_id", "tools"}
- assert "user_id" not in tool.inputSchema["properties"]
- assert "ToolDefinition" in tool.inputSchema["$defs"]
+ assert set(tool.input_schema["required"]) == {"app_id", "tools"}
+ assert "user_id" not in tool.input_schema["properties"]
+ assert "ToolDefinition" in tool.input_schema["$defs"]
assert asyncio.run(publish_mcp("a" * 32, [])) == {"error": "Current user identity is required"}
diff --git a/src/backend/tests/features/hosted_apps/test_application_project_tools.py b/src/backend/tests/features/hosted_apps/test_application_project_tools.py
index 8985cc7a..8ae6c961 100644
--- a/src/backend/tests/features/hosted_apps/test_application_project_tools.py
+++ b/src/backend/tests/features/hosted_apps/test_application_project_tools.py
@@ -34,7 +34,9 @@ class Toolkit:
def __init__(self):
self.functions = {}
- def register_tool_function(self, function, **kwargs):
+ def append(self, tool):
+ function = tool._func
+ kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.functions[function.__name__] = function
diff --git a/src/backend/tests/features/hosted_apps/test_hosting_remediation.py b/src/backend/tests/features/hosted_apps/test_hosting_remediation.py
index ae04f069..acaea287 100644
--- a/src/backend/tests/features/hosted_apps/test_hosting_remediation.py
+++ b/src/backend/tests/features/hosted_apps/test_hosting_remediation.py
@@ -89,10 +89,10 @@ def test_anonymous_cannot_evict_recovery_baseline(hosted):
async def test_mcp_limit_schema_has_bounds():
from features.hosted_apps.mcp import query_tool
from features.hosted_apps.schema import ToolDefinition, TableDefinition
- from mcp.server.fastmcp import FastMCP
+ from mcp.server.mcpserver import MCPServer
app = {"tables": {"entries": TableDefinition(name="entries", columns=[{"name": "title"}]).model_dump()}}
tool = ToolDefinition(name="entries", description="Read entries", table="entries", fields=["title"])
- mcp = FastMCP("probe")
+ mcp = MCPServer("probe")
mcp.add_tool(query_tool(None, app, tool))
- schema = (await mcp.list_tools())[0].inputSchema["properties"]["limit"]
+ schema = (await mcp.list_tools())[0].input_schema["properties"]["limit"]
assert schema["maximum"] == 100 and schema["minimum"] == 1
diff --git a/src/backend/tests/features/knowledge/test_kb_backend_routing.py b/src/backend/tests/features/knowledge/test_kb_backend_routing.py
index 559bbd75..d8dcd3cb 100644
--- a/src/backend/tests/features/knowledge/test_kb_backend_routing.py
+++ b/src/backend/tests/features/knowledge/test_kb_backend_routing.py
@@ -7,7 +7,6 @@
from types import SimpleNamespace
import pytest
-from mcp import types
from features.knowledge import edition as external_provider, kb_vector
from core.config.store.system_config import SystemConfigService
@@ -144,29 +143,40 @@ async def external(**kwargs):
assert local_data == []
+@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ["custom", "dify", "fastgpt", "weknora"])
-def test_public_tool_remains_available_for_selected_backend(monkeypatch, provider):
+async def test_public_tool_remains_available_for_selected_backend(monkeypatch, provider):
+ from core.llm.mcp.lifetime import discover
+ from tests.llm.test_mcp_parallel_execution import probe_server
+
configure(monkeypatch, provider)
monkeypatch.setattr(wiki_impl, "wiki_supported", lambda: False)
- handler = server.mcp._mcp_server.request_handlers[types.ListToolsRequest]
- result = asyncio.run(handler(types.ListToolsRequest(method="tools/list")))
- names = {tool.name for tool in result.root.tools}
- assert {"retrieve_dataset_content", "retrieve_local_kb", "list_datasets"} <= names
+ async with probe_server(server.mcp) as (url, _):
+ resource = await discover("retrieval", {"transport": "streamable_http", "url": url})
+ try:
+ names = {tool.name for tool in resource.tools}
+ assert {"retrieve_dataset_content", "retrieve_local_kb", "list_datasets"} <= names
+ finally:
+ await resource.close()
+
+@pytest.mark.asyncio
+async def test_local_public_result_survives_mcp_protocol(monkeypatch, local_data):
+ from core.llm.mcp.lifetime import discover
+ from tests.llm.test_mcp_parallel_execution import probe_server
-def test_local_public_result_survives_mcp_protocol(monkeypatch, local_data):
configure(monkeypatch, "custom")
- monkeypatch.setenv("CURRENT_USER_ID", "reader")
- handler = server.mcp._mcp_server.request_handlers[types.CallToolRequest]
- request = types.CallToolRequest(
- method="tools/call",
- params=types.CallToolRequestParams(
- name="retrieve_dataset_content", arguments={"query": "乘法定义"}
- ),
- )
- result = asyncio.run(handler(request))
- payload = json.loads(result.root.content[0].text)
- assert payload["items"][0]["content"] == "3 × 4 = 12"
+ async with probe_server(server.mcp) as (url, _):
+ resource = await discover("retrieval", {"transport": "streamable_http", "url": url,
+ "headers": {"X-Current-User-Id": "reader"}})
+ try:
+ tool = next(t for t in resource.tools if t.name == "retrieve_dataset_content")
+ result = await tool(query="乘法定义")
+ payload = result.metadata["mcp"]["structuredContent"]["result"]
+ assert "items" in payload, payload
+ assert payload["items"][0]["content"] == "3 × 4 = 12"
+ finally:
+ await resource.close()
@pytest.mark.parametrize("provider", ["custom", "fastgpt"])
diff --git a/src/backend/tests/features/marketplace/mcp_marketplace_cases_1.py b/src/backend/tests/features/marketplace/mcp_marketplace_cases_1.py
index 7f134763..2d9a64fe 100644
--- a/src/backend/tests/features/marketplace/mcp_marketplace_cases_1.py
+++ b/src/backend/tests/features/marketplace/mcp_marketplace_cases_1.py
@@ -55,70 +55,35 @@
)
@pytest.mark.asyncio
-async def test_http_probe_uses_stateless_client_without_explicit_close(db, monkeypatch):
- calls = {"connected": 0, "closed": 0, "is_stateful": None}
-
- class FakeClient:
- async def connect(self):
- calls["connected"] += 1
-
- async def list_tools(self):
- return [SimpleNamespace(name="search", description="Search", inputSchema={})]
-
+async def test_http_probe_releases_its_discovery_resource(db, monkeypatch):
+ from mcp.types import Tool
+ from core.harness.registry import mcp_clients
+ calls = []
+ class Resource:
+ descriptors = [Tool(name="search", description="Search", input_schema={"type":"object"})]
async def close(self):
- calls["closed"] += 1
-
- def _make_client(name, cfg, *, is_stateful=True, **kwargs):
- calls["is_stateful"] = is_stateful
- return FakeClient()
-
- from core.harness.registry import mcp_clients as mcp_pool
-
- monkeypatch.setattr(mcp_pool, "make_client", _make_client)
- row = AdminMcpServer(
- server_id="stateless_probe",
- display_name="Stateless Probe",
- transport="streamable_http",
- url="https://mcp.example.test/mcp",
- headers={},
- is_enabled=True,
- )
-
+ calls.append("closed")
+ async def discover(name, cfg):
+ calls.append(name)
+ return Resource()
+ monkeypatch.setattr(mcp_clients, "discover_tools", discover)
+ row = AdminMcpServer(server_id="probe", display_name="Probe", transport="streamable_http", url="https://mcp.example.test/mcp", headers={}, is_enabled=True)
ok, error = await management.probe_mcp_connectivity(row)
-
- assert ok is True
- assert error == ""
- assert calls == {"connected": 0, "closed": 0, "is_stateful": False}
+ assert ok and not error
+ assert calls == ["probe", "closed"]
assert row.tools_json[0]["name"] == "search"
@pytest.mark.asyncio
async def test_http_probe_surfaces_401_instead_of_cancel_scope_noise(db, monkeypatch):
- request = httpx.Request("POST", "https://mcp.example.test/mcp")
- response = httpx.Response(401, request=request)
- unauthorized = httpx.HTTPStatusError(
- "401 Unauthorized",
- request=request,
- response=response,
- )
-
- class FakeClient:
- async def list_tools(self):
- raise BaseExceptionGroup(
- "MCP request failed",
- [
- unauthorized,
- asyncio.CancelledError("Cancelled via cancel scope deadbeef"),
- ],
- )
-
- def _make_client(name, cfg, *, is_stateful=True, **kwargs):
- assert is_stateful is False
- return FakeClient()
-
- from core.harness.registry import mcp_clients as mcp_pool
-
- monkeypatch.setattr(mcp_pool, "make_client", _make_client)
+ import httpx2
+ request = httpx2.Request("POST", "https://mcp.example.test/mcp")
+ response = httpx2.Response(401, request=request)
+ unauthorized = httpx2.HTTPStatusError("401 Unauthorized", request=request, response=response)
+ async def discover(name, cfg):
+ raise BaseExceptionGroup("MCP request failed", [unauthorized, asyncio.CancelledError("Cancelled via cancel scope")])
+ from core.harness.registry import mcp_clients
+ monkeypatch.setattr(mcp_clients, "discover_tools", discover)
row = AdminMcpServer(
server_id="unauthorized_probe",
display_name="Unauthorized Probe",
@@ -177,7 +142,6 @@ def test_runtime_query_credentials_are_encrypted_and_never_sent_as_headers():
transport="streamable_http",
url="https://mcp.amap.com/mcp?locale=zh-CN",
headers=encrypted,
- is_stable=False,
is_enabled=True,
)
runtime_config = McpServerConfigService.get_instance()._row_to_config(row)
@@ -220,14 +184,14 @@ async def test_oauth_bundle_is_provider_neutral_and_never_sent_as_header():
transport="streamable_http",
url="https://mcp.example.test/mcp",
headers=encrypted,
- is_stable=False,
is_enabled=True,
)
runtime_config = McpServerConfigService.get_instance()._row_to_config(row)
assert "oauth_provider" in runtime_config
assert "headers" not in runtime_config
- client = make_client(row.server_id, runtime_config, is_stateful=False)
- assert client.oauth_provider is runtime_config["oauth_provider"]
+ client = make_client(row.server_id, runtime_config)
+ assert client.mode == "auto"
+ assert client.server.args[1]["oauth_provider"] is runtime_config["oauth_provider"]
@pytest.mark.asyncio
@@ -236,6 +200,7 @@ async def test_oauth_callback_and_status_are_shared_without_plaintext_code(monke
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
monkeypatch.setattr("core.infra.ephemeral.get_redis", lambda **_: fake_redis)
+ monkeypatch.setattr("core.infra.ephemeral.redis_configured", lambda: True)
flow = oauth_service.OAuthInstallFlow(
flow_id="mcpoauth_test",
slug="gitlab-official",
@@ -267,7 +232,8 @@ async def test_oauth_start_hands_back_before_discovery_finishes(db, monkeypatch)
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
monkeypatch.setattr("core.infra.ephemeral.get_redis", lambda **_: fake_redis)
- monkeypatch.setattr("core.capabilities.mcp.oauth_state.DISCOVERY_GRACE_SECONDS", 0.05)
+ monkeypatch.setattr("core.infra.ephemeral.redis_configured", lambda: True)
+ monkeypatch.setattr("core.capabilities.mcp.oauth.DISCOVERY_GRACE_SECONDS", 0.05)
mcp_curated.ensure_curated_market_items(db)
async def slow_discovery(flow):
@@ -280,7 +246,6 @@ async def slow_discovery(flow):
monkeypatch.setattr(oauth_service, "run_flow", slow_discovery)
try:
started = await oauth_service.start_oauth_install(
- db,
slug="gitlab-official",
owner_user_id="u1",
installed_by="u1",
@@ -306,6 +271,7 @@ async def test_oauth_cancel_marks_flow_failed_and_wakes_worker(monkeypatch):
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
monkeypatch.setattr("core.infra.ephemeral.get_redis", lambda **_: fake_redis)
+ monkeypatch.setattr("core.infra.ephemeral.redis_configured", lambda: True)
flow = oauth_service.OAuthInstallFlow(
flow_id="mcpoauth_cancel",
slug="gitlab-official",
@@ -434,7 +400,7 @@ async def test_self_service_mcp_uses_private_network_deployment_setting(
async def _validate(url, **kwargs):
validation.update(url=url, **kwargs)
- async def _probe(row, _db):
+ async def _probe(row, **_options):
row.tools_json = _tools("search")
return True, ""
@@ -457,7 +423,6 @@ async def _probe(row, _db):
url="http://10.10.0.8/mcp",
),
user=SimpleNamespace(user_id="u1"),
- db=db,
)
assert validation == {
diff --git a/src/backend/tests/features/marketplace/mcp_marketplace_cases_2.py b/src/backend/tests/features/marketplace/mcp_marketplace_cases_2.py
index 192a4816..8960dbd8 100644
--- a/src/backend/tests/features/marketplace/mcp_marketplace_cases_2.py
+++ b/src/backend/tests/features/marketplace/mcp_marketplace_cases_2.py
@@ -176,7 +176,6 @@ async def test_submit_snapshots_without_credentials_then_approve(db, monkeypatch
_patch_probe(monkeypatch)
result = await mcp_submissions.submit_to_marketplace(
- db,
source.server_id,
owner_user_id="u1",
submitter_name="Alice",
@@ -201,7 +200,6 @@ async def test_submit_snapshots_without_credentials_then_approve(db, monkeypatch
assert submission.tools_json == _tools("search_documents")
reviewed = await mcp_submissions.review_submission(
- db,
submission.submission_id,
approve=True,
reviewed_by="admin",
@@ -219,7 +217,7 @@ async def test_public_http_marketplace_submit_review_and_install(db, monkeypatch
source.url = "http://8.8.8.8:8082/mcp"
db.commit()
- async def _probe(row, db=None):
+ async def _probe(row, **_options):
return True, ""
monkeypatch.setattr(mcp_installation, 'probe_mcp_connectivity', _probe)
@@ -232,7 +230,6 @@ async def _probe(row, db=None):
monkeypatch.setattr(mcp_publishing, 'refresh_mcp_caches', lambda: None)
submitted = await mcp_submissions.submit_to_marketplace(
- db,
source.server_id,
owner_user_id="u1",
submitter_name="Alice",
@@ -240,13 +237,11 @@ async def _probe(row, db=None):
version="1.0.0",
)
reviewed = await mcp_submissions.review_submission(
- db,
submitted["submission_id"],
approve=True,
reviewed_by="admin",
)
installed = await mcp_installation.install_market_item(
- db,
reviewed["slug"],
owner_user_id="u2",
installed_by="u2",
@@ -282,7 +277,6 @@ async def test_market_installed_private_server_cannot_submit_again(db):
with pytest.raises(BadRequestError, match="不能再次申请上架"):
await mcp_submissions.submit_to_marketplace(
- db,
source.server_id,
owner_user_id="u1",
submitter_name="Alice",
@@ -299,7 +293,6 @@ async def test_submission_strips_query_secret_into_install_schema(db, monkeypatc
_patch_probe(monkeypatch)
submitted = await mcp_submissions.submit_to_marketplace(
- db,
source.server_id,
owner_user_id="u1",
submitter_name="Alice",
@@ -328,10 +321,11 @@ async def test_admin_publish_links_existing_global_instance_without_duplication(
_patch_probe(monkeypatch)
published = await mcp_publishing.publish_admin_server(
- db,
source.server_id,
- category="信息检索",
- version="1.0.0",
+ mcp_publishing.Publication(
+ category="信息检索",
+ version="1.0.0",
+ ),
)
assert published["installed"] is True
installation = db.query(McpMarketInstallation).one()
@@ -355,11 +349,12 @@ async def test_marketplace_first_admin_source_is_reused_on_global_install(db, mo
_patch_probe(monkeypatch)
published = await mcp_publishing.publish_admin_server(
- db,
source.server_id,
- category="信息检索",
- version="1.0.0",
- link_as_installation=False,
+ mcp_publishing.Publication(
+ category="信息检索",
+ version="1.0.0",
+ link_as_installation=False,
+ ),
)
db.refresh(source)
assert published["installed"] is False
@@ -372,7 +367,6 @@ async def test_marketplace_first_admin_source_is_reused_on_global_install(db, mo
assert "publisher-secret" not in json.dumps(published)
installed = await mcp_installation.install_market_item(
- db,
published["slug"],
owner_user_id=None,
installed_by="admin",
@@ -395,11 +389,12 @@ async def test_user_install_reuses_admin_managed_credentials_without_exposing_th
_patch_probe(monkeypatch)
published = await mcp_publishing.publish_admin_server(
- db,
source.server_id,
- category="信息检索",
- version="1.0.0",
- link_as_installation=False,
+ mcp_publishing.Publication(
+ category="信息检索",
+ version="1.0.0",
+ link_as_installation=False,
+ ),
)
listed = mcp_listing.get_market_item(
db,
@@ -412,7 +407,6 @@ async def test_user_install_reuses_admin_managed_credentials_without_exposing_th
assert "publisher-secret" not in json.dumps(listed)
installed = await mcp_installation.install_market_item(
- db,
published["slug"],
owner_user_id="u2",
installed_by="u2",
@@ -438,18 +432,18 @@ async def test_user_install_reuses_admin_managed_query_token(db, monkeypatch):
_patch_probe(monkeypatch)
published = await mcp_publishing.publish_admin_server(
- db,
source.server_id,
- category="信息检索",
- version="1.0.0",
- link_as_installation=False,
+ mcp_publishing.Publication(
+ category="信息检索",
+ version="1.0.0",
+ link_as_installation=False,
+ ),
)
assert published["credentials_managed_by_admin"] is True
assert published["url_origin"] == "https://mcp.example.test"
assert "publisher-query-secret" not in json.dumps(published)
installed = await mcp_installation.install_market_item(
- db,
published["slug"],
owner_user_id="u2",
installed_by="u2",
diff --git a/src/backend/tests/features/marketplace/mcp_marketplace_cases_3.py b/src/backend/tests/features/marketplace/mcp_marketplace_cases_3.py
index 419df6ac..89e44102 100644
--- a/src/backend/tests/features/marketplace/mcp_marketplace_cases_3.py
+++ b/src/backend/tests/features/marketplace/mcp_marketplace_cases_3.py
@@ -62,11 +62,12 @@ async def test_removed_admin_credential_falls_back_to_installer_input(db, monkey
_patch_probe(monkeypatch)
published = await mcp_publishing.publish_admin_server(
- db,
source.server_id,
- category="信息检索",
- version="1.0.0",
- link_as_installation=False,
+ mcp_publishing.Publication(
+ category="信息检索",
+ version="1.0.0",
+ link_as_installation=False,
+ ),
)
source.headers = {}
db.commit()
@@ -81,7 +82,6 @@ async def test_removed_admin_credential_falls_back_to_installer_input(db, monkey
with pytest.raises(BadRequestError, match="请填写安装凭据"):
await mcp_installation.install_market_item(
- db,
published["slug"],
owner_user_id="u2",
installed_by="u2",
@@ -99,11 +99,12 @@ async def test_market_edit_can_require_installer_credentials_without_reusing_sou
_patch_probe(monkeypatch)
published = await mcp_publishing.publish_admin_server(
- db,
source.server_id,
- category="信息检索",
- version="1.0.0",
- link_as_installation=False,
+ mcp_publishing.Publication(
+ category="信息检索",
+ version="1.0.0",
+ link_as_installation=False,
+ ),
)
updated = mcp_administration.update_market_item(
db,
@@ -119,7 +120,6 @@ async def test_market_edit_can_require_installer_credentials_without_reusing_sou
with pytest.raises(BadRequestError, match="请填写安装凭据"):
await mcp_installation.install_market_item(
- db,
published["slug"],
owner_user_id="u2",
installed_by="u2",
@@ -135,11 +135,12 @@ async def test_market_edit_sets_admin_token_and_keeps_it_out_of_responses(db, mo
_patch_probe(monkeypatch)
published = await mcp_publishing.publish_admin_server(
- db,
source.server_id,
- category="信息检索",
- version="1.0.0",
- link_as_installation=False,
+ mcp_publishing.Publication(
+ category="信息检索",
+ version="1.0.0",
+ link_as_installation=False,
+ ),
)
updated = mcp_administration.update_market_item(
db,
@@ -157,7 +158,6 @@ async def test_market_edit_sets_admin_token_and_keeps_it_out_of_responses(db, mo
assert "centrally-managed" not in str(source.headers)
installed = await mcp_installation.install_market_item(
- db,
published["slug"],
owner_user_id="u2",
installed_by="u2",
@@ -190,7 +190,6 @@ async def _fail_probe(*args, **kwargs):
monkeypatch.setattr(mcp_versions, 'refresh_mcp_caches', lambda: None)
monkeypatch.setattr(mcp_publishing, 'refresh_mcp_caches', lambda: None)
monkeypatch.setattr(admin_mcp_routes, "_probe_connectivity", _fail_probe)
- monkeypatch.setattr(admin_mcp_routes, "_sync_catalog_from_db", lambda db: None)
monkeypatch.setattr(admin_mcp_routes, "_refresh_caches", lambda: None)
request = admin_mcp_routes.McpServerCreateRequest(
@@ -206,7 +205,7 @@ async def _fail_probe(*args, **kwargs):
market_auth_prefix="Bearer ",
market_admin_token="",
)
- await admin_mcp_routes.create_mcp_server(request, db)
+ await admin_mcp_routes.create_mcp_server(request)
listed = mcp_listing.list_market_items(
db,
@@ -223,7 +222,7 @@ async def _fail_probe(*args, **kwargs):
assert listed["listing_notice"]["discovery_mode"] == "per_install"
assert listed["tool_count"] == 0
- async def _authenticated_probe(row, db=None):
+ async def _authenticated_probe(row, **_options):
assert decrypt_mcp_headers(row.headers)["Authorization"] == "Bearer user-secret"
row.tools_json = _tools("search_documents")
return True, ""
@@ -233,7 +232,6 @@ async def _authenticated_probe(row, db=None):
monkeypatch.setattr(mcp_submissions, 'probe_mcp_connectivity', _authenticated_probe)
monkeypatch.setattr(mcp_publishing, 'probe_mcp_connectivity', _authenticated_probe)
installed = await mcp_installation.install_market_item(
- db,
listed["slug"],
owner_user_id="u2",
installed_by="u2",
@@ -251,7 +249,7 @@ async def test_create_market_mcp_with_admin_token_makes_user_install_tokenless(d
async def _validate(*args, **kwargs):
return None
- async def _probe(row, db=None):
+ async def _probe(row, **_options):
row.tools_json = discovered_tools
return True, ""
@@ -268,7 +266,6 @@ async def _probe(row, db=None):
monkeypatch.setattr(mcp_versions, 'refresh_mcp_caches', lambda: None)
monkeypatch.setattr(mcp_publishing, 'refresh_mcp_caches', lambda: None)
monkeypatch.setattr(admin_mcp_routes, "_probe_connectivity", _probe)
- monkeypatch.setattr(admin_mcp_routes, "_sync_catalog_from_db", lambda db: None)
monkeypatch.setattr(admin_mcp_routes, "_refresh_caches", lambda: None)
request = admin_mcp_routes.McpServerCreateRequest(
@@ -284,7 +281,7 @@ async def _probe(row, db=None):
market_auth_prefix="Bearer ",
market_admin_token="central-secret",
)
- await admin_mcp_routes.create_mcp_server(request, db)
+ await admin_mcp_routes.create_mcp_server(request)
source = db.query(AdminMcpServer).filter_by(server_id="admin_token_mcp").one()
assert decrypt_mcp_headers(source.headers)["Authorization"] == "Bearer central-secret"
@@ -322,7 +319,6 @@ async def _fail_probe(*args, **kwargs):
monkeypatch.setattr(mcp_versions, 'refresh_mcp_caches', lambda: None)
monkeypatch.setattr(mcp_publishing, 'refresh_mcp_caches', lambda: None)
monkeypatch.setattr(admin_mcp_routes, "_probe_connectivity", _fail_probe)
- monkeypatch.setattr(admin_mcp_routes, "_sync_catalog_from_db", lambda db: None)
monkeypatch.setattr(admin_mcp_routes, "_refresh_caches", lambda: None)
request = admin_mcp_routes.McpServerCreateRequest(
@@ -334,7 +330,7 @@ async def _fail_probe(*args, **kwargs):
market_requires_auth=True,
market_auth_type="oauth2",
)
- await admin_mcp_routes.create_mcp_server(request, db)
+ await admin_mcp_routes.create_mcp_server(request)
listed = mcp_listing.list_market_items(
db,
@@ -358,19 +354,21 @@ async def test_move_existing_published_server_removes_global_installation(db, mo
_patch_probe(monkeypatch)
await mcp_publishing.publish_admin_server(
- db,
source.server_id,
- category="信息检索",
- version="1.0.0",
+ mcp_publishing.Publication(
+ category="信息检索",
+ version="1.0.0",
+ ),
)
assert db.query(McpMarketInstallation).count() == 1
moved = await mcp_publishing.publish_admin_server(
- db,
source.server_id,
- category="信息检索",
- version="1.0.1",
- link_as_installation=False,
+ mcp_publishing.Publication(
+ category="信息检索",
+ version="1.0.1",
+ link_as_installation=False,
+ ),
)
db.refresh(source)
@@ -388,7 +386,6 @@ def test_edit_market_metadata_propagates_to_existing_installations(db, monkeypat
description=item.description,
transport=version.transport,
url=version.url,
- is_stable=False,
is_enabled=True,
owner_user_id="u2",
)
@@ -439,14 +436,12 @@ async def test_install_uses_installers_own_encrypted_credentials(db, monkeypatch
_patch_probe(monkeypatch)
first = await mcp_installation.install_market_item(
- db,
"acme-search",
owner_user_id="u2",
installed_by="u2",
credentials={"Authorization": "Bearer user-two"},
)
second = await mcp_installation.install_market_item(
- db,
"acme-search",
owner_user_id="u3",
installed_by="u3",
diff --git a/src/backend/tests/features/marketplace/mcp_marketplace_cases_4.py b/src/backend/tests/features/marketplace/mcp_marketplace_cases_4.py
index 38eee866..97aa9c4f 100644
--- a/src/backend/tests/features/marketplace/mcp_marketplace_cases_4.py
+++ b/src/backend/tests/features/marketplace/mcp_marketplace_cases_4.py
@@ -81,7 +81,6 @@ async def test_curated_templates_seed_once_and_materialize_user_query_key(db, mo
actual_tools = _tools("maps_geo", "maps_weather")
_patch_probe(monkeypatch, tools=actual_tools)
installed = await mcp_installation.install_market_item(
- db,
"amap-maps",
owner_user_id="u2",
installed_by="u2",
@@ -107,7 +106,6 @@ async def test_curated_bearer_token_gets_prefix_and_can_be_rotated(db, monkeypat
_patch_probe(monkeypatch, tools=_tools("metaso_web_search"))
installed = await mcp_installation.install_market_item(
- db,
"metaso-search",
owner_user_id="u2",
installed_by="u2",
@@ -117,7 +115,6 @@ async def test_curated_bearer_token_gets_prefix_and_can_be_rotated(db, monkeypat
assert decrypt_mcp_headers(server.headers)["Authorization"] == "Bearer first-key"
updated = await mcp_installation.install_market_item(
- db,
"metaso-search",
owner_user_id="u2",
installed_by="u2",
@@ -134,7 +131,6 @@ async def test_tool_drift_pauses_new_installation(db, monkeypatch):
_patch_probe(monkeypatch, tools=_tools("search_documents", "delete_documents"))
with pytest.raises(BadRequestError, match="发生变化"):
await mcp_installation.install_market_item(
- db,
"acme-search",
owner_user_id="u2",
installed_by="u2",
@@ -150,7 +146,6 @@ async def test_failed_update_probe_never_commits_plaintext_credentials(db, monke
_approved_market(db)
_patch_probe(monkeypatch)
installed = await mcp_installation.install_market_item(
- db,
"acme-search",
owner_user_id="u2",
installed_by="u2",
@@ -160,7 +155,6 @@ async def test_failed_update_probe_never_commits_plaintext_credentials(db, monke
with pytest.raises(BadRequestError, match="发生变化"):
await mcp_installation.install_market_item(
- db,
"acme-search",
owner_user_id="u2",
installed_by="u2",
@@ -178,14 +172,12 @@ async def test_security_suspension_disables_all_derived_servers(db, monkeypatch)
_approved_market(db)
_patch_probe(monkeypatch)
await mcp_installation.install_market_item(
- db,
"acme-search",
owner_user_id="u2",
installed_by="u2",
credentials={"Authorization": "Bearer u2"},
)
await mcp_installation.install_market_item(
- db,
"acme-search",
owner_user_id="u3",
installed_by="u3",
@@ -207,7 +199,6 @@ async def test_review_rechecks_live_snapshot(db, monkeypatch):
source = _private_server(db)
_patch_probe(monkeypatch)
submitted = await mcp_submissions.submit_to_marketplace(
- db,
source.server_id,
owner_user_id="u1",
submitter_name="Alice",
@@ -218,7 +209,6 @@ async def test_review_rechecks_live_snapshot(db, monkeypatch):
with pytest.raises(BadRequestError, match="重新提交"):
await mcp_submissions.review_submission(
- db,
submitted["submission_id"],
approve=True,
reviewed_by="admin",
@@ -232,7 +222,7 @@ async def test_review_rechecks_live_snapshot(db, monkeypatch):
async def test_missing_source_revalidates_from_reviewed_version(db, monkeypatch):
_approved_market(db)
_patch_probe(monkeypatch)
- result = await mcp_revalidation.revalidate_market_item(db, "acme-search")
+ result = await mcp_revalidation.revalidate_market_item("acme-search")
assert result["status"] == "active"
assert result["status_reason"] == ""
@@ -243,7 +233,7 @@ async def test_missing_source_still_detects_live_snapshot_drift(db, monkeypatch)
_approved_market(db)
_patch_probe(monkeypatch, tools=_tools("changed_after_review"))
- result = await mcp_revalidation.revalidate_market_item(db, "acme-search")
+ result = await mcp_revalidation.revalidate_market_item("acme-search")
assert result["status"] == "changed"
assert "需发布新版本" in result["status_reason"]
@@ -260,7 +250,6 @@ async def test_manual_admin_revalidation_accepts_drift_as_new_version(db, monkey
_patch_probe(monkeypatch, tools=changed_tools)
result = await mcp_revalidation.revalidate_market_item(
- db,
"acme-search",
accept_changes=True,
)
@@ -288,7 +277,7 @@ async def test_periodic_revalidation_never_auto_accepts_drift(db, monkeypatch):
db.commit()
_patch_probe(monkeypatch, tools=_tools("changed_after_review"))
- result = await mcp_revalidation.revalidate_market_item(db, "acme-search")
+ result = await mcp_revalidation.revalidate_market_item("acme-search")
assert result["status"] == "changed"
assert db.query(McpMarketVersion).filter_by(slug="acme-search").count() == 1
@@ -304,7 +293,6 @@ async def test_manual_revalidation_rejects_empty_tool_snapshot(db, monkeypatch):
_patch_probe(monkeypatch, tools=[])
result = await mcp_revalidation.revalidate_market_item(
- db,
"acme-search",
accept_changes=True,
)
@@ -346,7 +334,6 @@ async def test_delisted_item_blocks_user_install_but_not_admin_install(db, monke
with pytest.raises(ResourceNotFoundError):
await mcp_installation.install_market_item(
- db,
item.slug,
owner_user_id="u2",
installed_by="u2",
@@ -354,7 +341,6 @@ async def test_delisted_item_blocks_user_install_but_not_admin_install(db, monke
)
installed = await mcp_installation.install_market_item(
- db,
item.slug,
owner_user_id=None,
installed_by="admin",
@@ -369,7 +355,6 @@ async def test_user_installs_admin_published_private_network_mcp(db, monkeypatch
_patch_install_side_effects(monkeypatch)
installed = await mcp_installation.install_market_item(
- db,
"builtin-search",
owner_user_id="u2",
installed_by="u2",
@@ -399,7 +384,6 @@ async def test_installer_cannot_redirect_an_admin_item_to_another_private_host(d
with pytest.raises(BadRequestError, match="本机、内网或保留地址"):
await mcp_installation.install_market_item(
- db,
"builtin-search",
owner_user_id="u2",
installed_by="u2",
@@ -425,7 +409,6 @@ async def test_installer_may_keep_the_admin_host_when_overriding_the_endpoint(db
_patch_install_side_effects(monkeypatch)
await mcp_installation.install_market_item(
- db,
"builtin-search",
owner_user_id="u2",
installed_by="u2",
diff --git a/src/backend/tests/features/marketplace/mcp_marketplace_support.py b/src/backend/tests/features/marketplace/mcp_marketplace_support.py
index da2246a2..39a02a5f 100644
--- a/src/backend/tests/features/marketplace/mcp_marketplace_support.py
+++ b/src/backend/tests/features/marketplace/mcp_marketplace_support.py
@@ -88,7 +88,7 @@
from core.infra.exceptions import BadRequestError, ResourceNotFoundError
-from core.llm.mcp.pool import make_client
+from core.llm.mcp.client import make_client
from core.capabilities.mcp import management as management
@@ -113,17 +113,23 @@
from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+from tests.db_support import share_factory
_IS_CE_TREE = not (Path(__file__).resolve().parents[3] / "edition_ee").exists()
@pytest.fixture
-def db():
- engine = create_engine("sqlite://", future=True)
+def db(monkeypatch):
+ # One connection for every thread: marketplace steps run in worker threads.
+ engine = create_engine(
+ "sqlite://", future=True, poolclass=StaticPool, connect_args={"check_same_thread": False}
+ )
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, autoflush=False, future=True)
session = Session()
+ share_factory(monkeypatch, Session)
try:
yield session
finally:
@@ -151,7 +157,6 @@ def _private_server(db, *, owner: str = "u1", server_id: str = "umcp_source"):
url="https://mcp.example.test/mcp",
headers=encrypt_mcp_headers({"Authorization": "Bearer publisher-secret"}),
tools_json=_tools("search_documents"),
- is_stable=False,
is_enabled=True,
owner_user_id=owner,
)
@@ -201,7 +206,7 @@ def _patch_probe(monkeypatch, tools=None):
async def _validate(*args, **kwargs):
return None
- async def _probe(row, db=None):
+ async def _probe(row, **_options):
if tools is not None:
row.tools_json = tools
return True, ""
@@ -252,7 +257,7 @@ def _admin_private_market(db, *, auth_schema=None, url: str = "http://mcp:9102/m
def _patch_install_side_effects(monkeypatch, *, resolves_to: str = "172.27.0.4"):
"""Keep the real URL guard in play while stubbing the network around it."""
- async def _probe(row, db=None):
+ async def _probe(row, **_options):
return True, ""
monkeypatch.setattr(
diff --git a/src/backend/tests/features/marketplace/test_mcp_marketplace.py b/src/backend/tests/features/marketplace/test_mcp_marketplace.py
index 48d5b47f..71cf6a4a 100644
--- a/src/backend/tests/features/marketplace/test_mcp_marketplace.py
+++ b/src/backend/tests/features/marketplace/test_mcp_marketplace.py
@@ -2,7 +2,7 @@
from tests.features.marketplace.mcp_marketplace_support import db
from tests.features.marketplace.mcp_marketplace_cases_1 import (
- test_http_probe_uses_stateless_client_without_explicit_close,
+ test_http_probe_releases_its_discovery_resource,
test_http_probe_surfaces_401_instead_of_cancel_scope_noise,
test_ce_metadata_contains_mcp_market_auth_without_ee_dependency,
test_header_credentials_are_encrypted_and_masked,
diff --git a/src/backend/tests/features/marketplace/test_oauth_sdk2_contract.py b/src/backend/tests/features/marketplace/test_oauth_sdk2_contract.py
new file mode 100644
index 00000000..a960c8f7
--- /dev/null
+++ b/src/backend/tests/features/marketplace/test_oauth_sdk2_contract.py
@@ -0,0 +1,114 @@
+"""OAuth SDK 2 callback integrity and interactive discovery budgets."""
+import asyncio
+import json
+from types import SimpleNamespace
+from urllib.parse import parse_qs, urlparse
+from unittest.mock import AsyncMock
+
+import pytest
+from mcp.shared.auth import OAuthMetadata
+
+from core.capabilities.mcp import oauth, management
+from features.marketplace.api.mcp_marketplace import oauth_callback
+from tests.db_support import share_session
+
+
+class MemoryState:
+ def __init__(self):
+ self.values = {}
+
+ async def get(self, key):
+ return self.values.get(key)
+
+ async def put(self, key, value, **kwargs):
+ self.values[key] = value
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cross_worker", [False, True])
+@pytest.mark.parametrize("issuer,accepted", [
+ ("https://issuer.example.test/", True),
+ ("https://attacker.example.test/", False),
+ (None, False),
+])
+async def test_callback_issuer_reaches_sdk_validation(monkeypatch, cross_worker, issuer, accepted):
+ """Exercise the actual route, flow callbacks and SDK authorization grant."""
+ state = MemoryState()
+ monkeypatch.setattr(oauth, "get_ephemeral_state", lambda: state)
+ monkeypatch.setattr(oauth, "encrypt_secret", lambda value: "encrypted:" + value)
+ monkeypatch.setattr(oauth, "decrypt_secret", lambda value: value.removeprefix("encrypted:"))
+ share_session(monkeypatch, SimpleNamespace(rollback=lambda: None, close=lambda: None))
+ monkeypatch.setattr(oauth, "validate_remote_mcp_url", AsyncMock())
+ monkeypatch.setattr(oauth, "market_dependency_materialize_mcp_http_connection", lambda url, headers: (url, headers))
+ version = SimpleNamespace(auth_config={}, auth_schema=[], url="https://mcp.example.test/mcp", transport="streamable_http")
+ monkeypatch.setattr(oauth, "get_version", lambda db, slug: (SimpleNamespace(display_name="OAuth test", description=""), version))
+ installed = AsyncMock(return_value={"installed": True})
+ catalog = SimpleNamespace(
+ normalize_auth_config=lambda *args: {"methods": [{"id": "oauth", "scopes": []}]},
+ installation_secrets=lambda *args, **kwargs: {},
+ install_market_item=installed,
+ )
+ monkeypatch.setattr(oauth, "marketplace_catalog", lambda: catalog)
+ flow = oauth.OAuthInstallFlow(
+ flow_id="issuer-test", slug="test", owner_user_id="owner", installed_by="owner",
+ auth_method="oauth", credentials={}, callback_url="https://app.example.test/callback", client_id="client",
+ )
+ monkeypatch.setattr(oauth, "process_state", lambda: SimpleNamespace(flows=flows))
+ flows = {flow.flow_id: flow}
+
+ async def probe(row, *, validate, oauth_provider, timeout_seconds):
+ assert oauth_provider.context.client_metadata.application_type == "web"
+ assert timeout_seconds == 300.0
+ oauth_provider.context.client_info = await oauth_provider.context.storage.get_client_info()
+ oauth_provider.context.oauth_metadata = OAuthMetadata(
+ issuer="https://issuer.example.test/",
+ authorization_endpoint="https://issuer.example.test/authorize",
+ token_endpoint="https://issuer.example.test/token",
+ authorization_response_iss_parameter_supported=True,
+ )
+
+ async def deliver_callback():
+ await flow.ready.wait()
+ authorization_state = parse_qs(urlparse(flow.authorization_url).query)["state"][0]
+ if cross_worker:
+ flows.clear()
+ await oauth_callback(flow_id=flow.flow_id, code="authorization-code", state=authorization_state,
+ iss=issuer, error="", error_description="")
+
+ callback = asyncio.create_task(deliver_callback())
+ try:
+ code, verifier = await oauth_provider._perform_authorization_code_grant()
+ assert code == "authorization-code"
+ assert verifier
+ return True, ""
+ finally:
+ await callback
+
+ monkeypatch.setattr(oauth, "probe_mcp_connectivity", probe)
+ await oauth.run_flow(flow)
+ assert flow.status == ("completed" if accepted else "failed")
+ assert installed.await_count == int(accepted)
+ persisted = json.loads(await state.get(oauth.callback_key(flow.flow_id)))
+ assert persisted["iss"] == issuer
+ assert persisted["code"] == "encrypted:authorization-code"
+ if not accepted:
+ assert "iss" in flow.error
+
+
+@pytest.mark.asyncio
+async def test_connectivity_probe_passes_interactive_budget_and_releases_resource(monkeypatch):
+ from core.capabilities.mcp.service import McpServerConfigService
+ from core.harness.registry import mcp_clients
+ cfg = {"transport": "streamable_http", "url": "https://mcp.example.test/mcp", "transport_timeout": 17}
+ monkeypatch.setattr(McpServerConfigService, "get_instance", lambda: SimpleNamespace(_row_to_config=lambda row: dict(cfg)))
+ resource = SimpleNamespace(descriptors=[], close=AsyncMock())
+ discover = AsyncMock(return_value=resource)
+ monkeypatch.setattr(mcp_clients, "discover_tools", discover)
+ provider = object()
+ row = SimpleNamespace(server_id="oauth", transport="streamable_http")
+ assert await management.probe_mcp_connectivity(row, oauth_provider=provider, timeout_seconds=300) == (True, "")
+ passed = discover.await_args.args[1]
+ assert passed["discovery_timeout"] == 300
+ assert passed["transport_timeout"] == 17
+ assert passed["oauth_provider"] is provider
+ resource.close.assert_awaited_once()
diff --git a/src/backend/tests/features/sites/test_cloud_site_publish.py b/src/backend/tests/features/sites/test_cloud_site_publish.py
index 0469ad8e..77af706e 100644
--- a/src/backend/tests/features/sites/test_cloud_site_publish.py
+++ b/src/backend/tests/features/sites/test_cloud_site_publish.py
@@ -136,8 +136,7 @@ def test_gateway_transfers_local_build_bytes_and_returns_cloud_url(caps_root, mo
import asyncio
import httpx
- import mcp.types
- from core.llm.mcp.manager import GatewayMCPTool
+ from core.llm.tools.cloud_gateway import GatewayMCPTool
from features.desktop.bridge import client as bridge
from features.sites.packaging import safe_extract_tar
from tests.features.desktop.capabilities.test_runtime_recovery import state
@@ -188,7 +187,7 @@ def cloud(request):
tool = GatewayMCPTool(
mcp_name="sites-site_publish",
source_plugin="sites",
- tool=mcp.types.Tool(name="publish_site", inputSchema={"type": "object"}),
+ tool={"name": "publish_site", "inputSchema": {"type": "object"}},
invoke_url=st["cloud_base"] + "/api/v1/desktop/capability/gateway/sites-site_publish/call",
schema_hash="a" * 64,
timeout=120,
diff --git a/src/backend/tests/features/sites/test_site_access_password.py b/src/backend/tests/features/sites/test_site_access_password.py
index e4507593..3fd9e68c 100644
--- a/src/backend/tests/features/sites/test_site_access_password.py
+++ b/src/backend/tests/features/sites/test_site_access_password.py
@@ -11,6 +11,7 @@
from features.sites.service import SiteService
from fastapi import FastAPI
from fastapi.testclient import TestClient
+from tests.db_support import share_session
FILES = [("index.html", b"secret "), ("app.js", b"console.log(1)")]
@@ -38,6 +39,7 @@ def client(db_session, monkeypatch):
app = FastAPI()
app.include_router(sites_serve.router)
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
from features.sites import rate_limit as site_rate_limit
site_rate_limit._rate_buckets.clear()
site_rate_limit._unlock_buckets.clear()
@@ -203,7 +205,7 @@ def test_manager_skips_the_gate_but_visitors_do_not(svc, owner, db_session):
assert svc.authorize_access(site, None, site_password.issue_access_token(site))
-def test_management_endpoints_set_and_clear_the_password(svc, owner, db_session):
+def test_management_endpoints_set_and_clear_the_password(svc, owner, db_session, monkeypatch):
"""站点管理面板走的就是这两个接口——顺带确认 DELETE 没被 /{site_id} 抢走路由。"""
from features.sites.api import sites as sites_routes
from core.auth.backend import UserContext, get_current_user
@@ -211,6 +213,7 @@ def test_management_endpoints_set_and_clear_the_password(svc, owner, db_session)
app = FastAPI()
app.include_router(sites_routes.router)
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id=owner.user_id, user_center_id=owner.user_id, username=owner.user_id
)
diff --git a/src/backend/tests/features/workbench/test_plugin_ui_routes.py b/src/backend/tests/features/workbench/test_plugin_ui_routes.py
index 8f1f4936..3ef83d3b 100644
--- a/src/backend/tests/features/workbench/test_plugin_ui_routes.py
+++ b/src/backend/tests/features/workbench/test_plugin_ui_routes.py
@@ -15,13 +15,14 @@
import pytest
from core.db.models import UserShadow
+from tests.db_support import share_session
OWNER = "u-ui-routes"
SLUG = "industry-knowledge-center"
@pytest.fixture()
-def client(db_session):
+def client(db_session, monkeypatch):
from api.app import app
from core.auth.backend import UserContext, get_current_user
from core.db.engine import get_db
@@ -39,6 +40,7 @@ def _override_db():
user_id=OWNER, user_center_id="c1", username="Tester", email="t@e.com"
)
app.dependency_overrides[get_db] = _override_db
+ share_session(monkeypatch, db_session)
try:
yield TestClient(app)
finally:
diff --git a/src/backend/tests/features/workbench/test_workbench_activity_routes.py b/src/backend/tests/features/workbench/test_workbench_activity_routes.py
index c01ab22c..32fd8208 100644
--- a/src/backend/tests/features/workbench/test_workbench_activity_routes.py
+++ b/src/backend/tests/features/workbench/test_workbench_activity_routes.py
@@ -5,15 +5,17 @@
from fastapi.testclient import TestClient
from core.auth.backend import UserContext, get_current_user
from core.db.engine import get_db
+from tests.db_support import share_factory, share_session
@pytest.fixture()
-def client(db_session):
+def client(db_session, monkeypatch):
from features.workbench.api.workbench import router
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id="alice", user_center_id="alice", username="Alice"
)
@@ -185,7 +187,7 @@ def test_module_scope_cannot_be_forged(client, plugin):
assert client.post(path, json={"binding": binding, "moduleId": "another"}).status_code == 403
-def test_concurrent_badge_storm_has_a_durable_shared_budget(client, plugin, db_session):
+def test_concurrent_badge_storm_has_a_durable_shared_budget(client, plugin, db_session, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy.orm import sessionmaker
@@ -198,6 +200,7 @@ def database():
yield db
client.app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, factory)
def publish(index):
return client.put(path + "/badge", json={"count": index + 1}).status_code
diff --git a/src/backend/tests/features/workbench/test_workbench_auth_routes.py b/src/backend/tests/features/workbench/test_workbench_auth_routes.py
index 6909645e..7969b372 100644
--- a/src/backend/tests/features/workbench/test_workbench_auth_routes.py
+++ b/src/backend/tests/features/workbench/test_workbench_auth_routes.py
@@ -2,6 +2,7 @@
from urllib.parse import parse_qs, urlsplit
+from tests.db_support import share_factory
from tests.features.workbench.test_workbench_external_routes import AUTH, BASE, client, create # noqa: F401
KEY = "external:wb_reports"
@@ -151,7 +152,7 @@ def test_revocation_and_auth_rotation_reject_outstanding_tickets(client, db_sess
assert redeem(client, body).status_code == 401
-def test_concurrent_target_exchange_has_exactly_one_winner(client, db_session):
+def test_concurrent_target_exchange_has_exactly_one_winner(client, db_session, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
@@ -169,6 +170,7 @@ def database():
yield db
app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, factory)
barrier = Barrier(6)
def exchange_once():
diff --git a/src/backend/tests/features/workbench/test_workbench_background_subpage_http.py b/src/backend/tests/features/workbench/test_workbench_background_subpage_http.py
index 2e8382f2..bc1cbace 100644
--- a/src/backend/tests/features/workbench/test_workbench_background_subpage_http.py
+++ b/src/backend/tests/features/workbench/test_workbench_background_subpage_http.py
@@ -21,6 +21,7 @@
from core.db.models import UserShadow
from core.db.models.plugin_resource import PluginResource
from core.capabilities.plugins.resources.crypto import encrypt_secret
+from tests.db_support import share_session
@pytest.fixture()
@@ -46,6 +47,7 @@ def boundary(monkeypatch):
app.include_router(workbench)
app.include_router(plugins)
app.dependency_overrides[get_db] = lambda: db
+ share_session(monkeypatch, db)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id="subpage-owner",
user_center_id="subpage-owner",
diff --git a/src/backend/tests/features/workbench/test_workbench_capability_routes.py b/src/backend/tests/features/workbench/test_workbench_capability_routes.py
index b9f10b6f..84c4d2dc 100644
--- a/src/backend/tests/features/workbench/test_workbench_capability_routes.py
+++ b/src/backend/tests/features/workbench/test_workbench_capability_routes.py
@@ -6,6 +6,7 @@
from tests.features.workbench.test_workbench_plugin_routes import client, launched
from tests.features.workbench.workbench_plugin_support import package
+from tests.db_support import share_factory
def capability_package():
@@ -215,7 +216,7 @@ def test_per_operation_check_detects_policy_revocation(client, db_session):
assert client.post(endpoint + "/check", json=body, headers=HOST).status_code == 404
-def test_simultaneous_approval_writes_do_not_grant_another_installation(client, db_session):
+def test_simultaneous_approval_writes_do_not_grant_another_installation(client, db_session, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from core.db.engine import get_db
@@ -230,6 +231,7 @@ def database():
yield db
client.app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, factory)
lease = client.post(endpoint.removesuffix("/capabilities") + "/revision-leases", headers=HOST,
json={"revision": launch["revision"], "instanceId": INSTANCE})
assert lease.status_code == 200, lease.text
@@ -323,6 +325,7 @@ def database():
yield db
client.app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, factory)
body = {"approvalId": token, "revision": launch["revision"], "instanceId": INSTANCE,
"sourceId": "records", "params": {}}
with ThreadPoolExecutor(max_workers=1) as pool:
diff --git a/src/backend/tests/features/workbench/test_workbench_diagnostic_failure_http.py b/src/backend/tests/features/workbench/test_workbench_diagnostic_failure_http.py
index 44116665..c65ad0f8 100644
--- a/src/backend/tests/features/workbench/test_workbench_diagnostic_failure_http.py
+++ b/src/backend/tests/features/workbench/test_workbench_diagnostic_failure_http.py
@@ -9,6 +9,7 @@
from core.db.models import UserShadow
from fastapi import FastAPI
from fastapi.testclient import TestClient
+from tests.db_support import share_session
BASE = "/v1/workbench"
KEY = "external:telemetry_failure"
@@ -37,6 +38,7 @@ def database():
yield db_session
app.dependency_overrides[get_db] = database
+ share_session(monkeypatch, db_session)
token = asyncio.run(create_session({"user_id": "diagnostic-failure-user", "username": "Reader"}))
with TestClient(app) as browser:
browser.cookies.set(settings.session.cookie_name, token)
diff --git a/src/backend/tests/features/workbench/test_workbench_diagnostics.py b/src/backend/tests/features/workbench/test_workbench_diagnostics.py
index 04dcedeb..a462ceff 100644
--- a/src/backend/tests/features/workbench/test_workbench_diagnostics.py
+++ b/src/backend/tests/features/workbench/test_workbench_diagnostics.py
@@ -6,6 +6,7 @@
from core.db.models import UserShadow
from fastapi import FastAPI
from fastapi.testclient import TestClient
+from tests.db_support import share_session
@pytest.fixture()
@@ -22,6 +23,7 @@ def database():
yield db_session
app.dependency_overrides[get_db] = database
+ share_session(monkeypatch, db_session)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id="wb19-reader", user_center_id="wb19-reader", username="Reader"
)
@@ -263,7 +265,7 @@ def test_probe_checks_peer_and_does_not_follow_redirect(client, probe_target, mo
assert "127.0.0.1" not in response.text
-def test_self_resource_observation_distinguishes_registration_from_process_count(client):
+def test_self_resource_observation_distinguishes_registration_from_process_count(client, monkeypatch):
# Explicitly model unavailable schema independently of collection order or SQL dialect.
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@@ -273,6 +275,7 @@ def test_self_resource_observation_distinguishes_registration_from_process_count
try:
with Session(empty) as db:
client.app.dependency_overrides[get_db] = lambda: db
+ share_session(monkeypatch, db)
response = client.get("/v1/workbench/me/diagnostics/resource-metrics?user_id=other")
assert response.status_code == 200
assert response.headers["cache-control"] == "private, no-store"
diff --git a/src/backend/tests/features/workbench/test_workbench_external_routes.py b/src/backend/tests/features/workbench/test_workbench_external_routes.py
index ca25ff4e..b062e3f3 100644
--- a/src/backend/tests/features/workbench/test_workbench_external_routes.py
+++ b/src/backend/tests/features/workbench/test_workbench_external_routes.py
@@ -5,6 +5,7 @@
from core.db.engine import get_db
from core.db.models import UserShadow
from fastapi.testclient import TestClient
+from tests.db_support import share_factory, share_session
BASE = "/v1/workbench"
ADMIN = BASE + "/admin/external-applications"
@@ -29,6 +30,7 @@ def database():
yield db_session
app.dependency_overrides[get_db] = database
+ share_session(monkeypatch, db_session)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id="wb-user", user_center_id="wb-user", username="Reader", email="reader@example.org"
)
@@ -240,7 +242,7 @@ def test_all_legacy_writes_reject_normalized_duplicate_ids(client, monkeypatch,
assert response.status_code == 409, response.text
-def test_concurrent_new_and_legacy_definitions_never_share_effective_id(client, db_session):
+def test_concurrent_new_and_legacy_definitions_never_share_effective_id(client, db_session, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
@@ -254,6 +256,7 @@ def database():
yield session
app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, factory)
# Both HTTP operations start together. Their independent DB sessions must
# serialize the namespace decision even when app_config does not yet exist.
for n in range(8):
diff --git a/src/backend/tests/features/workbench/test_workbench_plugin_routes.py b/src/backend/tests/features/workbench/test_workbench_plugin_routes.py
index 8c84f33c..6259bf93 100644
--- a/src/backend/tests/features/workbench/test_workbench_plugin_routes.py
+++ b/src/backend/tests/features/workbench/test_workbench_plugin_routes.py
@@ -7,6 +7,7 @@
from core.db.engine import get_db
from core.db.models import UserShadow
from tests.features.workbench.workbench_plugin_support import package
+from tests.db_support import share_factory, share_session
@pytest.fixture
@@ -33,6 +34,7 @@ async def app_error(request, error):
user_id="wb07", user_center_id="wb07", username="WB07", email=""
)
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
return TestClient(app)
@@ -188,7 +190,7 @@ def test_css_expansion_is_bounded_before_large_allocation(client):
assert response.json()["detail"]["code"] == "page_too_large"
-def test_simultaneous_first_switches_are_atomic(client, db_session):
+def test_simultaneous_first_switches_are_atomic(client, db_session, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy.orm import sessionmaker
@@ -200,6 +202,7 @@ def database():
yield db
client.app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, factory)
def toggle(value):
return client.patch(
diff --git a/src/backend/tests/features/workbench/test_workbench_preferences_routes.py b/src/backend/tests/features/workbench/test_workbench_preferences_routes.py
index 1da0f7f1..2225e8c6 100644
--- a/src/backend/tests/features/workbench/test_workbench_preferences_routes.py
+++ b/src/backend/tests/features/workbench/test_workbench_preferences_routes.py
@@ -5,17 +5,19 @@
from core.db.engine import get_db
from fastapi import FastAPI
from fastapi.testclient import TestClient
+from tests.db_support import share_factory, share_session
BASE = "/v1/workbench/me/navigation-preferences"
@pytest.fixture()
-def client(db_session):
+def client(db_session, monkeypatch):
from features.workbench.api.workbench import router
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id="alice", user_center_id="alice", username="Alice"
)
@@ -80,7 +82,7 @@ def test_user_environment_isolation_and_reset_do_not_change_other_preferences(cl
@pytest.mark.parametrize("revision", [0, 1])
-def test_two_devices_same_revision_have_one_winner(client, db_session, revision):
+def test_two_devices_same_revision_have_one_winner(client, db_session, revision, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
@@ -95,6 +97,7 @@ def database():
yield db
client.app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, factory)
barrier = Barrier(2)
def write(key):
diff --git a/src/backend/tests/features/workbench/test_workbench_resources.py b/src/backend/tests/features/workbench/test_workbench_resources.py
index 0bd47ba6..496d33d2 100644
--- a/src/backend/tests/features/workbench/test_workbench_resources.py
+++ b/src/backend/tests/features/workbench/test_workbench_resources.py
@@ -26,13 +26,18 @@ def encryption_key(monkeypatch):
@pytest.fixture
-def resource_db():
+def resource_db(monkeypatch):
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
# Shared owner/catalog/retained-revision checks now require their real durable tables.
+ from core.db import engine as db_engine
from core.db.engine import Base
+ from sqlalchemy.orm import sessionmaker
+
Base.metadata.create_all(engine)
+ # Closing a worker opens sessions of its own (core.db.threads.run_in_session).
+ monkeypatch.setattr(db_engine, "SessionLocal", sessionmaker(bind=engine))
with Session(engine) as db:
yield db
engine.dispose()
@@ -126,12 +131,13 @@ async def test_close_is_idempotent_revokes_tickets_and_prevents_lease(resource_d
resource_db.add(row)
resource_db.commit()
ticket = store.ticket(resource_db, row, "https://host.test")
- await service.close(resource_db, row)
- await service.close(resource_db, row)
+ await service.close(row)
+ await service.close(row)
+ resource_db.refresh(row)
assert row.status == "closed"
assert store.consume(resource_db, ticket, "r", "https://host.test") is None
with pytest.raises(HTTPException) as error:
- await application.renew(resource_db, "owner", "plugin:key", "instance", "r")
+ await application.renew("owner", "plugin:key", "instance", "r")
assert error.value.status_code == 410
@@ -145,13 +151,14 @@ async def test_janitor_cleans_expired_application_and_continues_after_failure(
resource_db.commit()
close = service.close
- async def sometimes(db, row):
+ async def sometimes(row):
if row.resource_id == "a":
raise RuntimeError("injected cleanup failure")
- await close(db, row)
+ await close(row)
monkeypatch.setattr(service, "close", sometimes)
- await lifecycle.reconcile(resource_db)
+ await lifecycle.reconcile()
+ resource_db.expire_all()
assert second.status == "closed"
assert first.status == "active"
@@ -240,7 +247,6 @@ def log_message(self, *args):
monkeypatch.setattr(interactive, "launch", launch)
monkeypatch.setattr(factory, "get_sandbox_provider", lambda: SimpleNamespace(name="test"))
args = (
- resource_db,
"owner",
"plugin:key",
"instance",
@@ -260,7 +266,7 @@ def log_message(self, *args):
row = application.owned(
resource_db, "owner", "plugin:key", "instance", first["resource_id"]
)
- await service.close(resource_db, row)
+ await service.close(row)
with pytest.raises(HTTPException) as error:
await application.create(*args)
assert error.value.status_code == 410
@@ -287,7 +293,8 @@ def revoked(*args):
closing = application.owned(
resource_db, "owner", "plugin:key", "instance", "r", allow_closed=True
)
- await service.close(resource_db, closing)
+ await service.close(closing)
+ resource_db.refresh(closing)
assert closing.status == "closed"
diff --git a/src/backend/tests/features/workbench/test_workbench_revocation_failure_http.py b/src/backend/tests/features/workbench/test_workbench_revocation_failure_http.py
index a8848a5d..1ce172af 100644
--- a/src/backend/tests/features/workbench/test_workbench_revocation_failure_http.py
+++ b/src/backend/tests/features/workbench/test_workbench_revocation_failure_http.py
@@ -259,7 +259,7 @@ async def shutdown(row, path, body=None, **kwargs):
from core.capabilities.plugins.resources import lifecycle
with SessionLocal() as db:
- asyncio.run(lifecycle.reconcile(db, USER))
+ asyncio.run(lifecycle.reconcile(USER))
db.expire_all()
assert db.get(PluginResource, "worker-a").status == "closed"
assert calls[-1] == "worker-a"
diff --git a/src/backend/tests/features/workbench/test_workbench_state_concurrency.py b/src/backend/tests/features/workbench/test_workbench_state_concurrency.py
index 10fce60d..6f11ceb1 100644
--- a/src/backend/tests/features/workbench/test_workbench_state_concurrency.py
+++ b/src/backend/tests/features/workbench/test_workbench_state_concurrency.py
@@ -5,9 +5,10 @@
from sqlalchemy.orm import sessionmaker
from core.db.engine import get_db
from tests.features.workbench.test_workbench_state_routes import client as client, BASE, SCOPE, body
+from tests.db_support import share_factory
-def test_concurrent_state_creation_has_one_cas_winner(client, db_session):
+def test_concurrent_state_creation_has_one_cas_winner(client, db_session, monkeypatch):
factory = sessionmaker(bind=db_session.get_bind())
def database():
@@ -15,6 +16,7 @@ def database():
yield db
client.app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, factory)
barrier = Barrier(2)
def write(value):
diff --git a/src/backend/tests/features/workbench/test_workbench_state_routes.py b/src/backend/tests/features/workbench/test_workbench_state_routes.py
index beee19b0..5d17ab0a 100644
--- a/src/backend/tests/features/workbench/test_workbench_state_routes.py
+++ b/src/backend/tests/features/workbench/test_workbench_state_routes.py
@@ -5,6 +5,7 @@
from fastapi.testclient import TestClient
from core.auth.backend import UserContext, get_current_user
from core.db.engine import get_db
+from tests.db_support import share_session
BASE = "/v1/workbench/me/application-state/builtin:chat"
SCOPE = "?workspace=main&packageRevision=1"
@@ -15,12 +16,13 @@ def body(revision=0, **values):
@pytest.fixture()
-def client(db_session):
+def client(db_session, monkeypatch):
from features.workbench.api.workbench import router
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id="alice", user_center_id="alice", username="Alice"
)
diff --git a/src/backend/tests/infra/test_core_cleanup.py b/src/backend/tests/infra/test_core_cleanup.py
index 5592920b..8ce64eea 100644
--- a/src/backend/tests/infra/test_core_cleanup.py
+++ b/src/backend/tests/infra/test_core_cleanup.py
@@ -99,3 +99,24 @@ async def __call__(self):
await call()
with pytest.raises(CircuitBreakerOpenError):
await call()
+
+
+def test_rate_limit_configuration_is_not_advertised_without_a_reader():
+ """The global HTTP limiter was never mounted; its knobs must not reappear."""
+ from pathlib import Path
+
+ from core.config.settings import settings
+
+ assert not hasattr(settings, "rate_limit")
+ root = Path(__file__).resolve().parents[4]
+ for relative in (
+ "docker-compose.yml",
+ "requirements.txt",
+ "desktop/requirements-desktop.txt",
+ "document/en/deployment/environment-variables.md",
+ "document/zh-CN/deployment/environment-variables.md",
+ ):
+ path = root / relative
+ if path.is_file():
+ text = path.read_text(encoding="utf-8")
+ assert "RATE_LIMIT_" not in text and "slowapi" not in text, relative
diff --git a/src/backend/tests/infra/test_db_off_loop_call_sites.py b/src/backend/tests/infra/test_db_off_loop_call_sites.py
new file mode 100644
index 00000000..e1cdb1d0
--- /dev/null
+++ b/src/backend/tests/infra/test_db_off_loop_call_sites.py
@@ -0,0 +1,115 @@
+"""Call sites that used to query on the loop, or across network waits, now do neither."""
+
+import threading
+from types import SimpleNamespace
+
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from core.db import engine as db_engine
+
+
+@pytest.fixture
+def isolated_db(monkeypatch):
+ database = create_engine(
+ "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
+ )
+ factory = sessionmaker(bind=database)
+ monkeypatch.setattr(db_engine, "SessionLocal", factory)
+ return database, factory
+
+
+async def test_project_memory_authorization_queries_off_the_loop(monkeypatch):
+ from core.memory import access, backend
+
+ loop_thread = threading.get_ident()
+ seen = []
+ memory = SimpleNamespace(
+ get=lambda memory_id: {"user_id": "owner", "metadata": {"workspace_id": "project:p1"}}
+ )
+ monkeypatch.setattr(backend, "settings", SimpleNamespace(memory=SimpleNamespace(enabled=True)))
+ monkeypatch.setattr(backend, "_get_memory", lambda: memory)
+
+ def owner(db, project_id, actor):
+ seen.append((threading.get_ident(), project_id, actor))
+ return "owner"
+
+ monkeypatch.setattr(access, "_project_memory_owner", owner)
+ context = await access.authorized_memory_context("m1", "editor")
+ assert context.scope_user_id == "owner" and context.workspace_id == "project:p1"
+ assert seen and seen[0][0] != loop_thread and seen[0][1:] == ("p1", "editor")
+
+ monkeypatch.setattr(access, "_project_memory_owner", lambda db, project_id, actor: None)
+ assert await access.authorized_memory_context("m1", "editor") is None
+
+
+async def test_resource_checkpoint_saves_after_the_worker_answers(monkeypatch, isolated_db):
+ from core.capabilities.plugins.resources import service, store
+
+ database, factory = isolated_db
+ store.PluginResourceCheckpoint.__table__.create(database)
+ monkeypatch.setenv("PLUGIN_RESOURCE_SECRET_KEY", "test-key")
+ order = []
+
+ async def worker(row, path, body=None, *, transport=None):
+ order.append("network")
+ return {"state": {"cookies": []}}
+
+ monkeypatch.setattr(service, "request", worker)
+ row = SimpleNamespace(application_key=None, user_id="owner", install_id="install")
+ saved = await service.checkpoint(row, "account", "connection")
+ with factory() as db:
+ record = db.get(store.PluginResourceCheckpoint, saved["checkpoint_id"])
+ assert (record.user_id, record.install_id, record.name) == ("owner", "install", "account")
+ assert order == ["network"]
+
+
+async def test_workbench_workers_close_without_a_shared_session(monkeypatch):
+ from features.workbench import revocation
+
+ loop_thread = threading.get_ident()
+ events = []
+
+ async def close(row):
+ events.append(("close", row.resource_id))
+
+ def release(db, row):
+ events.append(("release", row.resource_id, threading.get_ident() != loop_thread))
+
+ from core.db import threads
+
+ monkeypatch.setattr(revocation.service, "close", close)
+ monkeypatch.setattr(revocation, "_release_background_reservation", release)
+ # No database needed: the release step gets no session, only its thread matters.
+ monkeypatch.setattr(threads, "_in_session", lambda fn, args, kwargs: fn(None, *args, **kwargs))
+ rows = [SimpleNamespace(resource_id="a"), SimpleNamespace(resource_id="b")]
+ result = await revocation._close_workers(rows)
+ assert result == {"closed": 2, "deferred": 0}
+ assert events == [
+ ("close", "a"),
+ ("release", "a", True),
+ ("close", "b"),
+ ("release", "b", True),
+ ]
+
+
+async def test_stopping_prewarmed_workers_closes_rows_after_shutdowns(monkeypatch, isolated_db):
+ from core.capabilities.plugins.resources import prewarm, store
+
+ database, factory = isolated_db
+ store.PluginResource.__table__.create(database)
+ with factory() as db:
+ for resource_id, status in (("w1", "warm"), ("w2", "warming"), ("a1", "active")):
+ db.add(store.PluginResource(
+ resource_id=resource_id, user_id="owner", chat_id="chat", install_id="i",
+ revision="r", slug="s", module_id="m", scope="local", descriptor="",
+ status=status, created_at=0, expires_at=0,
+ ))
+ db.commit()
+ monkeypatch.setattr(store, "descriptor", lambda row: {})
+ assert await prewarm._stop("chat") is True
+ with factory() as db:
+ statuses = {row.resource_id: row.status for row in db.query(store.PluginResource)}
+ assert statuses == {"w1": "closed", "w2": "closed", "a1": "active"}
diff --git a/src/backend/tests/infra/test_db_thread_budget.py b/src/backend/tests/infra/test_db_thread_budget.py
new file mode 100644
index 00000000..c2e1f575
--- /dev/null
+++ b/src/backend/tests/infra/test_db_thread_budget.py
@@ -0,0 +1,120 @@
+"""The connection pool and the thread limits come from one budget, and DB work runs in it."""
+
+import asyncio
+import os
+import threading
+
+import anyio.to_thread
+import pytest
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from core.db import engine, threads
+
+
+def test_pool_is_one_connection_per_thread_that_can_use_it(monkeypatch):
+ from dataclasses import replace
+ from core.config.settings import settings
+
+ original = settings.db
+ object.__setattr__(settings, "db", replace(original, worker_threads=24))
+ try:
+ budget = engine.thread_budget()
+ finally:
+ object.__setattr__(settings, "db", original)
+ assert budget.request_threads == 24
+ assert budget.background_threads == min(32, (os.cpu_count() or 1) + 4)
+ assert budget.pool_size == budget.request_threads + budget.background_threads
+ assert budget.overflow == 10
+
+
+def test_worker_threads_setting_reads_its_environment(monkeypatch):
+ from core.config.identity_settings import DatabaseSettings
+
+ monkeypatch.setenv("WORKER_THREADS", "12")
+ assert DatabaseSettings().worker_threads == 12
+ monkeypatch.setenv("WORKER_THREADS", "0")
+ assert DatabaseSettings().worker_threads == 1
+
+
+def test_postgres_engine_takes_its_pool_from_the_budget():
+ import subprocess
+ import sys
+ from pathlib import Path
+
+ probe = (
+ "from core.db import engine as e; b = e.thread_budget(); "
+ "assert e.engine.pool.size() == b.pool_size == 7 + b.background_threads, e.engine.pool.size(); "
+ "assert e.engine.pool._max_overflow == b.overflow"
+ )
+ env = {
+ **os.environ,
+ # Creating the engine never connects, so no server is needed.
+ "DATABASE_URL": "postgresql://u:p@127.0.0.1:1/db",
+ "WORKER_THREADS": "7",
+ "DB_POOL_SIZE": "500", # retired: must have no effect
+ }
+ backend_root = Path(engine.__file__).resolve().parents[2]
+ result = subprocess.run(
+ [sys.executable, "-c", probe], cwd=backend_root, env=env, capture_output=True, text=True
+ )
+ assert result.returncode == 0, result.stderr[-2000:]
+
+
+def test_startup_applies_the_budget_before_any_other_step():
+ from api import lifecycle
+
+ first = lifecycle._startup_steps()[0]
+ assert first[0] is lifecycle._startup_thread_budget
+ assert first[3] == lifecycle._ALL_ROLES and first[4] == lifecycle._PER_WORKER
+
+
+async def test_apply_thread_budget_sizes_both_thread_pools():
+ limiter = anyio.to_thread.current_default_thread_limiter()
+ before = limiter.total_tokens
+ loop = asyncio.get_running_loop()
+ try:
+ threads.apply_thread_budget()
+ budget = engine.thread_budget()
+ assert limiter.total_tokens == budget.request_threads
+ assert loop._default_executor._max_workers == budget.background_threads
+ finally:
+ limiter.total_tokens = before
+
+
+async def test_run_db_and_run_in_session_leave_the_event_loop(monkeypatch):
+ database = create_engine(
+ "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
+ )
+ monkeypatch.setattr(engine, "SessionLocal", sessionmaker(bind=database))
+ loop_thread = threading.get_ident()
+ seen = {}
+
+ def query(db, value):
+ seen["thread"] = threading.get_ident()
+ seen["session"] = db
+ return db.execute(text("SELECT :v"), {"v": value}).scalar()
+
+ assert await threads.run_in_session(query, 7) == 7
+ assert seen["thread"] != loop_thread
+ # The session is closed before the result comes back: nothing is pinned.
+ assert not seen["session"].in_transaction()
+ assert await threads.run_db(lambda: threading.get_ident()) != loop_thread
+
+
+async def test_run_in_session_closes_the_session_when_the_step_fails(monkeypatch):
+ database = create_engine(
+ "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
+ )
+ monkeypatch.setattr(engine, "SessionLocal", sessionmaker(bind=database))
+ sessions = []
+
+ def fail(db):
+ db.execute(text("SELECT 1"))
+ sessions.append(db)
+ raise LookupError("boom")
+
+ with pytest.raises(LookupError):
+ await threads.run_in_session(fail)
+ assert not sessions[0].in_transaction()
diff --git a/src/backend/tests/infra/test_no_session_on_event_loop.py b/src/backend/tests/infra/test_no_session_on_event_loop.py
new file mode 100644
index 00000000..d40b1d4b
--- /dev/null
+++ b/src/backend/tests/infra/test_no_session_on_event_loop.py
@@ -0,0 +1,100 @@
+"""Static guard: async code never touches a database session.
+
+Database work belongs in a sync function run through ``core.db.threads``
+(``run_in_session`` / ``run_db`` / ``@off_loop``); see that module for the rule.
+An async function — route, dependency, tool, worker or helper — therefore never
+opens a session, never binds one, and never receives one. There are no
+exceptions: a new site fails this test instead of joining a list.
+"""
+
+import ast
+from pathlib import Path
+
+BACKEND = Path(__file__).resolve().parents[2]
+
+# Names an open session is bound to (``with factory() as db``), whatever the factory.
+SESSION_NAMES = {"db", "session_db", "cleanup_db"}
+
+
+def _name(node):
+ return getattr(node, "id", None) or getattr(node, "attr", "") or ""
+
+
+def _own_nodes(function):
+ """Nodes of ``function``'s body, excluding nested functions (those run elsewhere)."""
+ stack = list(ast.iter_child_nodes(function))
+ while stack:
+ node = stack.pop()
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
+ continue
+ yield node
+ stack.extend(ast.iter_child_nodes(node))
+
+
+def _session_parameters(function):
+ args = function.args
+ positional = args.posonlyargs + args.args
+ defaults = [None] * (len(positional) - len(args.defaults)) + list(args.defaults)
+ for arg, default in zip(positional + args.kwonlyargs, defaults + list(args.kw_defaults)):
+ injected = (
+ isinstance(default, ast.Call)
+ and _name(default.func) == "Depends"
+ and bool(default.args)
+ and _name(default.args[0]) == "get_db"
+ )
+ annotated = arg.annotation is not None and _name(arg.annotation) == "Session"
+ if injected or annotated or arg.arg in SESSION_NAMES:
+ yield arg.arg
+
+
+def _violations(function):
+ found = [f"parameter {name}" for name in _session_parameters(function)]
+ for node in _own_nodes(function):
+ if isinstance(node, ast.Call) and _name(node.func).endswith("SessionLocal"):
+ found.append(f"line {node.lineno}: opens a session")
+ if isinstance(node, (ast.With, ast.AsyncWith)):
+ for item in node.items:
+ if _name(item.optional_vars) in SESSION_NAMES:
+ found.append(f"line {node.lineno}: binds a session")
+ return found
+
+
+def test_async_code_never_touches_a_session():
+ offenders = []
+ for path in sorted(BACKEND.rglob("*.py")):
+ relative = path.relative_to(BACKEND).as_posix()
+ if relative.startswith(("tests/", "alembic/")) or "/node_modules/" in relative:
+ continue
+ try:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ except SyntaxError:
+ continue
+ for function in ast.walk(tree):
+ if isinstance(function, ast.AsyncFunctionDef):
+ offenders += [
+ f"{relative}:{function.name} ({why})" for why in _violations(function)
+ ]
+ assert offenders == [], "move the database step into core.db.threads.run_in_session"
+
+
+def test_guard_recognises_each_pattern():
+ source = '''
+async def injected(db: Session = Depends(get_db)): ...
+async def annotated(conn: Session): ...
+async def opened():
+ s = SessionLocal()
+async def bound(factory):
+ with factory() as db:
+ pass
+async def clean(item_id):
+ return await run_in_session(_load, item_id)
+'''
+ functions = {
+ node.name: node for node in ast.walk(ast.parse(source)) if isinstance(node, ast.AsyncFunctionDef)
+ }
+ assert {name for name, fn in functions.items() if _violations(fn)} == {
+ "injected",
+ "annotated",
+ "opened",
+ "bound",
+ }
diff --git a/src/backend/tests/integration/job_local_smoke.py b/src/backend/tests/integration/job_local_smoke.py
index 7037d036..335b5919 100644
--- a/src/backend/tests/integration/job_local_smoke.py
+++ b/src/backend/tests/integration/job_local_smoke.py
@@ -77,7 +77,7 @@ async def acceptance(root, backend_socket, runner_socket):
from api.routes.v1 import internal_jobs
from core.db.engine import Base, SessionLocal, engine
from core.db.models import UserShadow
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools.job_tool import register_run_job
from core.sandbox import get_sandbox_provider
from core.sandbox.runner import server as runner
@@ -141,14 +141,14 @@ def payload(response):
user_id = "acceptance-user"
await provider.put_file(session_id, "acceptance.py", SCRIPT.encode(), user_id=user_id)
- collector = ToolCollector()
+ collector = ToolAssembly()
register_run_job(
collector,
user_id=user_id,
chat_id=None,
sandbox_session_id=session_id,
)
- tool = collector.get_tool("run_job")
+ tool = next(t for t in collector if t.name == "run_job")
assert tool is not None, "run_job registration failed"
run_job = tool._func
started = payload(
diff --git a/src/backend/tests/integrations/channels/bot_cases_1.py b/src/backend/tests/integrations/channels/bot_cases_1.py
index 95f15463..33b6efe4 100644
--- a/src/backend/tests/integrations/channels/bot_cases_1.py
+++ b/src/backend/tests/integrations/channels/bot_cases_1.py
@@ -3,7 +3,6 @@
BOOL_CAPABILITY_DEFAULTS,
BadRequestError,
ChannelConnectionRepository,
- ChannelService,
InboundMsg,
LarkAdapter,
SendResult,
@@ -12,6 +11,8 @@
_inbound,
_mk_conn,
_mk_user,
+ _share_sessions,
+ channel_service,
asyncio,
base64,
bot_to_dict,
@@ -64,34 +65,34 @@ def test_clean_scope():
assert _clean_scope({"skill_ids": [1, 2]}) == {"skill_ids": ["1", "2"]}
-def test_create_bot_denied_without_capability(db_session):
+def test_create_bot_denied_without_capability(db_session, monkeypatch):
_mk_user(db_session, "u_deny", meta={})
- svc = ChannelService(db_session)
+ _share_sessions(monkeypatch, db_session)
with pytest.raises(AccessDeniedError):
- asyncio.run(svc.create_bot(
+ asyncio.run(channel_service.create_bot(
"u_deny", channel_type="lark", app_id="cli_a", app_secret="s",
))
-def test_create_bot_token_lock(db_session):
+def test_create_bot_token_lock(db_session, monkeypatch):
_mk_user(db_session, "u_ok", meta={"can_create_channel_bot": True})
# Pre-occupy the same app
ChannelConnectionRepository(db_session).create({
"channel_id": "chan_pre", "owner_user_id": "u_ok", "channel_type": "lark",
"app_id": "cli_dup", "config": {}, "status": "connected",
})
- svc = ChannelService(db_session)
+ _share_sessions(monkeypatch, db_session)
with pytest.raises(BadRequestError):
- asyncio.run(svc.create_bot(
+ asyncio.run(channel_service.create_bot(
"u_ok", channel_type="lark", app_id="cli_dup", app_secret="s",
))
-def test_create_bot_rejects_unknown_channel(db_session):
+def test_create_bot_rejects_unknown_channel(db_session, monkeypatch):
_mk_user(db_session, "u_ch", meta={"can_create_channel_bot": True})
- svc = ChannelService(db_session)
+ _share_sessions(monkeypatch, db_session)
with pytest.raises(BadRequestError):
- asyncio.run(svc.create_bot(
+ asyncio.run(channel_service.create_bot(
"u_ch", channel_type="telegram", app_id="a", app_secret="s",
))
@@ -124,12 +125,12 @@ def test_repo_list_filters_by_agent(db_session):
assert [c.channel_id for c in repo.list_by_owner("u_f", agent_id="ag_1")] == ["chan_ag"]
-def test_create_bot_rejects_unknown_agent(db_session):
+def test_create_bot_rejects_unknown_agent(db_session, monkeypatch):
_mk_user(db_session, "u_ag", meta={"can_create_channel_bot": True})
- svc = ChannelService(db_session)
+ _share_sessions(monkeypatch, db_session)
# agent_id pointing to a nonexistent/inaccessible sub-agent → rejected before any network call
with pytest.raises(BadRequestError):
- asyncio.run(svc.create_bot(
+ asyncio.run(channel_service.create_bot(
"u_ag", channel_type="lark", app_id="cli_ag2", app_secret="s",
agent_id="nope_agent",
))
@@ -328,13 +329,14 @@ def test_lark_parse_post_extracts_text_and_images():
assert any(a["key"] == "ik_x" for a in m.attachments)
-def test_inbound_ingest_attachments_stores_artifact(db_session):
+def test_inbound_ingest_attachments_stores_artifact(db_session, monkeypatch):
import asyncio
from integrations.channels import inbound as inb
from core.db.models import Artifact
conn = _mk_conn(db_session)
+ _share_sessions(monkeypatch, db_session)
class _Adapter:
async def download_resource(self, conn, msg, att):
@@ -350,7 +352,7 @@ def upload_bytes(self, content, key):
try:
msg = _inbound("oc_1", "p2p", text="", mid="mf1")
msg.attachments = [{"kind": "file", "key": "fk_1", "name": "note.txt"}]
- files = asyncio.run(inbound_attachments.ingest_attachments(db_session, _Adapter(), conn, "u_o", "chat_x", msg))
+ files = asyncio.run(inbound_attachments.ingest_attachments(_Adapter(), conn, "u_o", "chat_x", msg))
finally:
cs.get_storage = orig_get
diff --git a/src/backend/tests/integrations/channels/bot_cases_4.py b/src/backend/tests/integrations/channels/bot_cases_4.py
index 27893a90..abbfb36c 100644
--- a/src/backend/tests/integrations/channels/bot_cases_4.py
+++ b/src/backend/tests/integrations/channels/bot_cases_4.py
@@ -1,4 +1,5 @@
from tests.integrations.channels.bot_support import (
+ _share_sessions,
BadRequestError,
ChannelConnectionRepository,
InboundMsg,
@@ -131,7 +132,7 @@ def test_inbound_gate_routes_bystander_messages(db_session, monkeypatch):
# 不 patch close:fixture 是共享文件 SQLite + teardown drop_all,压住 close 会留锁
# 拖垮后续用例建表。但 _process_inbound 内部会 close,而 Session.close() 会把已加载
# 对象全部 expunge —— 所以下面一律用 channel_id 重新取 conn,不复用旧引用。
- monkeypatch.setattr(inbound_mod, "SessionLocal", lambda: db_session)
+ _share_sessions(monkeypatch, db_session)
started = []
@@ -348,7 +349,7 @@ def test_inbound_channel_origin_carries_channel_type(db_session, monkeypatch):
import integrations.channels.inbound as inbound_mod
chan_id = _mk_conn(db_session).channel_id
- monkeypatch.setattr(inbound_mod, "SessionLocal", lambda: db_session)
+ _share_sessions(monkeypatch, db_session)
captured = {}
diff --git a/src/backend/tests/integrations/channels/bot_cases_5.py b/src/backend/tests/integrations/channels/bot_cases_5.py
index a2ede987..c9b3101f 100644
--- a/src/backend/tests/integrations/channels/bot_cases_5.py
+++ b/src/backend/tests/integrations/channels/bot_cases_5.py
@@ -5,16 +5,18 @@
_hist,
_inbound,
_mk_conn,
+ _share_sessions,
asyncio,
json,
re,
)
-def test_history_pull_is_incremental_and_dedupes(db_session):
+def test_history_pull_is_incremental_and_dedupes(db_session, monkeypatch):
"""第二次拉取必须以游标为下界,且边界重复的消息不能重复入库。"""
from integrations.channels.inbound_sessions import find_or_create_session as _find_or_create_session
from integrations.channels.inbound_history import pull_history as _pull_history
+ _share_sessions(monkeypatch, db_session)
conn = _mk_conn(db_session)
msg = _inbound("oc_g1", "group", mid="m_at1")
session = _find_or_create_session(db_session, conn, msg)
@@ -28,11 +30,11 @@ def test_history_pull_is_incremental_and_dedupes(db_session):
[_hist("h2", "地点 302", t0 + 1_000), _hist("h3", "带上季度数据", t0 + 2_000)],
])
- assert asyncio.run(_pull_history(db_session, ad, conn, msg, session)) == 2
+ assert asyncio.run(_pull_history(ad, conn, msg, session.chat_id)) == 2
assert ad.calls[0]["since_ms"] > 0, "首次拉取要有冷启动窗口下界,不能从 0 全量拉"
assert ad.calls[0]["limit"] == 80
- assert asyncio.run(_pull_history(db_session, ad, conn, msg, session)) == 1, "重复的不该再记一次"
+ assert asyncio.run(_pull_history(ad, conn, msg, session.chat_id)) == 1, "重复的不该再记一次"
assert ad.calls[1]["since_ms"] == t0 + 1_000, "第二次必须从游标开始,而不是重新拉一遍窗口"
db_session.refresh(session)
@@ -40,12 +42,13 @@ def test_history_pull_is_incremental_and_dedupes(db_session):
assert texts == ["周三评审会", "地点 302", "带上季度数据"]
-def test_history_pull_respects_budget_and_truncation(db_session):
+def test_history_pull_respects_budget_and_truncation(db_session, monkeypatch):
"""总字数预算与单条截断都要生效——活跃群不能把提示词撑爆。"""
from integrations.channels.inbound_state import HISTORY_MAX_TOTAL_CHARS as _HISTORY_MAX_TOTAL_CHARS, OBSERVE_TEXT_MAX as _OBSERVE_TEXT_MAX
from integrations.channels.inbound_sessions import find_or_create_session as _find_or_create_session
from integrations.channels.inbound_history import pull_history as _pull_history
+ _share_sessions(monkeypatch, db_session)
conn = _mk_conn(db_session)
msg = _inbound("oc_g2", "group", mid="m_at2")
session = _find_or_create_session(db_session, conn, msg)
@@ -54,7 +57,7 @@ def test_history_pull_respects_budget_and_truncation(db_session):
_now = int(_t.time() * 1000)
big = [_hist(f"b{i}", "x" * 5000, _now - 60_000 + i) for i in range(10)]
ad = _HistAdapter([big])
- n = asyncio.run(_pull_history(db_session, ad, conn, msg, session))
+ n = asyncio.run(_pull_history(ad, conn, msg, session.chat_id))
db_session.refresh(session)
entries = (session.extra_data or {})["observed"]
@@ -63,11 +66,12 @@ def test_history_pull_respects_budget_and_truncation(db_session):
assert sum(len(e["t"]) for e in entries) <= _HISTORY_MAX_TOTAL_CHARS, "总量要卡预算"
-def test_history_pull_records_attachment_handles(db_session):
+def test_history_pull_records_attachment_handles(db_session, monkeypatch):
"""历史里的文件同样只留句柄进索引,且带上 message_id 供后续按需取回。"""
from integrations.channels.inbound_sessions import find_or_create_session as _find_or_create_session
from integrations.channels.inbound_history import pull_history as _pull_history
+ _share_sessions(monkeypatch, db_session)
conn = _mk_conn(db_session)
msg = _inbound("oc_g3", "group", mid="m_at3")
session = _find_or_create_session(db_session, conn, msg)
@@ -76,7 +80,7 @@ def test_history_pull_records_attachment_handles(db_session):
_hist("hf1", "看下这个", int(__import__("time").time() * 1000) - 60_000,
attachments=[{"kind": "file", "key": "fk_h", "name": "周报.docx"}]),
]])
- asyncio.run(_pull_history(db_session, ad, conn, msg, session))
+ asyncio.run(_pull_history(ad, conn, msg, session.chat_id))
db_session.refresh(session)
idx = (session.extra_data or {})["observed_files"]
@@ -85,12 +89,13 @@ def test_history_pull_records_attachment_handles(db_session):
assert idx["fk_h"]["raw"] == {"lark_message_id": "hf1"}
-def test_history_pull_degrades_and_stops_retrying(db_session):
+def test_history_pull_degrades_and_stops_retrying(db_session, monkeypatch):
"""拉取失败绝不能影响回复;连续失败到上限后不再重试,避免每条消息都白等一次超时。"""
from integrations.channels.inbound_state import HISTORY_MAX_FAILURES as _HISTORY_MAX_FAILURES
from integrations.channels.inbound_sessions import find_or_create_session as _find_or_create_session
from integrations.channels.inbound_history import pull_history as _pull_history
+ _share_sessions(monkeypatch, db_session)
conn = _mk_conn(db_session)
msg = _inbound("oc_g4", "group", mid="m_at4")
session = _find_or_create_session(db_session, conn, msg)
@@ -103,13 +108,13 @@ async def fetch_history(self, conn, conversation_id, *, since_ms, limit, newest_
bad = _Broken()
for _ in range(_HISTORY_MAX_FAILURES + 3):
- assert asyncio.run(_pull_history(db_session, bad, conn, msg, session)) == 0
+ assert asyncio.run(_pull_history(bad, conn, msg, session.chat_id)) == 0
assert _Broken.calls == _HISTORY_MAX_FAILURES, "到上限后应停止重试"
# 渠道没实现该钩子(钉钉当前状态)→ 直接跳过,不报错
class _NoHook:
pass
- assert asyncio.run(_pull_history(db_session, _NoHook(), conn, msg, session)) == 0
+ assert asyncio.run(_pull_history(_NoHook(), conn, msg, session.chat_id)) == 0
def test_lark_history_item_parsing():
@@ -254,7 +259,7 @@ def test_dingtalk_parse_dws_history_real_shape():
assert p({}) == [] and p(None) == []
-def test_history_coldstart_takes_newest_end(db_session):
+def test_history_coldstart_takes_newest_end(db_session, monkeypatch):
"""冷启动要取窗口里**最新**的一批,不是最旧的——7 天窗口 + 条数上限下,取旧端等于
把一周前的闲聊塞进来、而最近几天反而看不到。截断也必须砍旧的那头。"""
import time as _t
@@ -263,6 +268,7 @@ def test_history_coldstart_takes_newest_end(db_session):
from integrations.channels.inbound_sessions import find_or_create_session as _find_or_create_session
from integrations.channels.inbound_history import pull_history as _pull_history
+ _share_sessions(monkeypatch, db_session)
conn = _mk_conn(db_session)
msg = _inbound("oc_cold", "group", mid="m_cold")
session = _find_or_create_session(db_session, conn, msg)
@@ -271,7 +277,7 @@ def test_history_coldstart_takes_newest_end(db_session):
# 单条先被截到 500 字,所以要够多条才会触到 12000 字总预算(约 24 条)
batch = [_hist(f"c{i}", "x" * 4000, now - i * 60_000, name=f"甲{i:02d}") for i in range(30)]
ad = _HistAdapter([batch])
- n = asyncio.run(_pull_history(db_session, ad, conn, msg, session))
+ n = asyncio.run(_pull_history(ad, conn, msg, session.chat_id))
assert ad.calls[0]["newest_first"] is True, "首次拉取必须取新端"
# 冷启动窗口 = 7 天
@@ -289,21 +295,22 @@ def test_history_coldstart_takes_newest_end(db_session):
assert names == sorted(names, key=lambda x: -int(x[1:])), f"落库应为时间正序: {names}"
-def test_history_incremental_is_not_newest_first(db_session):
+def test_history_incremental_is_not_newest_first(db_session, monkeypatch):
"""有游标之后是增量:从游标往后取,方向必须是旧→新,否则会跳过消息。"""
import time as _t
from integrations.channels.inbound_sessions import find_or_create_session as _find_or_create_session
from integrations.channels.inbound_history import pull_history as _pull_history
+ _share_sessions(monkeypatch, db_session)
conn = _mk_conn(db_session)
msg = _inbound("oc_inc", "group", mid="m_inc")
session = _find_or_create_session(db_session, conn, msg)
t0 = int(_t.time() * 1000) - 3_600_000
ad = _HistAdapter([[_hist("i1", "第一条", t0)], [_hist("i2", "第二条", t0 + 1000)]])
- asyncio.run(_pull_history(db_session, ad, conn, msg, session))
- asyncio.run(_pull_history(db_session, ad, conn, msg, session))
+ asyncio.run(_pull_history(ad, conn, msg, session.chat_id))
+ asyncio.run(_pull_history(ad, conn, msg, session.chat_id))
assert ad.calls[0]["newest_first"] is True, "第一次是冷启动"
assert ad.calls[1]["newest_first"] is False, "第二次是增量,必须旧→新"
diff --git a/src/backend/tests/integrations/channels/bot_support.py b/src/backend/tests/integrations/channels/bot_support.py
index a0e9cc04..507a380e 100644
--- a/src/backend/tests/integrations/channels/bot_support.py
+++ b/src/backend/tests/integrations/channels/bot_support.py
@@ -66,7 +66,15 @@
from core.infra.exceptions import AccessDeniedError, BadRequestError
-from integrations.channels.service import ChannelService, _clean_scope, bot_to_dict
+from integrations.channels import service as channel_service
+from integrations.channels.service import _clean_scope, bot_to_dict
+
+
+def _share_sessions(monkeypatch, db):
+ """Steps that run through run_in_session open their sessions on the test's database."""
+ from sqlalchemy.orm import sessionmaker
+
+ monkeypatch.setattr("core.db.engine.SessionLocal", sessionmaker(bind=db.get_bind()))
def _mk_user(db, uid="u1", meta=None):
@@ -141,23 +149,13 @@ class _FakeWXConn:
def _reg_channel_tool(user_id, chat_id):
- """注册工具并取出被注册的函数。
-
- 注意:register_* 系列拿到的不是 AgentScope 2.0 的 Toolkit,而是鸭子类型的
- ToolCollector(见 core/llm/runtime/tool_collector.py)——真 Toolkit 是一次性构造的。
- """
- from core.llm.runtime.tool_collector import ToolCollector
+ """从原生工具列表取出附件工具函数。"""
+ from core.llm.factory.tools.definitions import ToolAssembly
from integrations.channels.tools.attachment import register_channel_attachment
- tc = ToolCollector()
+ tc = ToolAssembly()
register_channel_attachment(tc, user_id=user_id, chat_id=chat_id)
- ft = tc._tools.get("channel_read_attachment")
- assert ft is not None, "channel_read_attachment 未注册成功"
- for attr in ("original_func", "func", "_func"):
- fn = getattr(ft, attr, None)
- if callable(fn):
- return fn
- raise AssertionError(f"取不到底层函数,FunctionTool 属性:{dir(ft)}")
+ return next(tool for tool in tc if tool.name == "channel_read_attachment")._func
def _tool_json(resp):
diff --git a/src/backend/tests/integrations/channels/test_channel_bots_api.py b/src/backend/tests/integrations/channels/test_channel_bots_api.py
index 9d84074a..1387b046 100644
--- a/src/backend/tests/integrations/channels/test_channel_bots_api.py
+++ b/src/backend/tests/integrations/channels/test_channel_bots_api.py
@@ -55,6 +55,8 @@ def _override_db():
user_id=state["uid"], user_center_id="c", username=state["uid"],
)
app.dependency_overrides[get_db] = _override_db
+ # Async routes open their sessions through run_in_session.
+ monkeypatch.setattr("core.db.engine.SessionLocal", Session)
c = TestClient(app)
c.uidref = state # lets tests switch the current user
try:
diff --git a/src/backend/tests/integrations/channels/test_channel_desktop_e2e.py b/src/backend/tests/integrations/channels/test_channel_desktop_e2e.py
index b655d6a7..569e0e76 100644
--- a/src/backend/tests/integrations/channels/test_channel_desktop_e2e.py
+++ b/src/backend/tests/integrations/channels/test_channel_desktop_e2e.py
@@ -10,6 +10,7 @@
from contextvars import Context
import core.agent_runtime.runner as runner
+import core.db.engine as db_engine
import httpx
import pytest
from core.files.artifacts import store
@@ -22,11 +23,11 @@
from integrations.channels import desktop_worker as worker
from core.files.artifacts.service import persist_artifacts
from integrations.channels.relay_dispatch import dispatch_to_desktop
-from integrations.channels.service import ChannelService
from core.files.storage import LocalStorageBackend
from httpx import _client
from tests.integrations.channels.test_channel_desktop_http import cloud # noqa: F401
from tests.integrations.channels.test_channel_desktop_relay_api import api # noqa: F401
+from tests.integrations.channels.test_channel_desktop_relay_api import IN_CLOUD
from tests.integrations.channels.test_channel_desktop_worker import runtime # noqa: F401
# Restore the actual methods overridden by the older API fixture.
@@ -89,13 +90,19 @@ async def test_binding_ingress_local_run_reply_file_and_complete(
tmp_path,
):
client, cloud_sessions, _ = api
+ local_sessions = db_engine.SessionLocal
+ # One process plays both sides: cloud requests get the cloud database.
+ monkeypatch.setattr(
+ "core.db.engine.SessionLocal",
+ lambda: (cloud_sessions if IN_CLOUD.get() else local_sessions)(),
+ )
identity = bridge_identity.get_identity_state()
with runtime() as db:
db.get(UserShadow, "local-owner").user_center_id = "center"
db.commit()
for name, method in LARK_METHODS.items():
monkeypatch.setattr(LarkAdapter, name, method)
- monkeypatch.setattr(ChannelService, "_start_long_conn", lambda *args: None)
+ monkeypatch.setattr("integrations.channels.service._start_long_conn", lambda *args: None)
ephemeral = LocalEphemeralState()
monkeypatch.setattr("core.infra.ephemeral.get_ephemeral_state", lambda: ephemeral)
provider_requests = []
@@ -197,8 +204,7 @@ async def respond(request):
"AsyncClient",
lambda **kw: _client.AsyncClient(transport=httpx.MockTransport(respond), **kw),
)
- with runtime() as db:
- grant = await worker.prepare_binding(db, "local-owner")
+ grant = await worker.prepare_binding("local-owner")
if channel == "weixin":
start = client.post(
"/v1/channels/weixin/bind/start",
diff --git a/src/backend/tests/integrations/channels/test_channel_desktop_http.py b/src/backend/tests/integrations/channels/test_channel_desktop_http.py
index 6d729459..c8d70561 100644
--- a/src/backend/tests/integrations/channels/test_channel_desktop_http.py
+++ b/src/backend/tests/integrations/channels/test_channel_desktop_http.py
@@ -34,8 +34,7 @@ def cloud(api, monkeypatch):
async def test_desktop_register_claim_renew_reply_complete_accept_cloud_envelopes(api, cloud):
_, factory, sent = api
- with factory() as db:
- grant = await worker.prepare_binding(db, "owner")
+ grant = await worker.prepare_binding("owner")
assert grant["device_name"]
assert grant["device_id"] == "device-a"
@@ -88,9 +87,9 @@ async def test_invalid_registration_is_not_saved(api, cloud, monkeypatch, payloa
transport=httpx.MockTransport(lambda request: httpx.Response(
200, json={"code": 10000, "data": payload})), **kw,
))
+ with pytest.raises(BadRequestError, match="响应格式"):
+ await worker.prepare_binding("owner")
with api[1]() as db:
- with pytest.raises(BadRequestError, match="响应格式"):
- await worker.prepare_binding(db, "owner")
assert not db.scalar(select(ContentBlock).where(ContentBlock.id.like(worker.GRANT_PREFIX + "%")))
@@ -99,15 +98,13 @@ async def test_invalid_registration_is_not_saved(api, cloud, monkeypatch, payloa
])
async def test_credential_channels_bind_to_registered_desktop(api, cloud, monkeypatch, channel, transport):
from integrations.channels.registry import get_adapter
- from integrations.channels.service import ChannelService
async def validate(self, conn):
return {}
monkeypatch.setattr(type(get_adapter(channel)), "validate_credentials", validate)
- monkeypatch.setattr(ChannelService, "_start_long_conn", lambda *args: None)
+ monkeypatch.setattr("integrations.channels.service._start_long_conn", lambda *args: None)
client, factory, _ = api
- with factory() as db:
- grant = await worker.prepare_binding(db, "owner")
+ grant = await worker.prepare_binding("owner")
response = client.post("/v1/channels/bots", json={
"channel_type": channel, "app_id": "fixture-app", "app_secret": "fixture-secret",
"transport": transport, "execution_location": "local", "local_binding_id": grant["binding_id"],
@@ -121,7 +118,6 @@ async def validate(self, conn):
async def test_weixin_qr_local_binding_preserves_selected_device(api, cloud, monkeypatch):
from integrations.channels.adapters.weixin import WeixinAdapter
from core.infra.ephemeral import LocalEphemeralState
- from integrations.channels.service import ChannelService
async def start():
return {"qrcode": "fixture-qr", "qrcode_img_content": "fixture-png"}
@@ -132,10 +128,9 @@ async def poll(qrcode):
monkeypatch.setattr("core.infra.ephemeral.get_ephemeral_state", lambda: ephemeral)
monkeypatch.setattr(WeixinAdapter, "start_qr_bind", staticmethod(start))
monkeypatch.setattr(WeixinAdapter, "poll_qr_status", staticmethod(poll))
- monkeypatch.setattr(ChannelService, "_start_long_conn", lambda *args: None)
+ monkeypatch.setattr("integrations.channels.service._start_long_conn", lambda *args: None)
client, factory, _ = api
- with factory() as db:
- grant = await worker.prepare_binding(db, "owner")
+ grant = await worker.prepare_binding("owner")
result = client.post("/v1/channels/weixin/bind/start", params={
"execution_location": "local", "local_binding_id": grant["binding_id"],
})
@@ -203,8 +198,8 @@ async def test_other_account_cannot_bind_device_grant_over_http(api, cloud):
from core.auth.backend import UserContext, get_current_user
client, factory, _ = api
+ grant = await worker.prepare_binding("owner")
with factory() as db:
- grant = await worker.prepare_binding(db, "owner")
db.add(UserShadow(user_id="other", username="other", extra_data={"can_create_channel_bot": True}))
db.commit()
client.app.dependency_overrides[get_current_user] = lambda: UserContext(
diff --git a/src/backend/tests/integrations/channels/test_channel_desktop_relay_api.py b/src/backend/tests/integrations/channels/test_channel_desktop_relay_api.py
index 0ac4ab77..8446ff31 100644
--- a/src/backend/tests/integrations/channels/test_channel_desktop_relay_api.py
+++ b/src/backend/tests/integrations/channels/test_channel_desktop_relay_api.py
@@ -1,5 +1,7 @@
"""HTTP contract for desktop channel binding, device isolation and idempotent replies."""
+from contextvars import ContextVar
+
import pytest
from core.auth.backend import UserContext, get_current_user
from integrations.channels.protocol import InboundMsg, SendResult
@@ -12,6 +14,10 @@
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
+
+# Set while the cloud app serves a request, so a test that also runs a desktop
+# database in this process can route each session to the right side.
+IN_CLOUD = ContextVar("in_cloud", default=False)
from sqlalchemy.pool import StaticPool
@@ -36,6 +42,15 @@ def api(monkeypatch):
db.commit()
app = FastAPI()
setup_error_handlers(app)
+
+ @app.middleware("http")
+ async def cloud_scope(request, call_next):
+ token = IN_CLOUD.set(True)
+ try:
+ return await call_next(request)
+ finally:
+ IN_CLOUD.reset(token)
+
app.include_router(router)
app.include_router(desktop_router)
@@ -44,6 +59,8 @@ def database():
yield db
app.dependency_overrides[get_db] = database
+ # Async routes open their sessions through run_in_session.
+ monkeypatch.setattr("core.db.engine.SessionLocal", factory)
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id="owner", user_center_id="center", username="owner"
)
diff --git a/src/backend/tests/integrations/channels/test_channel_desktop_worker.py b/src/backend/tests/integrations/channels/test_channel_desktop_worker.py
index 604fba7e..0dd47f92 100644
--- a/src/backend/tests/integrations/channels/test_channel_desktop_worker.py
+++ b/src/backend/tests/integrations/channels/test_channel_desktop_worker.py
@@ -35,8 +35,6 @@ def runtime(tmp_path, monkeypatch):
"core.db.engine.SessionLocal",
"integrations.channels.inbound.SessionLocal",
"integrations.channels.inbound_attachments.SessionLocal",
- 'core.db.engine.SessionLocal',
- 'core.runs.tool_effect_recovery.SessionLocal',
):
monkeypatch.setattr(target, sessions)
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
diff --git a/src/backend/tests/integrations/channels/test_weixin_connection_health.py b/src/backend/tests/integrations/channels/test_weixin_connection_health.py
index 1fd4ba7c..c6e6731b 100644
--- a/src/backend/tests/integrations/channels/test_weixin_connection_health.py
+++ b/src/backend/tests/integrations/channels/test_weixin_connection_health.py
@@ -180,12 +180,11 @@ def unexpected_request(**kwargs):
def test_qr_binding_preserves_user_for_new_and_existing_bot(api, monkeypatch):
from core.infra.ephemeral import LocalEphemeralState
- from integrations.channels.service import ChannelService
from integrations.channels.models import ChannelConnection
state = LocalEphemeralState()
monkeypatch.setattr("core.infra.ephemeral.get_ephemeral_state", lambda: state)
- monkeypatch.setattr(ChannelService, "_start_long_conn", lambda *args: None)
+ monkeypatch.setattr("integrations.channels.service._start_long_conn", lambda *args: None)
async def start():
return {"qrcode": "fixture-qr", "qrcode_img_content": "png"}
diff --git a/src/backend/tests/integrations/channels/test_weixin_qr_protocol.py b/src/backend/tests/integrations/channels/test_weixin_qr_protocol.py
index 7a33b03e..883491f8 100644
--- a/src/backend/tests/integrations/channels/test_weixin_qr_protocol.py
+++ b/src/backend/tests/integrations/channels/test_weixin_qr_protocol.py
@@ -4,7 +4,6 @@
import pytest
from integrations.channels.adapters.weixin import WeixinAdapter
from core.infra.ephemeral import LocalEphemeralState
-from integrations.channels.service import ChannelService
from httpx import _client
from tests.integrations.channels.test_channel_desktop_relay_api import api, headers # noqa: F401
@@ -38,7 +37,7 @@ def test_raw_weixin_wait_scan_confirm_creates_local_bot(api, monkeypatch):
client, _, _ = api
ephemeral = LocalEphemeralState()
monkeypatch.setattr("core.infra.ephemeral.get_ephemeral_state", lambda: ephemeral)
- monkeypatch.setattr(ChannelService, "_start_long_conn", lambda *args: None)
+ monkeypatch.setattr("integrations.channels.service._start_long_conn", lambda *args: None)
states = iter(
[
{"status": "wait"},
diff --git a/src/backend/tests/integrations/dingtalk/test_dingtalk_integration.py b/src/backend/tests/integrations/dingtalk/test_dingtalk_integration.py
index 09c22ecd..55e4b822 100644
--- a/src/backend/tests/integrations/dingtalk/test_dingtalk_integration.py
+++ b/src/backend/tests/integrations/dingtalk/test_dingtalk_integration.py
@@ -11,6 +11,7 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
from core.db.engine import Base
import core.db.models # noqa: F401 ensure all models are registered (FK depends on users_shadow)
@@ -31,7 +32,9 @@
@pytest.fixture
def db(monkeypatch):
monkeypatch.delenv("HUGAGENT_CAPS_ROOT", raising=False)
- eng = create_engine("sqlite:///:memory:")
+ eng = create_engine(
+ "sqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False}
+ )
Base.metadata.create_all(eng)
sessions = sessionmaker(bind=eng)
monkeypatch.setattr("core.db.engine.SessionLocal", sessions)
@@ -204,7 +207,7 @@ def test_settings_dws_arch_flag():
def test_dingtalk_seeded_in_system_configs():
# Custom App credentials go through system-config dingtalk.* keys (configured visually in the Config admin platform, not in .env)
- from core.config.store.system_config_state import SEED_CONFIGS
+ from core.config.store.system_config_constants import SEED_CONFIGS
keys = {row[0] for row in SEED_CONFIGS}
assert {"dingtalk.client_id", "dingtalk.client_secret", "dingtalk.trusted_domains"} <= keys
@@ -258,11 +261,12 @@ def test_connection_repo_ensure_and_update(db):
assert rec2.granted_scopes == ["a", "b"]
-def test_connection_status_dict(db):
+@pytest.mark.asyncio
+async def test_connection_status_dict(db):
from integrations.dingtalk.service import DingTalkService
- svc = DingTalkService(db)
- data = svc.get_status("u1")
+ svc = DingTalkService()
+ data = await svc.get_status("u1")
assert data["status"] == "disconnected"
assert data["granted_scopes"] == []
assert "verification_url" in data
@@ -270,7 +274,8 @@ def test_connection_status_dict(db):
assert data["qr_data_uri"] is None # QR code is not rendered when not pending
-def test_status_dict_renders_qr_when_pending(db):
+@pytest.mark.asyncio
+async def test_status_dict_renders_qr_when_pending(db):
from integrations.dingtalk.service import DingTalkService
from integrations.dingtalk.parsing import make_qr_data_uri
@@ -286,7 +291,7 @@ def test_status_dict_renders_qr_when_pending(db):
"login_user_code": "AB-12",
},
)
- data = DingTalkService(db).get_status("u1")
+ data = await DingTalkService().get_status("u1")
assert data["status"] == "pending"
assert (data["qr_data_uri"] or "").startswith("data:image/svg+xml")
diff --git a/src/backend/tests/integrations/email/test_email_integration.py b/src/backend/tests/integrations/email/test_email_integration.py
index be4ed742..3874c187 100644
--- a/src/backend/tests/integrations/email/test_email_integration.py
+++ b/src/backend/tests/integrations/email/test_email_integration.py
@@ -14,6 +14,7 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
from core.db.engine import Base
import core.db.models # noqa: F401 ensures all models are registered (FK depends on users_shadow)
@@ -22,12 +23,17 @@
from integrations.email.repository import EmailConnectionRepository
from integrations.email import service as es
from integrations.email.service import EmailService, detect_provider, render_config_toml, get_email_config_bundle
+from tests.db_support import share_factory
@pytest.fixture
-def db():
- eng = create_engine("sqlite:///:memory:")
+def db(monkeypatch):
+ eng = create_engine(
+ "sqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False}
+ )
Base.metadata.create_all(eng)
+ # The service's repository opens its own sessions, one per step, on this database.
+ share_factory(monkeypatch, sessionmaker(bind=eng))
s = sessionmaker(bind=eng)()
s.add(UserShadow(user_id="u1", username="alice"))
s.commit()
@@ -126,8 +132,9 @@ def test_repo_ensure_and_update(db):
assert rec2.status == "connected" and rec2.email_address == "a@b.com" and rec2.imap_port == 993
-def test_status_dict_shape(db):
- data = EmailService(db).get_status("u1")
+@pytest.mark.asyncio
+async def test_status_dict_shape(db):
+ data = await EmailService().get_status("u1")
assert data["status"] == "disconnected"
for k in ("email_address", "provider", "imap_host", "smtp_host", "last_error"):
assert k in data
@@ -144,7 +151,7 @@ async def fake_verify(user_id):
monkeypatch.setattr(es, "_verify", fake_verify)
_redirect_storage(monkeypatch, tmp_path)
- svc = EmailService(db)
+ svc = EmailService()
data = await svc.connect("u1", email_address="alice@gmail.com", secret="app-pw", display_name="Alice")
assert data["status"] == "connected"
assert data["provider"] == "gmail"
@@ -165,7 +172,7 @@ async def fake_verify(user_id):
async def test_connect_custom_needs_server(monkeypatch, db, tmp_path):
_redirect_storage(monkeypatch, tmp_path)
- svc = EmailService(db)
+ svc = EmailService()
# Unknown domain with no server given → error guiding manual entry; must not attempt verification
data = await svc.connect("u1", email_address="user@self-hosted.example", secret="pw")
assert data["status"] == "error"
@@ -179,7 +186,7 @@ async def fake_verify(user_id):
monkeypatch.setattr(es, "_verify", fake_verify)
_redirect_storage(monkeypatch, tmp_path)
- svc = EmailService(db)
+ svc = EmailService()
data = await svc.connect("u1", email_address="alice@gmail.com", secret="wrong")
assert data["status"] == "error"
assert "AUTHENTICATIONFAILED" in (data["last_error"] or "")
@@ -191,7 +198,7 @@ async def fake_verify(user_id):
@pytest.mark.asyncio
async def test_connect_validates_input(db):
- svc = EmailService(db)
+ svc = EmailService()
assert (await svc.connect("u1", email_address="bad", secret="x"))["status"] == "error"
assert (await svc.connect("u1", email_address="a@b.com", secret=""))["status"] == "error"
@@ -203,7 +210,7 @@ async def fake_verify(user_id):
monkeypatch.setattr(es, "_verify", fake_verify)
_redirect_storage(monkeypatch, tmp_path)
- svc = EmailService(db)
+ svc = EmailService()
await svc.connect("u1", email_address="alice@gmail.com", secret="app-pw")
data = await svc.disconnect("u1")
assert data["status"] == "disconnected"
@@ -222,7 +229,7 @@ async def fake_verify(user_id):
monkeypatch.setattr(es, "_verify", fake_verify)
_redirect_storage(monkeypatch, tmp_path)
- svc = EmailService(db)
+ svc = EmailService()
await svc.connect("u1", email_address="alice@gmail.com", secret="app-pw")
# get_email_config_bundle uses its own SessionLocal — here we read the repo's bundle directly to verify its shape
rec = EmailConnectionRepository(db).get("u1")
@@ -331,6 +338,7 @@ def _override_db():
user_id="u1", user_center_id="c1", username="alice", email="a@e.com",
)
app.dependency_overrides[get_db] = _override_db
+ share_factory(monkeypatch, Session)
client = TestClient(app)
try:
r = client.get("/v1/integrations/email/status")
diff --git a/src/backend/tests/integrations/lark/test_app_lifecycle.py b/src/backend/tests/integrations/lark/test_app_lifecycle.py
index d0cccba8..efbb7509 100644
--- a/src/backend/tests/integrations/lark/test_app_lifecycle.py
+++ b/src/backend/tests/integrations/lark/test_app_lifecycle.py
@@ -22,12 +22,12 @@ async def initialize():
await asyncio.Future()
monkeypatch.setattr(configuration, "app_init_flow", initialize)
- service = LarkService(None)
+ service = LarkService()
try:
result = await service.start_app_init()
task = state.APP_INIT_TASK
assert result["verification_url"] == "https://example.test/setup"
- assert await LarkService(None).start_app_init() == result
+ assert await LarkService().start_app_init() == result
assert state.APP_INIT_TASK is task and calls == ["start"]
reset = await service.reset_app()
with pytest.raises(asyncio.CancelledError):
@@ -45,4 +45,4 @@ async def test_configured_app_does_not_spawn_initialization(monkeypatch):
async def forbidden():
raise AssertionError("already configured app spawned initialization")
monkeypatch.setattr(configuration, "app_init_flow", forbidden)
- assert (await LarkService(None).start_app_init())["app_id"] == "configured-app"
+ assert (await LarkService().start_app_init())["app_id"] == "configured-app"
diff --git a/src/backend/tests/llm/local_project_test_support.py b/src/backend/tests/llm/local_project_test_support.py
index 9743f660..6a9a2094 100644
--- a/src/backend/tests/llm/local_project_test_support.py
+++ b/src/backend/tests/llm/local_project_test_support.py
@@ -11,7 +11,7 @@
from core.llm.runtime import workspace
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools.pin_tool import register_pin_to_workspace
@@ -83,7 +83,7 @@ def local_project(tmp_path, db_session, monkeypatch):
def pin_tool(scope, session_id=None):
- collector = ToolCollector()
+ collector = ToolAssembly()
register_pin_to_workspace(collector, scope=scope, sandbox_session_id=session_id)
async def invoke(**args):
@@ -108,7 +108,7 @@ async def invoke(**args):
raise PermissionError(str(outcome.payload))
token = CURRENT_PERMISSION_TICKET.set(outcome.ticket)
try:
- return await collector.get_tool("pin_to_workspace")._func(**args)
+ return await next(t for t in collector if t.name == "pin_to_workspace")._func(**args)
finally:
CURRENT_PERMISSION_TICKET.reset(token)
diff --git a/src/backend/tests/llm/test_agent_api_runtime.py b/src/backend/tests/llm/test_agent_api_runtime.py
index 92cc5d1d..418989f8 100644
--- a/src/backend/tests/llm/test_agent_api_runtime.py
+++ b/src/backend/tests/llm/test_agent_api_runtime.py
@@ -65,7 +65,9 @@ class Collector:
def __init__(self):
self.tools = {}
- def register_tool_function(self, func, **kwargs):
+ def append(self, tool):
+ func = tool._func
+ kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.tools[func.__name__] = func
@@ -136,15 +138,15 @@ async def test_pin_rejects_private_artifact_and_only_accepts_file_ids(monkeypatc
import inspect
import json
import core.llm.api_runtime.agent_tools as tools
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
parsed = parse_api_scope(
scope(), owner_user_id="owner", chat_id="api_chat", agent_id="agent_123"
)
- collector = ToolCollector()
+ collector = ToolAssembly()
tools.register_agent_api_tools(collector, parsed)
assert all(
- collector.get_tool(name) is not None
+ any(t.name == name for t in collector)
for name in (
"Bash",
"write_stdin",
@@ -153,7 +155,7 @@ async def test_pin_rejects_private_artifact_and_only_accepts_file_ids(monkeypatc
"pin_to_workspace",
)
)
- pin = collector.get_tool("pin_to_workspace")._func
+ pin = next(t for t in collector if t.name == "pin_to_workspace")._func
assert "file_paths" not in inspect.signature(pin).parameters
monkeypatch.setattr(tools, "artifact_in_scope", lambda *_: False)
result = await pin(["private"])
@@ -297,7 +299,6 @@ async def test_api_sandbox_never_uses_general_pool_or_host_mounts(monkeypatch):
user_pool = SimpleNamespace(acquire=AsyncMock(return_value=SimpleNamespace(id="api-container")))
general_pool = SimpleNamespace(acquire=AsyncMock())
host = SimpleNamespace(_jupyter_user_pool=user_pool, _pool=general_pool)
- monkeypatch.setattr(sessions, "_user_bound_sandbox_required", lambda: False)
result = await sessions._OpenSandboxCreationMixin._create_session(host, "api_ak_123")
assert result.user_id == "api_ak_123"
user_pool.acquire.assert_awaited_once_with("api_ak_123")
@@ -317,7 +318,8 @@ def reject_mount(*args):
)
host = sessions._OpenSandboxCreationMixin()
host._make_config = lambda: None
- host._ttl_s = 60
+ host._create_owned_sandbox = create
+ host._session_expiry_s = lambda: 60
host._image = "image"
host._repoint_execd_direct = AsyncMock()
# Even if the workspace hook offers a mount, API principals must receive none.
diff --git a/src/backend/tests/llm/test_ask_user_question_tool.py b/src/backend/tests/llm/test_ask_user_question_tool.py
index 7cefb1b3..8c974357 100644
--- a/src/backend/tests/llm/test_ask_user_question_tool.py
+++ b/src/backend/tests/llm/test_ask_user_question_tool.py
@@ -4,7 +4,7 @@
import pytest
from agentscope.tool import Toolkit
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools import ask_user_question_tool as tool_module
@@ -64,13 +64,13 @@ def test_registration_policy_only_allows_standard_interactive_root(kwargs, expec
@pytest.mark.asyncio
async def test_registered_schema_matches_dsh_model_contract():
- collector = ToolCollector()
+ collector = ToolAssembly()
tool_module.register_ask_user_question(
collector,
chat_id="chat-schema",
interactive=True,
)
- toolkit = Toolkit(tools=collector.function_tools)
+ toolkit = Toolkit(tools=list(collector))
schema = next(
item["function"]
@@ -110,7 +110,9 @@ async def test_tool_returns_structured_answer_from_suspended_service(monkeypatch
captured = {}
class CaptureToolkit:
- def register_tool_function(self, function, **_kwargs):
+ def append(self, tool):
+ function = tool._func
+ _kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
captured["function"] = function
async def fake_ask(**kwargs):
@@ -179,7 +181,9 @@ async def test_tool_surfaces_non_answer_outcomes_without_retry_loop(
captured = {}
class CaptureToolkit:
- def register_tool_function(self, function, **_kwargs):
+ def append(self, tool):
+ function = tool._func
+ _kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
captured["function"] = function
async def fake_ask(**_kwargs):
diff --git a/src/backend/tests/llm/test_bash_process_tools.py b/src/backend/tests/llm/test_bash_process_tools.py
index 8d80ce79..adb023c9 100644
--- a/src/backend/tests/llm/test_bash_process_tools.py
+++ b/src/backend/tests/llm/test_bash_process_tools.py
@@ -12,7 +12,9 @@ class Toolkit:
def __init__(self):
self.functions = {}
- def register_tool_function(self, fn, **kwargs):
+ def append(self, tool):
+ fn = tool._func
+ kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.functions[fn.__name__] = fn
@@ -64,7 +66,7 @@ async def test_bash_yields_and_write_stdin_waits_on_same_owned_process(monkeypat
monkeypatch.setattr("core.config.local_mode.local_mode_enabled", lambda: False)
monkeypatch.setattr("core.sandbox.get_sandbox_provider", lambda: provider)
toolkit = Toolkit()
- register_bash(toolkit, loader=None, loaded_skill_ids=set(), chat_id="chat-a", user_id="u")
+ register_bash(toolkit, loader=None, chat_id="chat-a", user_id="u")
assert "write_stdin" in toolkit.functions
first = payload(await toolkit.functions["Bash"]("long-command", yield_time_ms=250))
assert first["session_id"].startswith("cmd_")
@@ -82,14 +84,14 @@ async def test_bash_yields_and_write_stdin_waits_on_same_owned_process(monkeypat
async def test_registered_schemas_expose_two_tool_contract(monkeypatch):
from agentscope.tool import Toolkit as AgentToolkit
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
monkeypatch.setenv("SANDBOX_TOOLS_ENABLED", "true")
- collector = ToolCollector()
- register_bash(collector, loader=None, loaded_skill_ids=set(), chat_id="schema-test")
+ collector = ToolAssembly()
+ register_bash(collector, loader=None, chat_id="schema-test")
schemas = {
s["function"]["name"]: s["function"]
- for s in await AgentToolkit(tools=collector.function_tools).get_tool_schemas()
+ for s in await AgentToolkit(tools=list(collector)).get_tool_schemas()
}
assert set(schemas) == {"Bash", "write_stdin", "background_process", "list_processes"}
assert schemas["Bash"]["parameters"]["properties"]["run_in_background"]["default"] is False
diff --git a/src/backend/tests/llm/test_chat_history_tool.py b/src/backend/tests/llm/test_chat_history_tool.py
index 991d9c0e..964682c5 100644
--- a/src/backend/tests/llm/test_chat_history_tool.py
+++ b/src/backend/tests/llm/test_chat_history_tool.py
@@ -22,7 +22,9 @@ class FakeToolkit:
def __init__(self):
self.tools = {}
- def register_tool_function(self, fn, **_kwargs):
+ def append(self, tool):
+ fn = tool._func
+ _kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.tools[fn.__name__] = fn
@@ -37,15 +39,7 @@ def wired(tmp_path, monkeypatch):
engine = create_engine(
f"sqlite:///{tmp_path / 'tool.db'}", connect_args={"check_same_thread": False}
)
- Base.metadata.create_all(
- engine,
- tables=[
- ChatSession.__table__,
- ChatMessage.__table__,
- ChatCompactionState.__table__,
- Project.__table__,
- ],
- )
+ Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
with Session() as db:
db.add_all(
diff --git a/src/backend/tests/llm/test_compaction_mid_turn.py b/src/backend/tests/llm/test_compaction_mid_turn.py
index 3f13c995..850bf55d 100644
--- a/src/backend/tests/llm/test_compaction_mid_turn.py
+++ b/src/backend/tests/llm/test_compaction_mid_turn.py
@@ -52,7 +52,8 @@ def _make_agent(
agent.model = SimpleNamespace(context_size=window)
agent.offloader = offloader
agent.context_config = ContextConfig()
- agent.state = SimpleNamespace(
+ from core.llm.runtime.state import AgentRuntimeState
+ agent.state = AgentRuntimeState(
context=list(context), summary="", chat_id=chat_id, session_id="sess-1"
)
agent._jx_compaction_system_prompt = sys_prompt
diff --git a/src/backend/tests/llm/test_context_adapter.py b/src/backend/tests/llm/test_context_adapter.py
index c058ffbf..fe348d18 100644
--- a/src/backend/tests/llm/test_context_adapter.py
+++ b/src/backend/tests/llm/test_context_adapter.py
@@ -9,7 +9,7 @@
from core.models.constants import PROVIDER_CONTEXT_META_KEY
from core.llm.context.ir import ContextAssembler
from core.llm.context.items import ContextItem
-from core.llm.context.ir_state import KIND_COMPACTION, KIND_MEMORY, KIND_REMINDER, KIND_TOOL_CALL, KIND_TOOL_RESULT, KIND_USER_INPUT
+from core.llm.context.ir_constants import KIND_COMPACTION, KIND_MEMORY, KIND_REMINDER, KIND_TOOL_CALL, KIND_TOOL_RESULT, KIND_USER_INPUT
from core.models.constants import IMAGE_TOKEN_RESERVE
from core.harness.execution_manifest import PromptManifestBuilder, stable_hash
diff --git a/src/backend/tests/llm/test_context_order_user_input.py b/src/backend/tests/llm/test_context_order_user_input.py
index c0620369..968b323a 100644
--- a/src/backend/tests/llm/test_context_order_user_input.py
+++ b/src/backend/tests/llm/test_context_order_user_input.py
@@ -12,7 +12,7 @@
from core.llm.context.adapter import AgentScopeContextAdapter
from core.llm.context.rendering import next_request_sequence, render_context_item
-from core.llm.context.ir_state import KIND_USER_INPUT, POLICY_NEVER
+from core.llm.context.ir_constants import KIND_USER_INPUT, POLICY_NEVER
from core.llm.context.ir import ContextAssembler
from core.llm.context.items import ContextItem
diff --git a/src/backend/tests/llm/test_context_safety.py b/src/backend/tests/llm/test_context_safety.py
index 5bedbf88..c8cc422b 100644
--- a/src/backend/tests/llm/test_context_safety.py
+++ b/src/backend/tests/llm/test_context_safety.py
@@ -9,6 +9,8 @@
class CaptureModel:
+ from agentscope.formatter import OpenAIChatFormatter
+ formatter = OpenAIChatFormatter()
model = "budget-probe"
context_size = 100
diff --git a/src/backend/tests/llm/test_context_step_units.py b/src/backend/tests/llm/test_context_step_units.py
index 625e5598..0e8be8a0 100644
--- a/src/backend/tests/llm/test_context_step_units.py
+++ b/src/backend/tests/llm/test_context_step_units.py
@@ -11,7 +11,7 @@
from core.models.chat_models import ReasoningEchoChatFormatter
from core.llm.context.adapter import AgentScopeContextAdapter
from core.llm.context.rendering import next_request_sequence, render_context_item
-from core.llm.context.ir_state import KIND_THINKING, KIND_TOOL_CALL, KIND_TOOL_RESULT, KIND_USER_INPUT, POLICY_NEVER
+from core.llm.context.ir_constants import KIND_THINKING, KIND_TOOL_CALL, KIND_TOOL_RESULT, KIND_USER_INPUT, POLICY_NEVER
from core.llm.context.ir import ContextAssembler
from core.llm.context.items import ContextItem
from core.llm.runtime.message_compat import session_to_msgs
diff --git a/src/backend/tests/llm/test_delta_coalescer.py b/src/backend/tests/llm/test_delta_coalescer.py
new file mode 100644
index 00000000..0b6c8e35
--- /dev/null
+++ b/src/backend/tests/llm/test_delta_coalescer.py
@@ -0,0 +1,145 @@
+"""Streaming deltas are merged before they become durable run events."""
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+from core.llm.runtime import streaming_constants
+from core.llm.runtime.delta_coalescer import DeltaCoalescer
+from core.llm.runtime.streaming import StreamingAgent
+
+
+def tool_delta(text, tid="t1"):
+ return ("tool_call_delta", {"name": "bash", "id": tid, "delta": text})
+
+
+def test_first_delta_is_immediate_and_followers_merge_until_due():
+ deltas = DeltaCoalescer()
+ assert deltas.feed(("text_delta", "a"), now=10.0) == [("text_delta", "a")]
+ assert deltas.feed(("text_delta", "b"), now=10.01) == []
+ assert deltas.feed(("text_delta", "c"), now=10.02) == []
+ assert deltas.deadline() == pytest.approx(10.0 + streaming_constants.DELTA_FLUSH_INTERVAL_S)
+ assert deltas.flush_due(now=10.03) == []
+ assert deltas.flush_due(now=10.06) == [("text_delta", "bc")]
+ assert deltas.deadline() is None
+
+
+def test_character_threshold_flushes_without_waiting(monkeypatch):
+ monkeypatch.setattr(streaming_constants, "DELTA_FLUSH_CHARS", 4)
+ deltas = DeltaCoalescer()
+ deltas.feed(("thinking_delta", "x"), now=0.0)
+ assert deltas.feed(("thinking_delta", "ab"), now=0.01) == []
+ assert deltas.feed(("thinking_delta", "cd"), now=0.02) == [("thinking_delta", "abcd")]
+
+
+def test_switching_stream_or_any_other_event_flushes_first_in_order():
+ deltas = DeltaCoalescer()
+ deltas.feed(("thinking_delta", "plan"), now=0.0)
+ assert deltas.feed(("thinking_delta", " more"), now=0.01) == []
+ # A different stream flushes the buffered one before itself is buffered.
+ assert deltas.feed(("text_delta", "answer"), now=0.02) == [("thinking_delta", " more")]
+ assert deltas.feed(("tool_call_start", {"id": "t1"}), now=0.03) == [
+ ("text_delta", "answer"),
+ ("tool_call_start", {"id": "t1"}),
+ ]
+ deltas.feed(tool_delta('{"a"', "t1"), now=1.0)
+ deltas.feed(tool_delta(":1}", "t1"), now=1.01)
+ # Parallel tool calls are separate streams.
+ assert deltas.feed(tool_delta("{", "t2"), now=1.02) == [tool_delta(":1}", "t1")]
+ assert deltas.drain(now=1.03) == [tool_delta("{", "t2")]
+
+
+def test_empty_deltas_are_dropped_and_reset_discards():
+ deltas = DeltaCoalescer()
+ assert deltas.feed(("text_delta", ""), now=0.0) == []
+ deltas.feed(("text_delta", "a"), now=0.0)
+ deltas.feed(("text_delta", "b"), now=0.01)
+ deltas.reset()
+ assert deltas.drain() == []
+ # After a reset the clock is forgotten: the next delta goes out at once.
+ assert deltas.feed(("text_delta", "c"), now=0.02) == [("text_delta", "c")]
+
+
+class TextBlockDeltaEvent:
+ def __init__(self, delta):
+ self.delta = delta
+
+
+class ToolCallStartEvent:
+ tool_call_id = "t1"
+ tool_call_name = "bash"
+
+
+def _agent(script):
+ async def reply_stream(inputs=None):
+ async for event in script():
+ yield event
+
+ state = SimpleNamespace(
+ user_id="u1",
+ chat_id="c1",
+ apply_request_context=lambda ctx, text: None,
+ context=SimpleNamespace(extend=lambda msgs: None),
+ pending_mcp_progress=[],
+ )
+ return SimpleNamespace(state=state, model=None, reply_stream=reply_stream)
+
+
+async def _collect(agent):
+ stream = StreamingAgent(agent, mcp_clients=[])
+ return [item async for item in stream.stream(session_messages=[], context={"enable_thinking": True})]
+
+
+@pytest.mark.asyncio
+async def test_token_stream_becomes_few_events_with_identical_text():
+ tokens = [f"t{i} " for i in range(200)]
+
+ async def script():
+ for token in tokens:
+ yield TextBlockDeltaEvent(token)
+
+ out = await _collect(_agent(script))
+ texts = [payload for kind, payload in out if kind == "text_delta"]
+ assert "".join(texts) == "".join(tokens)
+ assert len(texts) < len(tokens) / 10
+
+
+@pytest.mark.asyncio
+async def test_buffered_tail_is_flushed_on_deadline_while_the_model_is_silent(monkeypatch):
+ monkeypatch.setattr(streaming_constants, "QUEUE_POLL_INTERVAL_S", 5.0)
+ seen = []
+ released = asyncio.Event()
+
+ async def script():
+ yield TextBlockDeltaEvent("Hel")
+ yield TextBlockDeltaEvent("lo")
+ await released.wait()
+ yield ToolCallStartEvent()
+
+ stream = StreamingAgent(_agent(script), mcp_clients=[])
+ events = stream.stream(session_messages=[], context={"enable_thinking": True})
+ seen.append(await anext(events))
+ # "lo" is buffered; it must arrive on its deadline, not after the 5 s poll.
+ seen.append(await asyncio.wait_for(anext(events), timeout=1.0))
+ released.set()
+ seen.extend([item async for item in events])
+ assert seen[:2] == [("text_delta", "Hel"), ("text_delta", "lo")]
+ assert seen[2] == ("tool_call_start", {"name": "bash", "id": "t1"})
+ assert ("heartbeat", None) not in seen
+
+
+@pytest.mark.asyncio
+async def test_tail_is_flushed_before_tool_start_and_at_the_end():
+ async def script():
+ yield TextBlockDeltaEvent("a")
+ yield TextBlockDeltaEvent("b")
+ yield ToolCallStartEvent()
+ yield TextBlockDeltaEvent("c")
+ yield TextBlockDeltaEvent("d")
+
+ out = await _collect(_agent(script))
+ kinds = [kind for kind, _ in out]
+ assert kinds.index("tool_call_start") > 0
+ before = "".join(p for k, p in out[: kinds.index("tool_call_start")] if k == "text_delta")
+ after = "".join(p for k, p in out[kinds.index("tool_call_start") :] if k == "text_delta")
+ assert (before, after) == ("ab", "cd")
diff --git a/src/backend/tests/llm/test_execution_surface_limits.py b/src/backend/tests/llm/test_execution_surface_limits.py
index 2d26e90d..5719e2d3 100644
--- a/src/backend/tests/llm/test_execution_surface_limits.py
+++ b/src/backend/tests/llm/test_execution_surface_limits.py
@@ -6,7 +6,7 @@
from agentscope.message import UserMsg
from agentscope.tool import FunctionTool, ToolChunk
from core.llm.context.rendering import next_request_sequence, render_context_item
-from core.llm.context.ir_state import KIND_USER_INPUT, POLICY_NEVER
+from core.llm.context.ir_constants import KIND_USER_INPUT, POLICY_NEVER
from core.llm.context.items import make_text_context_item
from core.models.constants import IMAGE_TOKEN_RESERVE
from core.llm.runtime.manifest_agent import ManifestBoundAgent
diff --git a/src/backend/tests/llm/test_execution_surface_snapshot.py b/src/backend/tests/llm/test_execution_surface_snapshot.py
index 383b91d5..21727161 100644
--- a/src/backend/tests/llm/test_execution_surface_snapshot.py
+++ b/src/backend/tests/llm/test_execution_surface_snapshot.py
@@ -10,7 +10,7 @@
from core.llm.context.adapter import AgentScopeContextAdapter
from core.llm.context.rendering import next_request_sequence, render_context_item
from core.models.constants import PROVIDER_CONTEXT_META_KEY
-from core.llm.context.ir_state import KIND_USER_INPUT, POLICY_NEVER
+from core.llm.context.ir_constants import KIND_USER_INPUT, POLICY_NEVER
from core.llm.context.items import make_text_context_item
from core.harness.execution_manifest import PromptManifestBuilder, stable_hash
from core.llm.runtime.manifest_agent import ManifestBoundAgent
@@ -38,6 +38,8 @@ async def list_skills(self): # noqa: ANN201
class CaptureModel:
+ from agentscope.formatter import OpenAIChatFormatter
+ formatter = OpenAIChatFormatter()
model = "capture-model"
context_size = 32_768
diff --git a/src/backend/tests/llm/test_local_bash_permission.py b/src/backend/tests/llm/test_local_bash_permission.py
index 84d7bf3c..6d788b42 100644
--- a/src/backend/tests/llm/test_local_bash_permission.py
+++ b/src/backend/tests/llm/test_local_bash_permission.py
@@ -72,7 +72,9 @@ def _capture(policy, context):
class _Toolkit:
fn = None
- def register_tool_function(self, fn, **_kwargs):
+ def append(self, tool):
+ fn = tool._func
+ _kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.fn = fn
@@ -81,7 +83,6 @@ def _bash():
register_bash(
toolkit,
loader=None,
- loaded_skill_ids=set(),
chat_id="chat-1", user_id="user-1",
)
assert toolkit.fn is not None
diff --git a/src/backend/tests/llm/test_local_file_permission.py b/src/backend/tests/llm/test_local_file_permission.py
index 57112d76..ef884832 100644
--- a/src/backend/tests/llm/test_local_file_permission.py
+++ b/src/backend/tests/llm/test_local_file_permission.py
@@ -50,7 +50,9 @@ def _payload(response) -> dict:
class _Toolkit:
fn = None
- def register_tool_function(self, fn, **_kwargs):
+ def append(self, tool):
+ fn = tool._func
+ _kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.fn = fn
diff --git a/src/backend/tests/llm/test_local_project_cwd.py b/src/backend/tests/llm/test_local_project_cwd.py
index 68802381..9fcbfe5b 100644
--- a/src/backend/tests/llm/test_local_project_cwd.py
+++ b/src/backend/tests/llm/test_local_project_cwd.py
@@ -8,8 +8,9 @@
import pytest
from tests.llm.local_project_test_support import local_project, pin_tool
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly
from tests.llm.command_test_support import command_tool_state
+from tests.db_support import share_session
def payload(result):
@@ -27,7 +28,7 @@ async def test_relative_write_lands_in_project_and_pin_keeps_original(local_proj
source, scope = local_project
destination = source.parent / "交付 results" / "report.html"
- collector = ToolCollector()
+ collector = ToolAssembly()
register_write(
collector,
chat_id="pin-chat",
@@ -50,7 +51,7 @@ async def test_relative_write_lands_in_project_and_pin_keeps_original(local_proj
)
try:
result = payload(
- await collector.get_tool("Write")._func(
+ await next(t for t in collector if t.name == "Write")._func(
file_path="交付 results/report.html", content="Project original "
)
)
@@ -110,7 +111,7 @@ async def local_tools(local_project, command_tool_state, tmp_path, monkeypatch):
)
provider = ScriptRunnerProvider()
monkeypatch.setattr("core.sandbox.get_sandbox_provider", lambda: provider)
- collector = ToolCollector()
+ collector = ToolAssembly()
state = ReadStateTracker()
common = dict(
chat_id="pin-chat", sandbox_session_id="cwd-child", user_id="pin-user", scope=scope
@@ -119,7 +120,7 @@ async def local_tools(local_project, command_tool_state, tmp_path, monkeypatch):
register(collector, state=state, **common)
for register in (register_glob, register_grep):
register(collector, **common)
- register_bash(collector, loader=None, loaded_skill_ids=set(), **common)
+ register_bash(collector, loader=None, **common)
register_pin_to_workspace(collector, scope=scope, sandbox_session_id="cwd-child")
registry = ToolPermissionRegistry()
for name, spec in collector.permission_specs.items():
@@ -143,7 +144,7 @@ async def invoke(name, **args):
return outcome.payload
token = CURRENT_PERMISSION_TICKET.set(outcome.ticket)
try:
- return payload(await collector.get_tool(name)._func(**args))
+ return payload(await next(t for t in collector if t.name == name)._func(**args))
finally:
CURRENT_PERMISSION_TICKET.reset(token)
@@ -232,6 +233,7 @@ async def test_bash_files_pin_and_authenticated_preview_end_to_end(
app = FastAPI()
app.include_router(files.router)
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
headers = {
"x-desktop-bridge": "cwd-e2e-bridge",
"x-desktop-bridge-user": base64.b64encode(
@@ -371,6 +373,8 @@ def register_skills_to_toolkit(self, *args, **kwargs):
return 0
class ScriptedModel:
+ from agentscope.formatter import OpenAIChatFormatter
+ formatter = OpenAIChatFormatter()
model = "cwd-e2e-scripted"
context_size = 32768
calls = 0
diff --git a/src/backend/tests/llm/test_local_project_delivery.py b/src/backend/tests/llm/test_local_project_delivery.py
index 61611610..e750e96f 100644
--- a/src/backend/tests/llm/test_local_project_delivery.py
+++ b/src/backend/tests/llm/test_local_project_delivery.py
@@ -13,13 +13,13 @@
from core.llm.runtime import workspace
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly
from tests.llm.local_project_test_support import local_project, pin_tool
-def test_session_delivery_deduplicates_and_rejects_conflicting_names(
+def test_session_delivery_deduplicates_live_references_and_tracks_renames(
local_project, monkeypatch, tmp_path
):
from core.sandbox.workspace import resolution as _paths
@@ -39,14 +39,22 @@ def test_session_delivery_deduplicates_and_rejects_conflicting_names(
assert retry["pinned_count"] == 1
source.write_text("second")
changed = json.loads(asyncio.run(pin(file_paths=[str(source)])).content[0].text)
- assert changed["ok"] is False
+ assert changed["ok"] is True
assert changed["pinned_count"] == 1
- assert "同名文件" in changed["failed"][0]["error"]
+ assert changed["pinned"][0]["file_id"] == first["pinned"][0]["file_id"]
+ assert changed["pinned"][0]["already_pinned"] is True
+ from core.files.artifacts.local_reference import authorized_reference
+ reference = authorized_reference(first["pinned"][0]["file_id"], "pin-user")
+ assert reference["path"] == str(source)
+ assert Path(reference["path"]).read_text() == "second"
+ assert authorized_reference(reference["file_id"], "another-user") is None
renamed = source.with_name("output-v2.txt")
source.rename(renamed)
delivered = json.loads(asyncio.run(pin(file_paths=[str(renamed)])).content[0].text)
assert delivered["pinned_count"] == 2
assert delivered["pinned"][0]["file_id"] != first["pinned"][0]["file_id"]
+ assert authorized_reference(first["pinned"][0]["file_id"], "pin-user") is None
+ assert Path(authorized_reference(delivered["pinned"][0]["file_id"], "pin-user")["path"]).read_text() == "second"
def test_design_picker_accepts_local_images_without_pinning(local_project, monkeypatch, tmp_path):
@@ -77,7 +85,7 @@ async def choose(**kwargs):
return {"status": "chosen", "option_id": "b"}
monkeypatch.setattr(confirmation, "pick", choose)
- collector = ToolCollector()
+ collector = ToolAssembly()
register_choose_design(collector, chat_id="pin-chat", user_id="pin-user")
registry = ToolPermissionRegistry()
registry.register("choose_design", collector.permission_specs["choose_design"], source="test")
@@ -99,10 +107,37 @@ async def invoke():
assert outcome.proceed
token = CURRENT_PERMISSION_TICKET.set(outcome.ticket)
try:
- return await collector.get_tool("choose_design")._func(**args)
+ return await next(t for t in collector if t.name == "choose_design")._func(**args)
finally:
CURRENT_PERMISSION_TICKET.reset(token)
result = json.loads(asyncio.run(invoke()).content[0].text)
assert result["ok"] and result["selected_id"] == "b"
assert workspace.get_pinned() == []
+
+
+def test_expired_inherited_ticket_cannot_read_or_overwrite_reference(local_project, db_session):
+ from contextvars import copy_context
+ from fastapi import HTTPException
+ import pytest
+ from core.files.artifacts.local_reference import authorized_reference, overwrite_reference
+ from core.sandbox.permissions.dispatch_models import CURRENT_PERMISSION_TICKET, PermissionTicket, PermissionIntent
+ from features.desktop.local.grants import remove_grant
+
+ source, scope = local_project
+ delivered = json.loads(asyncio.run(pin_tool(scope)(file_paths=[str(source)])).content[0].text)
+ fid = delivered["pinned"][0]["file_id"]
+ ticket = PermissionTicket("Write", "finished-call", "", "test-write", (
+ PermissionIntent("local_path", "write", str(source), "temporary approval"),
+ ))
+ token = CURRENT_PERMISSION_TICKET.set(ticket)
+ inherited = copy_context()
+ CURRENT_PERMISSION_TICKET.reset(token)
+ remove_grant(str(source.parent))
+ # An in-flight approval is valid only until the dispatch completes.
+ assert inherited.run(authorized_reference, fid, "pin-user") is not None
+ ticket.lifetime.expire()
+ assert inherited.run(authorized_reference, fid, "pin-user") is None
+ with pytest.raises(HTTPException):
+ inherited.run(overwrite_reference, fid, "pin-user", b"unauthorized", db_session)
+ assert source.read_text() == "original report"
diff --git a/src/backend/tests/llm/test_local_project_pin.py b/src/backend/tests/llm/test_local_project_pin.py
index f40fc404..f60a98ed 100644
--- a/src/backend/tests/llm/test_local_project_pin.py
+++ b/src/backend/tests/llm/test_local_project_pin.py
@@ -17,10 +17,11 @@
from core.llm.runtime import workspace
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly
from tests.llm.local_project_test_support import local_project, pin_tool
+from tests.db_support import share_session
def test_pin_local_path_opens_original_and_deduplicates(local_project, db_session, tmp_path):
@@ -67,9 +68,9 @@ async def get_file_to_path(self, session, src, dst, **kwargs):
return len(data)
monkeypatch.setattr("core.sandbox.get_sandbox_provider", lambda: Provider())
- collector = ToolCollector()
+ collector = ToolAssembly()
register_sandbox_get_artifact(collector, chat_id="pin-chat", user_id="pin-user", scope=scope)
- export = collector.get_tool("sandbox_get_artifact")._func
+ export = next(t for t in collector if t.name == "sandbox_get_artifact")._func
result = json.loads(asyncio.run(export(src_path=str(source))).content[0].text)
assert result.get("ok"), result
assert get_artifact(result["file_id"])["path"] == str(source)
@@ -95,14 +96,12 @@ def test_pinned_file_remains_readable_across_turns_and_saves_to_project(
assert load_artifact_meta(fid, "pin-user")["name"] == "report.txt"
assert fetch_parsed_text(fid, "pin-user") == "original report"
monkeypatch.setenv("HUGAGENT_HOME", str(tmp_path / "home"))
- result = asyncio.run(
- overwrite_file(
+ result = overwrite_file(
fid,
UploadFile(filename="report.txt", file=io.BytesIO(b"saved from Canvas")),
SimpleNamespace(user_id="pin-user"),
db_session,
)
- )
assert result["file_id"] == fid
assert source.read_text() == "saved from Canvas"
assert fetch_parsed_text(fid, "pin-user") == "saved from Canvas"
@@ -175,10 +174,10 @@ def test_live_spreadsheet_sheet_read_and_artifact_input(local_project):
fid = json.loads(asyncio.run(pin_tool(scope)(file_paths=[str(sheet)])).content[0].text)[
"pinned"
][0]["file_id"]
- collector = ToolCollector()
+ collector = ToolAssembly()
register_read_artifact(collector, user_id="pin-user")
response = json.loads(
- asyncio.run(collector.get_tool("read_artifact")._func(file_id=fid, sheet_name="预算"))
+ asyncio.run(next(t for t in collector if t.name == "read_artifact")._func(file_id=fid, sheet_name="预算"))
.content[0]
.text
)
@@ -228,11 +227,11 @@ async def get_file_to_path(self, session, src, dst, **kwargs):
return Path(dst).stat().st_size
monkeypatch.setattr("core.sandbox.get_sandbox_provider", lambda: Provider())
- collector = ToolCollector()
+ collector = ToolAssembly()
register_sandbox_get_artifact(collector, chat_id="pin-chat", user_id="pin-user", scope=scope)
result = json.loads(
asyncio.run(
- collector.get_tool("sandbox_get_artifact")._func(
+ next(t for t in collector if t.name == "sandbox_get_artifact")._func(
src_path="/workspace/scratch/output.txt"
)
)
@@ -257,7 +256,7 @@ def test_write_delivers_original_project_file(local_project, monkeypatch):
monkeypatch.setattr(
"core.sandbox.get_sandbox_provider", lambda: SimpleNamespace(name="script_runner")
)
- collector = ToolCollector()
+ collector = ToolAssembly()
register_write(
collector, chat_id="pin-chat", user_id="pin-user", state=ReadStateTracker(), scope=scope
)
@@ -273,7 +272,7 @@ def test_write_delivers_original_project_file(local_project, monkeypatch):
try:
response = json.loads(
asyncio.run(
- collector.get_tool("Write")._func(
+ next(t for t in collector if t.name == "Write")._func(
file_path=str(target), content="created in project", register_as_artifact=True
)
)
@@ -310,10 +309,10 @@ def test_export_rejects_project_symlink_escape(local_project, tmp_path, monkeypa
str(outside) if value == path else original_resolve(value, user, **kwargs)
),
)
- collector = ToolCollector()
+ collector = ToolAssembly()
register_sandbox_get_artifact(collector, chat_id="pin-chat", user_id="pin-user", scope=scope)
result = json.loads(
- asyncio.run(collector.get_tool("sandbox_get_artifact")._func(src_path=path)).content[0].text
+ asyncio.run(next(t for t in collector if t.name == "sandbox_get_artifact")._func(src_path=path)).content[0].text
)
assert "error" in result and "file_id" not in result
@@ -344,6 +343,7 @@ def test_desktop_bridge_previews_pinned_html_through_http(local_project, db_sess
app = FastAPI()
app.include_router(files.router)
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
headers = {
"x-desktop-bridge": "fixture-only-bridge",
"x-desktop-bridge-user": base64.b64encode(
diff --git a/src/backend/tests/llm/test_local_session_reference.py b/src/backend/tests/llm/test_local_session_reference.py
index 7baa841b..adb42fab 100644
--- a/src/backend/tests/llm/test_local_session_reference.py
+++ b/src/backend/tests/llm/test_local_session_reference.py
@@ -61,17 +61,17 @@ def test_session_canvas_saves_original_and_rechecks_ownership(local_project, db_
source = session_file(tmp_path, monkeypatch)
result = json.loads(asyncio.run(pin_tool(None, "pin-chat")(file_paths=[str(source)])).content[0].text)
fid = result["pinned"][0]["file_id"]
- response = asyncio.run(overwrite_file(
+ response = overwrite_file(
fid, UploadFile(filename="report.txt", file=io.BytesIO(b"saved in original")),
SimpleNamespace(user_id="pin-user"), db_session,
- ))
+ )
assert response["file_id"] == fid
assert source.read_text() == "saved in original"
with pytest.raises(HTTPException):
- asyncio.run(overwrite_file(
+ overwrite_file(
fid, UploadFile(filename="report.txt", file=io.BytesIO(b"unauthorized")),
SimpleNamespace(user_id="other-user"), db_session,
- ))
+ )
@pytest.mark.parametrize("change", ["symlink", "revoked"])
@@ -101,19 +101,19 @@ def test_session_access_revalidates_source(local_project, db_session, tmp_path,
def test_write_and_pin_use_same_session_reference(local_project, tmp_path, monkeypatch):
from core.llm.tools.write_tool import register_write
from core.llm.tools._state import ReadStateTracker
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.sandbox.permissions.dispatch_models import CURRENT_PERMISSION_TICKET, PermissionTicket, PermissionIntent
source = session_file(tmp_path, monkeypatch)
source.unlink()
- collector = ToolCollector()
+ collector = ToolAssembly()
monkeypatch.setattr("core.sandbox.get_sandbox_provider", lambda: SimpleNamespace(name="script_runner"))
register_write(collector, chat_id="pin-chat", user_id="pin-user", state=ReadStateTracker())
token = CURRENT_PERMISSION_TICKET.set(PermissionTicket(
"Write", "write", "", "test", (PermissionIntent("local_path", "write", str(source), "create"),),
))
try:
- written = json.loads(asyncio.run(collector.get_tool("Write")._func(
+ written = json.loads(asyncio.run(next(t for t in collector if t.name == "Write")._func(
file_path=str(source), content="written original", register_as_artifact=True,
)).content[0].text)
finally:
diff --git a/src/backend/tests/llm/test_manifest_mcp_client.py b/src/backend/tests/llm/test_manifest_mcp_client.py
index 9b3bd9be..2f56c2c1 100644
--- a/src/backend/tests/llm/test_manifest_mcp_client.py
+++ b/src/backend/tests/llm/test_manifest_mcp_client.py
@@ -60,36 +60,31 @@ def test_protocol_accepts_only_a_complete_current_dynamic_manifest():
@pytest.mark.asyncio
async def test_manifest_client_lists_cloud_schemas_without_network():
- from core.llm.mcp.manager import ManifestMCPClient
- from core.llm.mcp.pool import make_client
+ from core.llm.tools.cloud_manifest import manifest_tools
class NetworkMustNotRun(httpx.AsyncBaseTransport):
async def handle_async_request(self, _request): # pragma: no cover
raise AssertionError("schema listing must not perform network I/O")
- client = make_client("search", _config(NetworkMustNotRun()), is_stateful=False)
- assert isinstance(client, ManifestMCPClient)
-
- tools = await client.list_tools()
+ tools = manifest_tools("search", _config(NetworkMustNotRun()))
assert [tool.name for tool in tools] == ["search"]
assert tools[0].input_schema["required"] == ["query"]
def test_incomplete_manifest_config_is_rejected_not_compatibility_routed():
- from core.llm.mcp.pool import make_client, uses_manifest_schema
+ from core.llm.tools.cloud_manifest import manifest_tools
config = _config()
config["manifest_tools"] = ["search"]
- assert uses_manifest_schema(config) is False
- with pytest.raises(ValueError, match="invalid cloud capability manifest"):
- make_client("search", config, is_stateful=False)
+ with pytest.raises(ValueError, match="Invalid manifest tool entry"):
+ manifest_tools("search", config)
@pytest.mark.asyncio
async def test_manifest_tool_calls_json_gateway_with_current_schema_hash():
- from core.llm.mcp.pool import make_client
+ from core.llm.tools.cloud_manifest import manifest_tools
captured: dict = {}
@@ -112,8 +107,7 @@ async def handler(request: httpx.Request) -> httpx.Response:
)
config = _config(httpx.MockTransport(handler))
- client = make_client("search", config, is_stateful=False)
- tool = (await client.list_tools())[0]
+ tool = manifest_tools("search", config)[0]
result = await tool(query="hello")
diff --git a/src/backend/tests/llm/test_mcp_factory_e2e.py b/src/backend/tests/llm/test_mcp_factory_e2e.py
new file mode 100644
index 00000000..36cec53c
--- /dev/null
+++ b/src/backend/tests/llm/test_mcp_factory_e2e.py
@@ -0,0 +1,88 @@
+"""Full production factory -> HTTP model -> SDK2 server -> final model reply."""
+import os
+import subprocess
+import sys
+
+
+def test_production_factory_calls_real_mcp_and_returns_final_reply(tmp_path):
+ script = r'''
+import asyncio, json, socket
+from contextlib import asynccontextmanager
+import uvicorn
+from fastapi import FastAPI, Request
+from fastapi.responses import StreamingResponse
+from agentscope.message import UserMsg
+from tests.llm.test_mcp_parallel_execution import probe_server
+from core.db.engine import Base, engine, SessionLocal
+from core.db.models import Project, UserShadow, ChatSession, AdminMcpServer
+from core.models.repository import create_provider, assign_role
+from core.harness.registry import load_models
+from features.installed import INSTALLED_FEATURES
+from features import register_product_tools, register_run_capabilities, register_model_credentials
+register_product_tools()
+register_run_capabilities()
+register_model_credentials()
+load_models(INSTALLED_FEATURES)
+Base.metadata.create_all(engine)
+from core.llm.factory import create_agent_executor
+from core.llm.mcp.manager import close_clients
+
+async def run():
+ model_app = FastAPI()
+ requests = []
+ @model_app.post("/v1/chat/completions")
+ async def complete(request: Request):
+ body = await request.json()
+ requests.append(body)
+ first = len(requests) == 1
+ delta = {"role": "assistant", "tool_calls": [{"index": 0, "id": "probe-call", "type": "function", "function": {"name": "probe", "arguments": '{"item":7}'}}]} if first else {"role": "assistant", "content": "verified completed-7"}
+ async def stream():
+ yield "data: " + json.dumps({"id":"fixture", "object":"chat.completion.chunk", "created":1, "model":"fixture", "choices":[{"index":0,"delta":delta,"finish_reason":None}]}) + "\n\n"
+ yield "data: " + json.dumps({"id":"fixture", "object":"chat.completion.chunk", "created":1, "model":"fixture", "choices":[{"index":0,"delta":{},"finish_reason":"tool_calls" if first else "stop"}], "usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}}) + "\n\n"
+ yield "data: [DONE]\n\n"
+ return StreamingResponse(stream(), media_type="text/event-stream")
+ sock = socket.socket()
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ server = uvicorn.Server(uvicorn.Config(model_app, log_level="error"))
+ task = asyncio.create_task(server.serve(sockets=[sock]))
+ try:
+ async with asyncio.timeout(10):
+ while not server.started:
+ await asyncio.sleep(.01)
+ async with probe_server() as (url, calls):
+ with SessionLocal() as db:
+ model = create_provider(db, display_name="fixture", provider_type="chat", base_url=f"http://127.0.0.1:{port}/v1", api_key="test-only", model_name="fixture", extra_config={"context_length":32768,"api_protocol":"chat_completions"})
+ assign_role(db, "main_agent", model.provider_id)
+ db.add(UserShadow(user_id="owner", username="Owner"))
+ db.add(Project(project_id="e2e", name="E2E", kind="personal", owner_user_id="owner", extra_data={"environment":{"mode":"custom","mcp_ids":["probe-server"]}}))
+ db.add(ChatSession(chat_id="e2e-chat", user_id="owner", project_id="e2e", title="E2E"))
+ db.add(AdminMcpServer(server_id="probe-server", display_name="Probe", owner_user_id="owner", is_enabled=True, transport="streamable_http", url=url))
+ db.commit()
+ from core.runs.journal import RunJournal
+ journal = RunJournal(SessionLocal)
+ journal.accept(run_id="e2e-run", message_id="e2e-msg", chat_id="e2e-chat", user_id="owner", request_payload={"kind":"chat"}, recovery_snapshot={"kind":"chat","worker_args":{}})
+ assert journal.claim("e2e-run", owner="e2e-worker", lease_seconds=300)
+ agent, resources = await create_agent_executor(current_user_id="owner", chat_id="e2e-chat", project_ctx={"project_id":"e2e"}, enabled_mcp_ids=["probe-server"], max_iters=3)
+ agent.state.run_id = "e2e-run"
+ agent.state.journal_owner = "e2e-worker"
+ try:
+ result = await agent.reply(UserMsg(name="user", content="Call probe with item 7 and report the result."))
+ assert list(calls) == [7], calls
+ assert len(requests) == 2, len(requests)
+ assert "completed-7" in json.dumps(requests[1]), requests[1]
+ assert "verified completed-7" in str(result), result
+ assert len(resources) == 1
+ finally:
+ await close_clients(resources)
+ assert all(r.session is None or r.session._owner.done() for r in resources)
+ finally:
+ server.should_exit = True
+ await asyncio.wait_for(task, 10)
+ sock.close()
+asyncio.run(run())
+engine.dispose()
+'''
+ env = dict(os.environ, DATABASE_URL=f"sqlite:///{tmp_path / 'e2e.db'}", REDIS_URL="", JX_CAPABILITIES_ENABLED="false", LOCAL_MODE="false", SANDBOX_TOOLS_ENABLED="false")
+ result = subprocess.run([sys.executable, "-c", script], env=env, capture_output=True, text=True, timeout=90)
+ assert result.returncode == 0, result.stdout[-8000:] + result.stderr[-8000:]
diff --git a/src/backend/tests/llm/test_mcp_human_wait_timeout.py b/src/backend/tests/llm/test_mcp_human_wait_timeout.py
new file mode 100644
index 00000000..fe917ed2
--- /dev/null
+++ b/src/backend/tests/llm/test_mcp_human_wait_timeout.py
@@ -0,0 +1,271 @@
+"""Human waits do not consume the active MCP invocation budget."""
+import asyncio
+import uuid
+import json
+import socket
+from contextlib import asynccontextmanager
+
+import uvicorn
+from starlette.applications import Starlette
+from starlette.responses import Response, StreamingResponse
+from starlette.routing import Route
+
+import pytest
+from mcp import types
+from mcp.server.mcpserver import Context, MCPServer
+
+from core.llm.mcp.lifetime import discover
+from core.llm.tools import user_questions as uq
+from tests.llm.test_mcp_parallel_execution import probe_server
+from tests.llm.test_mcp_runtime_pool import _run
+
+
+def question_server():
+ server = MCPServer("human-budget")
+
+ @server.tool()
+ async def choose(ctx: Context, before: float = 0, after: float = 0, questions: int = 1) -> str | types.InputRequiredResult:
+ if ctx.input_responses:
+ await asyncio.sleep(after)
+ answer = ctx.input_responses["choice"]
+ return answer.action + ":" + str((answer.content or {}).get("color", ""))
+ await asyncio.sleep(before)
+ return types.InputRequiredResult(input_requests={key: types.ElicitRequest(
+ params=types.ElicitRequestFormParams(message="Choose a color", requested_schema={
+ "type": "object", "properties": {"color": {"type": "string"}}, "required": ["color"],
+ }),
+ ) for key in ("choice", "second")[:questions]})
+ return server
+
+
+async def pending_question(chat):
+ async with asyncio.timeout(5):
+ while True:
+ pending = await uq.get_all_pending_shared(chat)
+ if pending:
+ return pending[0]
+ await asyncio.sleep(.01)
+
+
+@pytest.mark.asyncio
+async def test_answer_after_execution_budget_still_resumes_real_mcp_call():
+ chat = "mcp-wait-" + uuid.uuid4().hex
+ async with probe_server(question_server()) as (url, _):
+ resource = await discover("human-budget", {
+ "transport": "streamable_http", "url": url, "execution_timeout": .5, "transport_timeout": .15,
+ }, interactions=_run("unused", chat=chat))
+ task = asyncio.create_task(resource.tools[0](after=.25))
+ try:
+ question = await pending_question(chat)
+ await asyncio.sleep(.7)
+ assert not task.done(), "Human wait consumed the MCP execution budget"
+ reply = await uq.answer_shared(chat, question["request_id"], [{"id": "color", "custom": "blue"}])
+ assert reply["ok"]
+ result = await asyncio.wait_for(task, 3)
+ assert "accept:blue" in str(result.content)
+ assert not await uq.get_all_pending_shared(chat)
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_answer_resumes_remaining_budget_instead_of_resetting_it():
+ chat = "mcp-remaining-" + uuid.uuid4().hex
+ async with probe_server(question_server()) as (url, _):
+ resource = await discover("human-budget", {
+ "transport": "streamable_http", "url": url, "execution_timeout": .8,
+ }, interactions=_run("unused", chat=chat))
+ # Each server phase fits the budget alone; their sum must still time out.
+ task = asyncio.create_task(resource.tools[0](before=.5, after=.5))
+ try:
+ question = await pending_question(chat)
+ await asyncio.sleep(1)
+ assert not task.done()
+ assert (await uq.answer_shared(chat, question["request_id"], [
+ {"id": "color", "custom": "blue"},
+ ]))["ok"]
+ result = await asyncio.wait_for(task, 2)
+ assert result.metadata.get("outcome_unknown") is True
+ assert "TimeoutError" in str(result.content)
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ await resource.close()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cancel_call", [False, True])
+async def test_cancellation_during_human_wait_cleans_pending_question(cancel_call):
+ chat = "mcp-cancel-" + uuid.uuid4().hex
+ async with probe_server(question_server()) as (url, _):
+ resource = await discover("human-budget", {
+ "transport": "streamable_http", "url": url, "execution_timeout": .5,
+ }, interactions=_run("unused", chat=chat))
+ task = asyncio.create_task(resource.tools[0]())
+ try:
+ question = await pending_question(chat)
+ await asyncio.sleep(.7)
+ assert not task.done()
+ if cancel_call:
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(task, 2)
+ else:
+ assert (await uq.cancel_shared(chat, question["request_id"]))["ok"]
+ assert "cancel:" in str((await asyncio.wait_for(task, 2)).content)
+ assert not await uq.get_all_pending_shared(chat)
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ await resource.close()
+
+
+@asynccontextmanager
+async def legacy_server():
+ # A real pre-2026 protocol peer: negotiate initialize and issue a callback
+ # while tools/call remains outstanding (unlike modern InputRequiredResult).
+ messages = asyncio.Queue()
+ tool_id = None
+ invocation_method = None
+
+ async def stream(request):
+ async def events():
+ yield "event: endpoint\ndata: /messages/\n\n"
+ while True:
+ yield "event: message\ndata: " + json.dumps(await messages.get()) + "\n\n"
+ return StreamingResponse(events(), media_type="text/event-stream")
+
+ async def post(request):
+ nonlocal tool_id, invocation_method
+ body = await request.json()
+ method = body.get("method")
+ ident = body.get("id")
+ result = {}
+ if method == "server/discover":
+ await messages.put({"jsonrpc": "2.0", "id": ident,
+ "error": {"code": -32601, "message": "Method not found"}})
+ return Response(status_code=202)
+ if method == "initialize":
+ result = {"protocolVersion": "2025-11-25", "capabilities": {"tools": {}, "resources": {}, "prompts": {}},
+ "serverInfo": {"name": "legacy-human-budget", "version": "1"}}
+ elif method == "tools/list":
+ result = {"tools": [{"name": "choose", "inputSchema": {"type": "object", "properties": {}}}]}
+ elif method == "resources/list":
+ result = {"resources": [{"uri": "test://color", "name": "color"}]}
+ elif method == "resources/templates/list":
+ result = {"resourceTemplates": []}
+ elif method == "prompts/list":
+ result = {"prompts": [{"name": "color"}]}
+ elif method in {"tools/call", "resources/read", "prompts/get"}:
+ tool_id = ident
+ invocation_method = method
+ await messages.put({"jsonrpc": "2.0", "id": "question", "method": "elicitation/create",
+ "params": {"mode": "form", "message": "Choose a color",
+ "requestedSchema": {"type": "object",
+ "properties": {"color": {"type": "string"}}, "required": ["color"]}}})
+ return Response(status_code=202)
+ elif method is None and ident == "question":
+ answer = body["result"]
+ ident = tool_id
+ text = answer["action"] + ":" + answer.get("content", {}).get("color", "")
+ if invocation_method == "resources/read":
+ result = {"contents": [{"uri": "test://color", "text": text}]}
+ elif invocation_method == "prompts/get":
+ result = {"messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
+ else:
+ result = {"content": [{"type": "text", "text": text}]}
+ if ident is not None:
+ await messages.put({"jsonrpc": "2.0", "id": ident, "result": result})
+ return Response(status_code=202)
+
+ app = Starlette(routes=[Route("/sse", stream), Route("/messages/", post, methods=["POST"])])
+ sock = socket.socket()
+ sock.bind(("127.0.0.1", 0))
+ http = uvicorn.Server(uvicorn.Config(app, log_level="error"))
+ task = asyncio.create_task(http.serve(sockets=[sock]))
+ try:
+ async with asyncio.timeout(5):
+ while not http.started:
+ if task.done():
+ await task
+ await asyncio.sleep(.01)
+ yield f"http://127.0.0.1:{sock.getsockname()[1]}/sse"
+ finally:
+ http.should_exit = True
+ await asyncio.wait_for(task, 5)
+ sock.close()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["tool", "resource", "prompt"])
+async def test_legacy_sse_request_clock_also_excludes_human_wait(kind):
+ chat = "mcp-legacy-" + uuid.uuid4().hex
+ async with legacy_server() as url:
+ resource = await discover("legacy-human-budget", {
+ "transport": "sse", "url": url, "execution_timeout": .5,
+ }, interactions=_run("unused", chat=chat))
+ tool = next(tool for tool in resource.tools if getattr(tool, "kind", "tool") == kind)
+ task = asyncio.create_task(tool())
+ try:
+ question = await pending_question(chat)
+ await asyncio.sleep(.7)
+ assert not task.done(), "Legacy SDK request timeout interrupted the human wait"
+ assert (await uq.answer_shared(chat, question["request_id"], [
+ {"id": "color", "custom": "blue"},
+ ]))["ok"]
+ assert "accept:blue" in str((await asyncio.wait_for(task, 3)).content)
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_overlapping_questions_keep_budget_paused_until_last_answer():
+ chat = "mcp-overlap-" + uuid.uuid4().hex
+ async with probe_server(question_server()) as (url, _):
+ resource = await discover("human-budget", {
+ "transport": "streamable_http", "url": url, "execution_timeout": .5,
+ }, interactions=_run("unused", chat=chat))
+ task = asyncio.create_task(resource.tools[0](questions=2))
+ try:
+ async with asyncio.timeout(5):
+ while len(pending := await uq.get_all_pending_shared(chat)) < 2:
+ await asyncio.sleep(.01)
+ for question in pending:
+ await asyncio.sleep(.7)
+ assert not task.done()
+ assert (await uq.answer_shared(chat, question["request_id"], [
+ {"id": "color", "custom": "blue"},
+ ]))["ok"]
+ assert "accept:blue" in str((await asyncio.wait_for(task, 3)).content)
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_waiting_call_does_not_pause_another_invocations_budget():
+ chat = "mcp-isolated-" + uuid.uuid4().hex
+ async with probe_server(question_server()) as (url, _):
+ cfg = {"transport": "streamable_http", "url": url, "execution_timeout": .5}
+ waiting = await discover("human-budget", cfg, interactions=_run("unused", chat=chat))
+ working = await discover("human-budget", cfg, interactions=_run("unused", chat=chat + "-other"))
+ task = asyncio.create_task(waiting.tools[0]())
+ try:
+ question = await pending_question(chat)
+ result = await asyncio.wait_for(working.tools[0](before=1), 2)
+ assert result.metadata.get("outcome_unknown") is True
+ assert not task.done()
+ assert (await uq.answer_shared(chat, question["request_id"], [
+ {"id": "color", "custom": "blue"},
+ ]))["ok"]
+ assert "accept:blue" in str((await asyncio.wait_for(task, 3)).content)
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ await waiting.close()
+ await working.close()
diff --git a/src/backend/tests/llm/test_mcp_native_adversarial.py b/src/backend/tests/llm/test_mcp_native_adversarial.py
new file mode 100644
index 00000000..f4963bb2
--- /dev/null
+++ b/src/backend/tests/llm/test_mcp_native_adversarial.py
@@ -0,0 +1,219 @@
+"""Adversarial lifecycle, pagination, authority and schema tests."""
+import asyncio
+import sys
+from types import SimpleNamespace
+import pytest
+from mcp import types
+from mcp.server.lowlevel import Server
+from mcp.server.mcpserver import MCPServer
+from mcp.shared.exceptions import MCPError
+from core.llm.mcp.lifetime import discover
+from core.llm.mcp import chat_sessions
+from tests.llm.test_mcp_parallel_execution import probe_server
+from tests.llm.test_mcp_native_capabilities import call_scope
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("repeat_cursor", [False, True])
+async def test_paginated_discovery_and_repeated_cursor_rejection(repeat_cursor):
+ calls = []
+ async def listing(ctx, params):
+ cursor = params.cursor if params else None
+ calls.append(cursor)
+ return types.ListToolsResult(tools=[types.Tool(name="one" if cursor is None else "two", input_schema={"type":"object"})],
+ next_cursor="next" if cursor is None or repeat_cursor else None)
+ low = Server("paged", on_list_tools=listing)
+ server = MCPServer("paged")
+ server._lowlevel_server = low
+ async with probe_server(server) as (url, _):
+ if repeat_cursor:
+ with pytest.raises(BaseExceptionGroup) as failure:
+ await discover("paged", {"transport":"streamable_http", "url":url})
+ assert failure.value.subgroup(lambda e: isinstance(e, ValueError) and "pagination cursor repeated" in str(e)) is not None
+ else:
+ resource = await discover("paged", {"transport":"streamable_http", "url":url})
+ try:
+ assert [t.name for t in resource.tools] == ["one", "two"]
+ assert calls == [None, "next"]
+ finally:
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_resource_only_server_never_receives_tools_list():
+ async def resources(ctx, params):
+ return types.ListResourcesResult(resources=[types.Resource(name="only",uri="doc://only")])
+ async def templates(ctx, params):
+ return types.ListResourceTemplatesResult(resource_templates=[])
+ async def read(ctx, params):
+ return types.ReadResourceResult(contents=[types.TextResourceContents(uri=params.uri, text="only-resource")])
+ server = MCPServer("resource-only")
+ server._lowlevel_server = Server("resource-only", on_list_resources=resources,
+ on_list_resource_templates=templates, on_read_resource=read)
+ async with probe_server(server) as (url, _):
+ resource = await discover("only", {"transport":"streamable_http","url":url})
+ try:
+ assert len(resource.tools) == 1
+ assert "only-resource" in str(await resource.tools[0]())
+ finally:
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_schema_change_during_header_recovery_never_replays_call():
+ calls = []
+ async def listing(ctx, params):
+ return types.ListToolsResult(tools=[types.Tool(name="write", input_schema={"type":"object",
+ "properties": {"new": {"type":"string"}} if calls else {}})])
+ async def invoke(ctx, params):
+ calls.append(params.name)
+ raise MCPError(code=-32020, message="schema changed")
+ server = MCPServer("changes")
+ server._lowlevel_server = Server("changes", on_list_tools=listing, on_call_tool=invoke)
+ async with probe_server(server) as (url, _):
+ resource = await discover("changes", {"transport":"streamable_http","url":url})
+ try:
+ with pytest.raises(Exception):
+ await resource.tools[0]()
+ assert calls == ["write"]
+ finally:
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_chat_deleted_while_first_process_is_opening(tmp_path):
+ script = tmp_path / "slow.py"
+ script.write_text('import time\ntime.sleep(300)\n')
+ cfg = {"transport":"stdio","command":sys.executable,"args":[str(script)]}
+ opening = asyncio.create_task(discover("slow",cfg,scope=("user","deleted","")))
+ await asyncio.sleep(.15)
+ await chat_sessions.close_chat("deleted")
+ with pytest.raises((asyncio.CancelledError, RuntimeError)):
+ await asyncio.wait_for(opening, 5)
+ assert not any(k[1] == "deleted" for k in chat_sessions._entries)
+ assert not any(t.get_name() == "mcp-session:slow" and not t.done() for t in asyncio.all_tasks())
+
+
+@pytest.mark.asyncio
+async def test_sampling_budget_is_reserved_before_slow_authorization():
+ call = call_scope()
+ ready = asyncio.Event()
+ async def validate():
+ await ready.wait()
+ call.validate = validate
+ params = types.CreateMessageRequestParams(messages=[], max_tokens=10)
+ requests = [asyncio.create_task(call.sampling(None, params)) for _ in range(12)]
+ await asyncio.sleep(0)
+ assert call.run.samples == 4
+ ready.set()
+ results = await asyncio.gather(*requests)
+ assert sum(isinstance(r,types.CreateMessageResult) for r in results) == 4
+
+
+@pytest.mark.asyncio
+async def test_connector_revoked_while_user_is_answering(monkeypatch):
+ call = call_scope()
+ allowed = True
+ call.run.agent._jx_prepared_capabilities = SimpleNamespace(validate=lambda **_:None,
+ validate_tool_call=lambda _:allowed)
+ async def answer(**kwargs):
+ nonlocal allowed
+ allowed = False
+ return {"status":"answered","answers":[{"id":"name","custom":"Ada"}]}
+ monkeypatch.setattr("core.llm.tools.user_questions.ask",answer)
+ params = types.ElicitRequestFormParams(message="Name",requested_schema={"type":"object",
+ "properties":{"name":{"type":"string"}},"required":["name"]})
+ with pytest.raises(PermissionError):
+ await call.elicitation(None,params)
+
+
+@pytest.mark.asyncio
+async def test_outer_cancellation_cancels_pending_model_work():
+ call = call_scope()
+ stopped = asyncio.Event()
+ class SlowModel:
+ model = "slow"
+ async def __call__(self,*args,**kwargs):
+ try:
+ await asyncio.sleep(300)
+ finally:
+ stopped.set()
+ call.run.agent.model = SlowModel()
+ params = types.CreateMessageRequestParams(messages=[],max_tokens=10)
+ task = asyncio.create_task(call.dispatch("sampling",None,params))
+ await asyncio.sleep(.01)
+ await call.finish()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert stopped.is_set() and not call.pending
+
+
+@pytest.mark.asyncio
+async def test_deleted_chat_revokes_every_run_sharing_its_session(tmp_path):
+ script = tmp_path / "shared.py"
+ script.write_text('from mcp.server.mcpserver import MCPServer\ns=MCPServer("shared")\n@s.tool()\ndef echo(text: str)->str: return text\ns.run()\n')
+ cfg = {"transport":"stdio", "command":sys.executable, "args":[str(script)]}
+ first = await discover("shared", cfg, scope=("user", "deleted-shared", ""))
+ second = await asyncio.wait_for(discover("shared", cfg, scope=("user", "deleted-shared", "")), 5)
+ assert first.session is second.session
+ await chat_sessions.close_chat("deleted-shared")
+ for run in (first, second):
+ with pytest.raises(RuntimeError, match="lease is closed"):
+ await run.tools[0](text="after delete")
+ assert first.session._owner.done()
+ await first.close()
+ await second.close()
+ assert not any(k[1] == "deleted-shared" for k in chat_sessions._entries)
+
+
+@pytest.mark.asyncio
+async def test_discovery_repeated_cancel_waits_for_owner_exit(monkeypatch):
+ from core.llm.mcp import session as session_module
+ entered, exiting, finish = asyncio.Event(), asyncio.Event(), asyncio.Event()
+ class Client:
+ async def __aenter__(self):
+ return self
+ async def __aexit__(self, *args):
+ exiting.set()
+ await finish.wait()
+ @property
+ def server_capabilities(self):
+ return SimpleNamespace(tools=True)
+ async def list_tools(self, **kwargs):
+ entered.set()
+ await asyncio.sleep(300)
+ monkeypatch.setattr(session_module, "make_client", lambda *a, **kw:Client())
+ opening = asyncio.create_task(discover("repeat-cancel", {"transport":"streamable_http"}))
+ await entered.wait()
+ opening.cancel()
+ await exiting.wait()
+ opening.cancel()
+ await asyncio.sleep(.01)
+ assert not opening.done()
+ finish.set()
+ with pytest.raises(asyncio.CancelledError):
+ await opening
+ assert not any(t.get_name() == "mcp-session:repeat-cancel" and not t.done() for t in asyncio.all_tasks())
+
+
+def test_retired_column_migration_preserves_existing_servers():
+ import importlib.util
+ from pathlib import Path
+ import sqlalchemy as sa
+ from alembic.migration import MigrationContext
+ from alembic.operations import Operations
+ path = Path(__file__).parents[2] / "alembic/versions/mcpnative01_drop_stable.py"
+ spec = importlib.util.spec_from_file_location("mcp_migration",path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ engine = sa.create_engine("sqlite://")
+ with engine.begin() as connection:
+ connection.exec_driver_sql("CREATE TABLE admin_mcp_servers (server_id TEXT PRIMARY KEY, is_stable BOOLEAN NOT NULL)")
+ connection.exec_driver_sql("INSERT INTO admin_mcp_servers VALUES ('keep', 0)")
+ with Operations.context(MigrationContext.configure(connection)):
+ module.upgrade()
+ assert [c["name"] for c in sa.inspect(connection).get_columns("admin_mcp_servers")] == ["server_id"]
+ assert connection.exec_driver_sql("SELECT server_id FROM admin_mcp_servers").scalar() == "keep"
+ module.downgrade()
+ assert connection.exec_driver_sql("SELECT is_stable FROM admin_mcp_servers").scalar() == 1
+ module.upgrade()
diff --git a/src/backend/tests/llm/test_mcp_native_capabilities.py b/src/backend/tests/llm/test_mcp_native_capabilities.py
new file mode 100644
index 00000000..090c7f4b
--- /dev/null
+++ b/src/backend/tests/llm/test_mcp_native_capabilities.py
@@ -0,0 +1,169 @@
+"""Native modern protocol and business boundaries over real HTTP and STDIO."""
+import asyncio
+import os
+import sys
+from types import SimpleNamespace
+import pytest
+from agentscope.message import TextBlock
+from agentscope.model import ChatResponse
+from mcp import types
+from mcp.server.mcpserver import MCPServer, Context
+from core.llm.mcp.lifetime import discover
+from core.llm.mcp.callbacks import Interactions
+from core.llm.mcp import chat_sessions
+from core.llm.mcp.chat_sessions import close_chat
+from tests.llm.test_mcp_parallel_execution import probe_server
+
+
+def interactions():
+ class Model:
+ model = "fixture"
+ async def __call__(self, messages, tools=None, **kwargs):
+ assert tools is None
+ assert kwargs["max_tokens"] == 10
+ assert "private-history" not in str(messages)
+ return ChatResponse(content=[TextBlock(text="Paris")], is_last=True)
+ request = SimpleNamespace(chat_id="mcp-chat", run_id="", current_user_id="mcp-user")
+ binding = Interactions(request, "probe", True)
+ binding.agent = SimpleNamespace(model=Model(), state=SimpleNamespace(pending_mcp_progress=[]))
+ return binding
+
+
+def call_scope():
+ """One active outer call of a fresh run binding."""
+ return interactions().open_call()
+
+
+@pytest.mark.asyncio
+async def test_modern_sampling_elicitation_progress_resources_and_prompts(monkeypatch):
+ server = MCPServer("modern")
+ @server.tool()
+ async def sample(ctx: Context) -> str | types.InputRequiredResult:
+ if ctx.input_responses:
+ return ctx.input_responses["q"].content.text
+ return types.InputRequiredResult(input_requests={"q": types.CreateMessageRequest(
+ params=types.CreateMessageRequestParams(messages=[types.SamplingMessage(
+ role="user", content=types.TextContent(text="Capital of France?"))], max_tokens=10))})
+ @server.tool()
+ async def form(ctx: Context) -> str | types.InputRequiredResult:
+ if ctx.input_responses:
+ answer = ctx.input_responses["q"]
+ assert answer.content == {"count": 3, "enabled": True}
+ return "accepted"
+ return types.InputRequiredResult(input_requests={"q": types.ElicitRequest(params=types.ElicitRequestFormParams(
+ message="Choose", requested_schema={"type": "object", "properties": {
+ "count": {"type": "integer", "minimum": 1}, "enabled": {"type": "boolean"}},
+ "required": ["count", "enabled"]}))})
+ @server.tool()
+ async def progress(ctx: Context) -> str:
+ await ctx.report_progress(1, 2, "halfway")
+ return "done"
+ @server.tool()
+ async def linked() -> types.CallToolResult:
+ return types.CallToolResult(content=[types.TextContent(text="description"),
+ types.ResourceLink(name="report", uri="report://one", mime_type="text/plain")],
+ structured_content={"answer": 42})
+ @server.resource("report://one")
+ def report() -> str:
+ return "resource body"
+ @server.resource("report://by-name/{name}")
+ def report_template(name: str) -> str:
+ return "template " + name
+ @server.prompt()
+ def summarize(subject: str) -> str:
+ return "untrusted prompt " + subject
+ from core.llm.tools import user_questions as uq
+ async def answer_form():
+ for _ in range(500):
+ queue = uq.get_ui_queue("mcp-chat")
+ if queue and not queue.empty():
+ event = queue.get_nowait()
+ if event["event"] == "requested":
+ assert uq.answer("mcp-chat", event["request_id"], [
+ {"id":"count", "custom":"3"}, {"id":"enabled", "selected":["option_1"]}
+ ])["ok"]
+ return
+ await asyncio.sleep(.01)
+ raise AssertionError("MCP elicitation did not reach the user-question queue")
+ binding = interactions()
+ async with probe_server(server, json_response=False) as (url, _):
+ resource = await discover("modern", {"transport": "streamable_http", "url": url}, interactions=binding)
+ try:
+ assert resource.session.client.protocol_version == "2026-07-28"
+ tools = {t.name: t for t in resource.tools}
+ assert "Paris" in str(await tools["sample"]())
+ answer_task = asyncio.create_task(answer_form())
+ try:
+ assert "accepted" in str(await tools["form"]())
+ await answer_task
+ finally:
+ answer_task.cancel()
+ await asyncio.gather(answer_task, return_exceptions=True)
+ await tools["progress"]()
+ assert binding.agent.state.pending_mcp_progress[0]["progress"] == 1
+ result = await tools["linked"]()
+ assert result.metadata["mcp"]["structuredContent"] == {"answer": 42}
+ assert "report://one" in str(result.content)
+ resource_tool = next(t for t in resource.tools if t.name.startswith("mcp_resource_"))
+ assert "resource body" in str(await resource_tool())
+ template_tool = next(t for t in resource.tools if t.name.startswith("mcp_resource_template_"))
+ assert "template one" in str(await template_tool(name="one"))
+ prompt_tool = next(t for t in resource.tools if t.name.startswith("mcp_prompt_"))
+ assert "untrusted prompt topic" in str(await prompt_tool(subject="topic"))
+ finally:
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_stdio_chat_reuse_isolated_users_stale_lease_and_cancelled_opener(tmp_path):
+ script = tmp_path / "pid.py"
+ script.write_text('import os\nfrom mcp.server.mcpserver import MCPServer\ns=MCPServer("pid")\n@s.tool()\ndef pid()->int:return os.getpid()\ns.run()\n')
+ cfg = {"transport": "stdio", "command": sys.executable, "args": [str(script)]}
+ scope = ("user-one", "chat", "")
+ first = await discover("pid", cfg, scope=scope)
+ old_tool = first.tools[0]
+ pid = (await old_tool()).metadata["mcp"]["structuredContent"]
+ await first.close()
+ second = await discover("pid", cfg, scope=scope)
+ try:
+ assert (await second.tools[0]()).metadata["mcp"]["structuredContent"] == pid
+ with pytest.raises(RuntimeError, match="lease is closed"):
+ await old_tool()
+ # A concurrent run in the same chat shares the live process instead of waiting.
+ concurrent = await asyncio.wait_for(discover("pid", cfg, scope=scope), 5)
+ assert (await concurrent.tools[0]()).metadata["mcp"]["structuredContent"] == pid
+ await concurrent.close()
+ assert (await second.tools[0]()).metadata["mcp"]["structuredContent"] == pid
+ # A run cancelled while its process opens leaves a parked session, not a leak.
+ opener = asyncio.create_task(discover("pid", cfg, scope=("user-one", "other-chat", "")))
+ await asyncio.sleep(0.02)
+ opener.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await opener
+ reused = await discover("pid", cfg, scope=("user-one", "other-chat", ""))
+ try:
+ assert sum(k[1] == "other-chat" for k in chat_sessions._entries) == 1
+ finally:
+ await reused.close()
+ third = await discover("pid", cfg, scope=("user-two", "chat", ""))
+ try:
+ assert (await third.tools[0]()).metadata["mcp"]["structuredContent"] != pid
+ finally:
+ await third.close()
+ finally:
+ await second.close()
+ await close_chat()
+ assert second.session._owner.done()
+
+
+@pytest.mark.asyncio
+async def test_sampling_denies_excessive_or_shared_context():
+ call = call_scope()
+ params = types.CreateMessageRequestParams(messages=[], max_tokens=5000)
+ assert isinstance(await call.sampling(None, params), types.ErrorData)
+ params.max_tokens = 10
+ params.include_context = "allServers"
+ assert isinstance(await call.sampling(None, params), types.ErrorData)
+ call.run.interactive = False
+ form = types.ElicitRequestFormParams(message="test", requested_schema={"type":"object"})
+ assert (await call.elicitation(None, form)).action == "decline"
diff --git a/src/backend/tests/llm/test_mcp_parallel_execution.py b/src/backend/tests/llm/test_mcp_parallel_execution.py
index 94f87242..4f82c6db 100644
--- a/src/backend/tests/llm/test_mcp_parallel_execution.py
+++ b/src/backend/tests/llm/test_mcp_parallel_execution.py
@@ -8,18 +8,19 @@
import pytest
import uvicorn
from agentscope.agent import Agent
-from agentscope.mcp import HttpMCPConfig
from agentscope.message import TextBlock, ToolCallBlock, UserMsg
from agentscope.model import ChatResponse
from agentscope.tool import Toolkit
-from mcp.server.fastmcp import FastMCP
+from mcp.server.mcpserver import MCPServer
-from core.llm.mcp.manager import BareNameMCPClient
+from core.llm.mcp.lifetime import discover
class ThreeCallsModel:
model = "mcp-concurrency-probe"
context_size = 32768
+ from agentscope.formatter import OpenAIChatFormatter
+ formatter = OpenAIChatFormatter()
def __init__(self):
self.calls = 0
@@ -41,21 +42,23 @@ async def __call__(self, messages, tools=None, **kwargs):
@asynccontextmanager
-async def probe_server():
+async def probe_server(mcp_server=None, *, json_response=True):
intervals = {}
- server = FastMCP("parallel-probe", stateless_http=True, json_response=True)
+ server = mcp_server or MCPServer("parallel-probe")
- @server.tool(annotations={"readOnlyHint": True})
async def probe(item: int) -> str:
started = time.monotonic()
await asyncio.sleep(0.4)
intervals[item] = (started, time.monotonic())
return f"completed-{item}"
+ if mcp_server is None:
+ server.tool(annotations={"readOnlyHint": True})(probe)
+
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
- http = uvicorn.Server(uvicorn.Config(server.streamable_http_app(), log_level="error"))
+ http = uvicorn.Server(uvicorn.Config(server.streamable_http_app(stateless_http=True, json_response=json_response), log_level="error"))
task = asyncio.create_task(http.serve(sockets=[sock]))
try:
async with asyncio.timeout(10):
@@ -71,23 +74,18 @@ async def probe(item: int) -> str:
@pytest.mark.asyncio
-@pytest.mark.parametrize("stateful", [False, True])
-async def test_agent_overlaps_real_mcp_requests(stateful):
+async def test_agent_overlaps_real_mcp_requests():
async with probe_server() as (url, intervals):
- client = BareNameMCPClient(
- name="parallel-probe",
- is_stateful=stateful,
- mcp_config=HttpMCPConfig(url=url),
- )
+ client = await discover("parallel-probe", {"transport": "streamable_http", "url": url})
try:
- await client.connect()
- # Enumeration and direct lookup must preserve the same policy.
- await client.list_tools()
+ tools = client.tools
+ # The probe is annotated read-only, so the agent may overlap its calls.
+ assert all(tool.is_concurrency_safe for tool in tools)
agent = Agent(
name="probe-agent",
system_prompt="Run the probes.",
model=ThreeCallsModel(),
- toolkit=Toolkit(mcps=[client]),
+ toolkit=Toolkit(tools=tools),
)
started = time.monotonic()
result = await agent.reply(
@@ -98,7 +96,7 @@ async def test_agent_overlaps_real_mcp_requests(stateful):
overlap = min(end for _, end in intervals.values()) - max(
start for start, _ in intervals.values()
)
- print(f"stateful={stateful} calls=3 elapsed={elapsed:.3f}s overlap={overlap:.3f}s")
+ print(f"calls=3 elapsed={elapsed:.3f}s overlap={overlap:.3f}s")
assert overlap > 0.2, "MCP requests did not execute concurrently"
assert "done" in str(result)
finally:
diff --git a/src/backend/tests/llm/test_mcp_pool_cache.py b/src/backend/tests/llm/test_mcp_pool_cache.py
deleted file mode 100644
index 8de3b314..00000000
--- a/src/backend/tests/llm/test_mcp_pool_cache.py
+++ /dev/null
@@ -1,203 +0,0 @@
-"""Tests for MCPConnectionPool (AgentScope 2.0 rewrite).
-
-Migration note: the 1.x "tool-function cache" (_cached_tool_funcs / get_callable_function /
-build_toolkit_from_cache returning a Toolkit) no longer exists in 2.0 -- MCPClient has no
-get_callable_function, and the Toolkit is constructed once. This pool is now responsible only for **connection reuse**:
- • only pools stdio stable servers (HTTP is not pooled even when is_stable, it goes per-request stateless)
- • get_request_clients() returns a list of connected MCPClients (including stable + transient)
- • shutdown / reconnect / reinitialize lifecycle
-
-This file is rewritten accordingly, testing 2.0's actual pool behavior.
-"""
-
-from __future__ import annotations
-
-from typing import Dict, List, Optional
-from unittest.mock import patch
-
-import pytest
-
-
-# ── Fake MCPClient ─────────────────────────────────────────────────────────
-
-class FakeTool:
- def __init__(self, name: str):
- self.name = name
- self.description = ""
-
-
-class FakeMCPClient:
- """Mimics agentscope 2.0 MCPClient (connect/list_tools/close)."""
-
- def __init__(self, name: str, tools: Optional[List[str]] = None):
- self.name = name
- self._tools = [FakeTool(t) for t in (tools or ["tool_a", "tool_b"])]
- self.connect_count = 0
- self.close_count = 0
- self.list_tools_count = 0
-
- async def connect(self):
- self.connect_count += 1
-
- async def list_tools(self):
- self.list_tools_count += 1
- return list(self._tools)
-
- async def close(self):
- self.close_count += 1
-
-
-def _make_pool():
- from core.llm.mcp.pool import MCPConnectionPool
- pool = MCPConnectionPool.__new__(MCPConnectionPool)
- pool.__init__()
- return pool
-
-
-def _stdio_configs(names: List[str], stable: bool = True) -> Dict[str, dict]:
- return {
- name: {"command": "python", "args": ["-m", f"extensions.mcp_servers.{name}"], "is_stable": stable}
- for name in names
- }
-
-
-def _http_config(name: str, stable: bool = True) -> Dict[str, dict]:
- return {name: {"transport": "streamable_http", "url": f"http://{name}/mcp", "is_stable": stable}}
-
-
-@pytest.fixture
-def pool():
- return _make_pool()
-
-
-def _patch_make_client(registry: dict):
- def fake_make_client(name, cfg, *, is_stateful=True):
- c = FakeMCPClient(name=name)
- registry[name] = c
- return c
- return patch('core.llm.mcp.pool.make_client', side_effect=fake_make_client)
-
-
-class TestInitialize:
- @pytest.mark.asyncio
- async def test_initialize_connects_stdio_stable(self, pool):
- clients: dict = {}
- with _patch_make_client(clients):
- await pool.initialize(_stdio_configs(["server_a", "server_b"]))
- assert pool.is_initialized
- assert pool.stable_client_count == 2
- assert pool.has_cached_tools # 2.0 semantics: stable connections already exist
- assert clients["server_a"].connect_count == 1
- assert clients["server_b"].connect_count == 1
-
- @pytest.mark.asyncio
- async def test_http_not_pooled(self, pool):
- """An HTTP server does not enter the stable pool even when is_stable (2.0: goes per-request stateless)."""
- clients: dict = {}
- with _patch_make_client(clients):
- await pool.initialize(_http_config("kb_http"))
- assert pool.stable_client_count == 0
- assert "kb_http" not in pool.stable_server_ids
-
- @pytest.mark.asyncio
- async def test_connect_failure_does_not_block_init(self, pool):
- def fake_make_client(name, cfg, *, is_stateful=True):
- c = FakeMCPClient(name=name)
- if name == "bad":
- async def fail():
- raise RuntimeError("connect failed")
- c.connect = fail
- return c
- with patch('core.llm.mcp.pool.make_client', side_effect=fake_make_client):
- await pool.initialize(_stdio_configs(["good", "bad"]))
- assert pool.is_initialized
- assert "good" in pool._stable_clients
- assert "bad" not in pool._stable_clients
-
-
-class TestGetRequestClients:
- @pytest.mark.asyncio
- async def test_returns_stable_clients(self, pool):
- clients: dict = {}
- with _patch_make_client(clients):
- await pool.initialize(_stdio_configs(["srv_a", "srv_b"]))
- req, transient = await pool.get_request_clients(enabled_keys=["srv_a", "srv_b"])
- assert {c.name for c in req} == {"srv_a", "srv_b"}
- assert transient == []
-
- @pytest.mark.asyncio
- async def test_partial_enabled_keys(self, pool):
- clients: dict = {}
- with _patch_make_client(clients):
- await pool.initialize(_stdio_configs(["srv_a", "srv_b", "srv_c"]))
- req, _ = await pool.get_request_clients(enabled_keys=["srv_a"])
- assert {c.name for c in req} == {"srv_a"}
-
- @pytest.mark.asyncio
- async def test_transient_spawned_fresh(self, pool):
- clients: dict = {}
- with _patch_make_client(clients):
- await pool.initialize(_stdio_configs(["stable_srv"]))
- req, transient = await pool.get_request_clients(
- enabled_keys=["stable_srv", "transient_srv"],
- per_request_servers_cfg=_stdio_configs(["transient_srv"], stable=False),
- )
- assert len(transient) == 1
- assert transient[0].name == "transient_srv"
- assert transient[0].connect_count == 1
-
- @pytest.mark.asyncio
- async def test_build_toolkit_from_cache_alias(self, pool):
- """The backward-compatible alias build_toolkit_from_cache now returns (clients, transient)."""
- clients: dict = {}
- with _patch_make_client(clients):
- await pool.initialize(_stdio_configs(["srv"]))
- req, transient = await pool.build_toolkit_from_cache(enabled_keys=["srv"])
- assert {c.name for c in req} == {"srv"}
-
-
-class TestLifecycle:
- @pytest.mark.asyncio
- async def test_shutdown_closes_and_clears(self, pool):
- clients: dict = {}
- with _patch_make_client(clients):
- await pool.initialize(_stdio_configs(["srv"]))
- assert pool.has_cached_tools
- await pool.shutdown()
- assert not pool.has_cached_tools
- assert pool.stable_client_count == 0
- assert clients["srv"].close_count == 1
-
- @pytest.mark.asyncio
- async def test_reconnect_stable(self, pool):
- clients: dict = {}
- with _patch_make_client(clients):
- await pool.initialize(_stdio_configs(["srv"]))
- new_client = await pool._reconnect_stable("srv")
- assert new_client is not None
- assert new_client.connect_count == 1
-
- @pytest.mark.asyncio
- async def test_reinitialize_on_config_change(self, pool):
- with _patch_make_client({}):
- await pool.initialize(_stdio_configs(["srv_a"]))
- assert pool.stable_server_ids == frozenset({"srv_a"})
- await pool.reinitialize_if_config_changed(_stdio_configs(["srv_a", "srv_b"]))
- assert pool.stable_server_ids == frozenset({"srv_a", "srv_b"})
-
-
-class TestHasCachedTools:
- @pytest.mark.asyncio
- async def test_false_before_init(self, pool):
- assert not pool.has_cached_tools
-
- @pytest.mark.asyncio
- async def test_true_after_init_with_stable(self, pool):
- with _patch_make_client({}):
- await pool.initialize(_stdio_configs(["srv"]))
- assert pool.has_cached_tools
-
- @pytest.mark.asyncio
- async def test_false_when_no_stable_servers(self, pool):
- await pool.initialize(_stdio_configs([], stable=True))
- assert not pool.has_cached_tools
diff --git a/src/backend/tests/llm/test_mcp_runtime_pool.py b/src/backend/tests/llm/test_mcp_runtime_pool.py
new file mode 100644
index 00000000..d6df0cdf
--- /dev/null
+++ b/src/backend/tests/llm/test_mcp_runtime_pool.py
@@ -0,0 +1,272 @@
+"""Run handles, the shared STDIO pool, call admission and result shaping over real SDK transports."""
+import asyncio
+import sys
+import time
+from types import SimpleNamespace
+from typing import Any, Dict
+
+import pytest
+from agentscope.message import TextBlock
+from agentscope.model import ChatResponse
+from mcp import types
+from mcp.server.caching import CacheHint
+from mcp.server.mcpserver import Context, MCPServer
+from mcp.shared.exceptions import MCPError
+
+from core.llm.mcp import chat_sessions
+from core.llm.mcp.callbacks import Interactions
+from core.llm.mcp.lifetime import discover
+from core.llm.mcp.tools import queue_progress
+from tests.llm.test_mcp_parallel_execution import probe_server
+
+
+def _state(chunk):
+ return str(getattr(chunk.state, "value", chunk.state))
+
+
+def _texts(chunk):
+ return [block.text for block in chunk.content if isinstance(block, TextBlock)]
+
+
+def _run(answer, chat="pool-chat", user="pool-user"):
+ class Model:
+ model = "fixture"
+ async def __call__(self, messages, tools=None, **kwargs):
+ return ChatResponse(content=[TextBlock(text=answer)], is_last=True)
+ request = SimpleNamespace(chat_id=chat, run_id="", current_user_id=user)
+ return Interactions(request, "pool", True,
+ agent=SimpleNamespace(model=Model(), state=SimpleNamespace(pending_mcp_progress=[])))
+
+
+def _effects_server(calls):
+ server = MCPServer("effects")
+
+ @server.tool(annotations={"readOnlyHint": True})
+ async def look(seconds: float = 0) -> str:
+ await asyncio.sleep(seconds)
+ return "seen"
+
+ @server.tool()
+ async def write(key: str, seconds: float = 0) -> str:
+ calls.append(key)
+ await asyncio.sleep(seconds)
+ return "written " + key
+
+ @server.tool(annotations={"readOnlyHint": True})
+ async def reject() -> str:
+ raise MCPError(code=-32602, message="bad request")
+
+ @server.tool(annotations={"readOnlyHint": True})
+ def typed(text: str) -> Dict[str, Any]:
+ return {"text": text}
+
+ @server.tool(annotations={"readOnlyHint": True})
+ async def structured_only() -> types.CallToolResult:
+ return types.CallToolResult(content=[], structured_content={"answer": 42})
+
+ @server.tool(annotations={"readOnlyHint": True})
+ async def described() -> types.CallToolResult:
+ return types.CallToolResult(content=[types.TextContent(text="see data")], structured_content={"answer": 42})
+
+ @server.tool(annotations={"readOnlyHint": True})
+ def plain() -> str:
+ return "done"
+
+ @server.tool(annotations={"readOnlyHint": True})
+ async def steps(ctx: Context) -> str:
+ for step in range(1, 6):
+ await ctx.report_progress(step, 5, f"step {step}")
+ return "stepped"
+ return server
+
+
+@pytest.mark.asyncio
+async def test_request_failures_become_results_and_keep_the_session():
+ calls = []
+ async with probe_server(_effects_server(calls)) as (url, _):
+ resource = await discover("effects", {"transport": "streamable_http", "url": url, "execution_timeout": 0.5})
+ tools = {tool.name: tool for tool in resource.tools}
+ try:
+ timed_out = await tools["look"](seconds=2)
+ assert _state(timed_out) == "error" and "outcome_unknown" not in timed_out.metadata
+ rejected = await tools["reject"]()
+ assert _state(rejected) == "error"
+ assert not resource.session.failed
+ assert _texts(await tools["look"]()) == ["seen"]
+ finally:
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_write_with_unknown_outcome_is_never_replayed_in_the_same_run():
+ calls = []
+ async with probe_server(_effects_server(calls)) as (url, _):
+ cfg = {"transport": "streamable_http", "url": url, "execution_timeout": 0.5}
+ resource = await discover("effects", cfg)
+ tools = {tool.name: tool for tool in resource.tools}
+ try:
+ first = await tools["write"](key="a", seconds=2)
+ assert first.metadata["outcome_unknown"] is True
+ replay = await tools["write"](key="a", seconds=2)
+ assert replay.metadata["outcome_unknown"] is True
+ assert calls == ["a"]
+ assert _texts(await tools["write"](key="b")) == ["written b"]
+ finally:
+ await resource.close()
+ # A new run decides for itself.
+ later = await discover("effects", cfg)
+ try:
+ assert _texts(await {t.name: t for t in later.tools}["write"](key="a")) == ["written a"]
+ finally:
+ await later.close()
+
+
+@pytest.mark.asyncio
+async def test_read_only_calls_overlap_while_writes_run_alone():
+ calls = []
+ async with probe_server(_effects_server(calls)) as (url, _):
+ resource = await discover("effects", {"transport": "streamable_http", "url": url})
+ tools = {tool.name: tool for tool in resource.tools}
+ try:
+ assert tools["look"].is_concurrency_safe and not tools["write"].is_concurrency_safe
+ started = time.monotonic()
+ await asyncio.gather(*(tools["look"](seconds=0.4) for _ in range(4)))
+ assert time.monotonic() - started < 1.0
+ started = time.monotonic()
+ await asyncio.gather(*(tools["write"](key=str(i), seconds=0.3) for i in range(3)))
+ assert time.monotonic() - started >= 0.85
+ finally:
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_structured_output_reaches_the_model_once():
+ async with probe_server(_effects_server([])) as (url, _):
+ resource = await discover("effects", {"transport": "streamable_http", "url": url})
+ tools = {tool.name: tool for tool in resource.tools}
+ try:
+ typed = await tools["typed"](text="x")
+ assert len(_texts(typed)) == 1 and typed.metadata["mcp"]["structuredContent"]
+ assert _texts(await tools["structured_only"]()) == ['{"answer":42}']
+ assert _texts(await tools["plain"]()) == ["done"]
+ # Text that does not carry the structured value keeps it for the model.
+ assert _texts(await tools["described"]()) == ["see data", '{"answer":42}']
+ finally:
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_progress_keeps_the_newest_event_per_call():
+ queue = []
+ for step in range(3):
+ queue_progress(queue, {"tool_id": "a", "progress": step})
+ queue_progress(queue, {"tool_id": "b", "progress": 9})
+ assert queue == [{"tool_id": "a", "progress": 2}, {"tool_id": "b", "progress": 9}]
+
+ run = _run("unused")
+ async with probe_server(_effects_server([]), json_response=False) as (url, _):
+ resource = await discover("effects", {"transport": "streamable_http", "url": url}, interactions=run)
+ try:
+ await {t.name: t for t in resource.tools}["steps"]()
+ events = run.agent.state.pending_mcp_progress
+ assert len(events) == 1 and events[0]["progress"] == 5
+ finally:
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_listings_are_shared_across_runs_of_one_authorization_context():
+ server = MCPServer("cached", cache_hints={method: CacheHint(ttl_ms=60_000) for method in
+ ("server/discover", "tools/list", "resources/list",
+ "resources/templates/list", "prompts/list")})
+
+ @server.tool(annotations={"readOnlyHint": True})
+ def ping() -> str:
+ return "pong"
+
+ async with probe_server(server) as (url, _):
+ first = await _timed_listing(url, "alice")
+ second = await _timed_listing(url, "alice")
+ other = await _timed_listing(url, "bob")
+ assert second < first and other == first
+
+
+async def _timed_listing(url, user):
+ """Count list requests one run sends, through the SDK's own session methods."""
+ from mcp.client.session import ClientSession
+ sent = []
+ original = ClientSession.send_request
+
+ async def counting(self, request, *args, **kwargs):
+ sent.append(getattr(request, "method", type(request).__name__))
+ return await original(self, request, *args, **kwargs)
+
+ ClientSession.send_request = counting
+ try:
+ resource = await discover("cached", {"transport": "streamable_http", "url": url,
+ "headers": {"X-Current-User-Id": user}})
+ await resource.close()
+ finally:
+ ClientSession.send_request = original
+ return len(sent)
+
+
+def _stdio_server(tmp_path):
+ script = tmp_path / "pool_server.py"
+ script.write_text(
+ "import os\n"
+ "from mcp import types\n"
+ "from mcp.server.mcpserver import Context, MCPServer\n"
+ "s = MCPServer('pool')\n"
+ "@s.tool(annotations={'readOnlyHint': True})\n"
+ "def pid() -> int:\n return os.getpid()\n"
+ "@s.tool(annotations={'readOnlyHint': True})\n"
+ "async def ask(ctx: Context) -> str | types.InputRequiredResult:\n"
+ " if ctx.input_responses:\n"
+ " return ctx.input_responses['q'].content.text\n"
+ " return types.InputRequiredResult(input_requests={'q': types.CreateMessageRequest(\n"
+ " params=types.CreateMessageRequestParams(messages=[types.SamplingMessage(\n"
+ " role='user', content=types.TextContent(text='who?'))], max_tokens=10))})\n"
+ "s.run()\n")
+ return {"transport": "stdio", "command": sys.executable, "args": [str(script)]}
+
+
+@pytest.mark.asyncio
+async def test_concurrent_runs_share_one_process_and_each_answers_its_own_requests(tmp_path):
+ cfg = _stdio_server(tmp_path)
+ scope = ("pool-user", "pool-chat", "")
+ first = await discover("pool", cfg, interactions=_run("first-run"), scope=scope)
+ second = await asyncio.wait_for(discover("pool", cfg, interactions=_run("second-run"), scope=scope), 5)
+ try:
+ assert first.session is second.session
+ for run in (first, second):
+ run.interactions.agent.toolkit = None
+ answers = await asyncio.gather(
+ {t.name: t for t in first.tools}["ask"](), {t.name: t for t in second.tools}["ask"]())
+ assert [_texts(a) for a in answers] == [["first-run"], ["second-run"]]
+ finally:
+ await first.close()
+ await second.close()
+ await chat_sessions.close_chat()
+
+
+@pytest.mark.asyncio
+async def test_idle_sessions_make_room_and_only_held_sessions_count_against_capacity(tmp_path, monkeypatch):
+ cfg = _stdio_server(tmp_path)
+ monkeypatch.setattr(chat_sessions, "MAX_USER_SESSIONS", 2)
+ try:
+ for chat in range(4):
+ resource = await discover("pool", cfg, scope=("cap-user", f"chat-{chat}", ""))
+ assert _state(await resource.tools[0]()) != "error"
+ await resource.close()
+ assert sum(k[0] == "cap-user" for k in chat_sessions._entries) <= 2
+ held = [await discover("pool", cfg, scope=("cap-user", f"held-{i}", "")) for i in range(2)]
+ try:
+ with pytest.raises(RuntimeError, match="capacity"):
+ await discover("pool", cfg, scope=("cap-user", "one-too-many", ""))
+ finally:
+ for resource in held:
+ await resource.close()
+ finally:
+ await chat_sessions.close_chat()
+ assert not chat_sessions._entries
diff --git a/src/backend/tests/llm/test_mcp_session_ownership.py b/src/backend/tests/llm/test_mcp_session_ownership.py
new file mode 100644
index 00000000..90d9e380
--- /dev/null
+++ b/src/backend/tests/llm/test_mcp_session_ownership.py
@@ -0,0 +1,128 @@
+"""Real SDK transports: one discovery, run ownership, user isolation and cancellation."""
+import asyncio
+import sys
+import pytest
+from agentscope.message import ToolCallBlock
+from agentscope.tool import Toolkit
+from core.llm.mcp.lifetime import discover
+from core.llm.runtime.state import AgentRuntimeState
+from tests.llm.test_mcp_parallel_execution import probe_server
+
+
+@pytest.mark.asyncio
+async def test_http_tools_are_discovered_once_and_read_only_tools_may_overlap():
+ async with probe_server() as (url, intervals):
+ resource = await discover("probe", {"transport": "streamable_http", "url": url})
+ try:
+ assert [t.name for t in resource.tools] == ["probe"]
+ assert resource.tools[0].is_concurrency_safe is True
+ toolkit = Toolkit(tools=resource.tools)
+ for _ in range(2):
+ assert (await toolkit.get_tool_schemas())[0]["function"]["name"] == "probe"
+ chunks = await resource.tools[0](item=8)
+ assert "completed-8" in str(chunks)
+ assert list(intervals) == [8]
+ finally:
+ await resource.close()
+ assert resource.session._owner.done()
+
+
+@pytest.mark.asyncio
+async def test_stdio_discovery_and_calls_can_cross_tasks_without_cross_task_cleanup(tmp_path):
+ script = tmp_path / "server.py"
+ script.write_text('from mcp.server.mcpserver import MCPServer\ns=MCPServer("owned")\n@s.tool()\ndef echo(text: str)->str: return text\ns.run()\n')
+ cfg = {"command": sys.executable, "args": [str(script)]}
+ resource = await discover("owned", cfg)
+ async def call():
+ return await resource.tools[0](text="run-owned")
+ assert "run-owned" in str(await asyncio.create_task(call()))
+ await asyncio.create_task(resource.close())
+ assert resource.session._owner.done()
+ assert not resource.session._owner.cancelled()
+
+
+@pytest.mark.asyncio
+async def test_failed_discovery_releases_owner(tmp_path):
+ script = tmp_path / "bad.py"
+ script.write_text('raise SystemExit(1)\n')
+ with pytest.raises(Exception):
+ await discover("bad", {"command": sys.executable, "args": [str(script)]})
+ assert not any(t.get_name() == "mcp-session:bad" and not t.done() for t in asyncio.all_tasks())
+
+
+@pytest.mark.asyncio
+async def test_cancel_never_ready_stdio_does_not_leave_an_owner(tmp_path):
+ script = tmp_path / "never_ready.py"
+ script.write_text('import time\ntime.sleep(300)\n')
+ task = asyncio.create_task(discover("never-ready", {
+ "command": sys.executable, "args": [str(script)],
+ }))
+ await asyncio.sleep(0.2)
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(task, 5)
+ assert not any(t.get_name() == "mcp-session:never-ready" and not t.done() for t in asyncio.all_tasks())
+
+
+@pytest.mark.asyncio
+async def test_run_guard_handles_native_tools_and_revoked_mcp():
+ from agentscope.tool import FunctionTool
+ from core.llm.tools.ontology_toolkit import OntologyFilteredToolkit
+ calls = []
+ async def local(value: str):
+ calls.append(value)
+ return value
+ native = FunctionTool(local)
+ async with probe_server() as (url, intervals):
+ resource = await discover("probe", {"transport": "streamable_http", "url": url})
+ try:
+ toolkit = OntologyFilteredToolkit(tools=[native, *resource.tools])
+ allowed = True
+ toolkit.capability_guard = lambda name: allowed and name == "probe"
+ state = AgentRuntimeState()
+ result = [x async for x in toolkit.call_tool(ToolCallBlock(id="native", name="local", input='{"value":"local-ok"}'), state)]
+ assert calls == ["local-ok"]
+ result = [x async for x in toolkit.call_tool(ToolCallBlock(id="allowed", name="probe", input='{"item":1}'), state)]
+ assert 1 in intervals
+ allowed = False
+ result = [x async for x in toolkit.call_tool(ToolCallBlock(id="revoked", name="probe", input='{"item":2}'), state)]
+ assert 2 not in intervals
+ assert "本次调用未执行" in str(result)
+ finally:
+ await resource.close()
+
+
+@pytest.mark.asyncio
+async def test_plugin_rollback_repeated_cancel_releases_every_stdio_owner(tmp_path):
+ from types import SimpleNamespace
+ from core.llm.tools.plugin_activation import _ActivationStage
+ script = tmp_path / "delayed_shutdown.py"
+ script.write_text('''import anyio
+from contextlib import asynccontextmanager
+from mcp.server.mcpserver import MCPServer
+@asynccontextmanager
+async def lifespan(server):
+ try:
+ yield {}
+ finally:
+ await anyio.sleep(.3)
+s = MCPServer("rollback", lifespan=lifespan)
+@s.tool()
+def echo(text: str)->str: return text
+s.run()
+''')
+ live = SimpleNamespace(tools=[], skills_or_loaders=[])
+ stage = _ActivationStage(live, {"close_list": []}, None)
+ resources = [await discover("rollback-"+str(i), {"command":sys.executable,"args":[str(script)]}) for i in range(2)]
+ for resource in resources:
+ stage.add_client(resource.name, resource)
+ cleanup = asyncio.create_task(stage.discard())
+ await asyncio.sleep(.05)
+ cleanup.cancel()
+ await asyncio.sleep(.05)
+ cleanup.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(cleanup, 10)
+ assert all(r.session._stop.is_set() and r.session._owner.done() for r in resources)
+ assert not stage._staged_clients
+ assert not live.tools
diff --git a/src/backend/tests/llm/test_mcp_timeouts.py b/src/backend/tests/llm/test_mcp_timeouts.py
index 2d097021..bcb13462 100644
--- a/src/backend/tests/llm/test_mcp_timeouts.py
+++ b/src/backend/tests/llm/test_mcp_timeouts.py
@@ -3,29 +3,22 @@
from types import SimpleNamespace
-class _CapturingClient:
- def __init__(self, **kwargs) -> None:
- self.kwargs = kwargs
-
-
def test_kb_client_gets_tighter_execution_and_transport_deadlines(monkeypatch):
- from core.llm.mcp.pool import make_client
+ from core.llm.mcp.client import make_client
monkeypatch.delenv("KB_MCP_EXECUTION_TIMEOUT_SECONDS", raising=False)
client = make_client(
"retrieve_dataset_content",
{"transport": "streamable_http", "url": "http://mcp:9100/mcp/"},
- is_stateful=False,
- client_cls=_CapturingClient,
)
- assert client.kwargs["execution_timeout"] == 75.0
- assert client.kwargs["mcp_config"].timeout == 80.0
- assert client.kwargs["mcp_config"].url == "http://mcp:9100/mcp"
+ assert client.read_timeout_seconds == 75.0
+ from core.llm.mcp.client import _http_transport_timeout
+ assert _http_transport_timeout("retrieve_dataset_content", {}, client.read_timeout_seconds) == 80.0
def test_mcp_timeout_config_overrides_defaults():
- from core.llm.mcp.pool import make_client
+ from core.llm.mcp.client import make_client
client = make_client(
"custom",
@@ -35,25 +28,23 @@ def test_mcp_timeout_config_overrides_defaults():
"execution_timeout": 240,
"transport_timeout": 250,
},
- is_stateful=False,
- client_cls=_CapturingClient,
)
- assert client.kwargs["execution_timeout"] == 240.0
- assert client.kwargs["mcp_config"].timeout == 250.0
+ assert client.read_timeout_seconds == 240.0
+ from core.llm.mcp.client import _http_transport_timeout
+ assert _http_transport_timeout("custom", {"transport_timeout": 250}, client.read_timeout_seconds) == 250.0
def test_stdio_client_also_has_a_hard_execution_deadline(monkeypatch):
- from core.llm.mcp.pool import make_client
+ from core.llm.mcp.client import make_client
monkeypatch.setenv("MCP_TOOL_EXECUTION_TIMEOUT_SECONDS", "90")
client = make_client(
"stdio-tool",
{"transport": "stdio", "command": "python", "args": []},
- client_cls=_CapturingClient,
)
- assert client.kwargs["execution_timeout"] == 90.0
+ assert client.read_timeout_seconds == 90.0
def test_database_extra_config_exposes_timeout_overrides(monkeypatch):
@@ -61,7 +52,6 @@ def test_database_extra_config_exposes_timeout_overrides(monkeypatch):
row = SimpleNamespace(
transport="streamable_http",
- is_stable=False,
url="http://remote/mcp",
headers={},
extra_config={"execution_timeout": 180, "transport_timeout": 190},
diff --git a/src/backend/tests/llm/test_migration_vendor_datasource.py b/src/backend/tests/llm/test_migration_vendor_datasource.py
new file mode 100644
index 00000000..4b487704
--- /dev/null
+++ b/src/backend/tests/llm/test_migration_vendor_datasource.py
@@ -0,0 +1,114 @@
+"""Migration seams outside the main factory: LiteLLM and datasource probes."""
+import os
+import sys
+from types import SimpleNamespace
+
+import pytest
+from agentscope.message import UserMsg
+from agentscope.model import OpenAIChatModel
+from openai.types.chat import ChatCompletionChunk
+from mcp.server.mcpserver import MCPServer
+
+from core.models.providers.vendor_models import LiteLLMChatModel
+from core.llm.mcp import lifetime
+from features.datasources import introspection
+from tests.llm.test_mcp_parallel_execution import probe_server
+
+
+@pytest.mark.asyncio
+async def test_litellm_stream_uses_native_parser(monkeypatch):
+ calls = []
+ closed = []
+
+ async def completion(**kwargs):
+ calls.append(kwargs)
+
+ async def stream():
+ for delta, finish in [({"role": "assistant", "content": "vendor-ok"}, None), ({}, "stop")]:
+ yield ChatCompletionChunk.model_validate({
+ "id": "litellm-stream", "object": "chat.completion.chunk",
+ "created": 1, "model": "vendor-probe",
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
+ })
+ class VendorStream:
+ def __aiter__(self):
+ return stream()
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc_info):
+ closed.append(True)
+
+ return VendorStream()
+
+ monkeypatch.setitem(sys.modules, "litellm", SimpleNamespace(acompletion=completion))
+ model = LiteLLMChatModel(
+ litellm_model="bedrock/vendor-probe", litellm_kwargs={}, model="vendor-probe",
+ parameters=OpenAIChatModel.Parameters(), stream=True, timeout=5, context_size=32768,
+ )
+ response = await model._call_api("vendor-probe", [UserMsg(name="user", content="hello")])
+ chunks = [chunk async for chunk in response]
+ assert calls[0]["stream"] is True
+ assert calls[0]["model"] == "bedrock/vendor-probe"
+ assert any("vendor-ok" in str(chunk.content) for chunk in chunks)
+ assert chunks[-1].finished_reason.value == "completed"
+ assert closed == [True]
+
+
+TOOLS = '''
+@s.tool()
+def search_objects(source: str = "", database: str = "") -> str:
+ return '{"tables":[{"schema":"public","table":"orders","columns":[{"name":"id","type":"integer"}]}]}'
+@s.tool()
+def list_indices(index_pattern: str) -> str:
+ return '["orders"]'
+@s.tool()
+def get_mappings(index: str) -> str:
+ return '{"orders":{"mappings":{"properties":{"id":{"type":"integer"}}}}}'
+'''
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("transport", ["stdio", "streamable_http"])
+@pytest.mark.parametrize("kind", ["sql", "es"])
+async def test_real_datasource_probe_releases_resources(monkeypatch, tmp_path, transport, kind):
+ resources = []
+ original = lifetime.discover
+
+ async def record_discovery(*args, **kwargs):
+ resource = await original(*args, **kwargs)
+ resources.append(resource)
+ return resource
+
+ monkeypatch.setattr(lifetime, "discover", record_discovery)
+
+ async def run(cfg):
+ monkeypatch.setattr(introspection, "_get_mcp_cfg", lambda *_: (cfg, None))
+ if kind == "sql":
+ rows, error = await introspection._introspect_sql("source", "database")
+ else:
+ rows, error = await introspection._introspect_es("source", "orders*")
+ assert error is None
+ assert rows[0]["table"] == "orders"
+ assert rows[0]["columns"] == [{"name": "id", "type": "integer"}]
+ assert len(resources) == 1
+ assert resources[0].session._owner.done()
+ assert not resources[0].session._owner.cancelled()
+
+ if transport == "streamable_http":
+ server = MCPServer("datasource-probe")
+ exec(TOOLS, {"s": server})
+ async with probe_server(server) as (url, _):
+ await run({"transport": transport, "url": url})
+ else:
+ pid_file = tmp_path / "pid"
+ script = tmp_path / "server.py"
+ script.write_text(
+ "import os\nfrom pathlib import Path\nfrom mcp.server.mcpserver import MCPServer\n"
+ + f"Path({str(pid_file)!r}).write_text(str(os.getpid()))\n"
+ + 's=MCPServer("datasource-probe")\n' + TOOLS + '\ns.run()\n'
+ )
+ await run({"transport": transport, "command": sys.executable, "args": [str(script)]})
+ with pytest.raises(ProcessLookupError):
+ os.kill(int(pid_file.read_text()), 0)
diff --git a/src/backend/tests/llm/test_model_failover.py b/src/backend/tests/llm/test_model_failover.py
index 7ff4dd88..fe75ae5d 100644
--- a/src/backend/tests/llm/test_model_failover.py
+++ b/src/backend/tests/llm/test_model_failover.py
@@ -27,7 +27,8 @@ def __init__(self, provider_id: str, behaviour: Any, *, stream: bool = True):
self.parameters = SimpleNamespace()
self.stream = stream
self.context_size = 32000
- self.formatter = SimpleNamespace()
+ from agentscope.formatter import OpenAIChatFormatter
+ self.formatter = OpenAIChatFormatter()
self.wire_protocol = "openai_chat"
self.structured_reasoning = False
self._behaviour = behaviour
diff --git a/src/backend/tests/llm/test_model_steps.py b/src/backend/tests/llm/test_model_steps.py
index 2e57d77f..ce8c19d2 100644
--- a/src/backend/tests/llm/test_model_steps.py
+++ b/src/backend/tests/llm/test_model_steps.py
@@ -240,7 +240,9 @@ def test_replay_reproduces_three_serial_steps_in_order_without_duplication():
"c1",
"c2",
]
- assert rows[-1]["content"][-1] == {"type": "text", "text": "done"}
+ assert rows[-1]["content"][-1]["type"] == "text"
+ assert rows[-1]["content"][-1]["text"] == "done"
+ assert rows[-1]["content"][-1]["created_at"] is not None
def test_replay_keeps_two_parallel_calls_in_one_step_with_both_results_after_it():
@@ -306,7 +308,8 @@ def test_close_dangling_calls_balances_the_record_at_rest():
def test_cancelled_turn_is_marked_at_the_end_of_the_last_answer():
rows = M.replay_rows([_assistant(TextBlock(type="text", text="我先看看"))], cancelled=True)
- assert rows[-1]["content"] == [{"type": "text", "text": "我先看看\n\n[本轮回答被用户中断]"}]
+ assert len(rows[-1]["content"]) == 1
+ assert rows[-1]["content"][0]["text"] == "我先看看\n\n[本轮回答被用户中断]"
rows = M.replay_rows(
[
@@ -334,10 +337,9 @@ def test_replace_final_text_rewrites_only_the_last_answer_text():
M.replace_final_text(steps, "revised")
- assert steps[-1]["blocks"] == [
- {"type": "thinking", "thinking": "B"},
- {"type": "text", "text": "revised"},
- ]
+ assert len(steps[-1]["blocks"]) == 2
+ assert steps[-1]["blocks"][0]["thinking"] == "B"
+ assert steps[-1]["blocks"][1] == {"type": "text", "text": "revised"}
assert steps[0]["blocks"][1]["type"] == "tool_call"
diff --git a/src/backend/tests/llm/test_offloader.py b/src/backend/tests/llm/test_offloader.py
index 300c1af3..8f246326 100644
--- a/src/backend/tests/llm/test_offloader.py
+++ b/src/backend/tests/llm/test_offloader.py
@@ -80,7 +80,7 @@ async def test_failed_offload_is_not_presented_as_a_file(runner_workspace, strea
from agentscope.tool import Toolkit
from agentscope.tool._response import ToolChunk
from core.llm.context.compacting_agent import CompactingAgent
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly, function_tool
from tests.llm.test_tool_result_images import CaptureModel
class UnwritableFiles:
@@ -91,14 +91,14 @@ async def logs():
"""Read diagnostic logs."""
return ToolChunk(content=[TextBlock(text="BEGIN " + "log line " * 20_000 + " END")])
- collector = ToolCollector()
- collector.register_tool_function(logs)
+ collector = ToolAssembly()
+ collector.append(function_tool(logs))
model = CaptureModel("logs", {})
agent = CompactingAgent(
name="agent",
system_prompt="Inspect logs.",
model=model,
- toolkit=Toolkit(tools=collector.function_tools),
+ toolkit=Toolkit(tools=list(collector)),
context_config=ContextConfig(tool_result_limit=100),
offloader=SandboxOffloader(UnwritableFiles(), "chat-a"),
)
@@ -198,7 +198,7 @@ async def test_full_output_can_be_read_from_the_path_seen_by_model(runner_worksp
from agentscope.tool import Toolkit
from agentscope.tool._response import ToolChunk
from core.llm.context.compacting_agent import CompactingAgent
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly, function_tool
from tests.llm.test_tool_result_images import CaptureModel
text = 'BEGIN\n{"数据": "' + "line " * 30_000 + '"}\nEND'
@@ -207,15 +207,15 @@ async def logs():
"""Read diagnostic logs."""
return ToolChunk(content=[TextBlock(text=text)])
- collector = ToolCollector()
- collector.register_tool_function(logs)
+ collector = ToolAssembly()
+ collector.append(function_tool(logs))
model = CaptureModel("logs", {})
files = RunnerFiles()
agent = CompactingAgent(
name="agent",
system_prompt="Inspect logs.",
model=model,
- toolkit=Toolkit(tools=collector.function_tools),
+ toolkit=Toolkit(tools=list(collector)),
context_config=ContextConfig(tool_result_limit=100),
offloader=SandboxOffloader(files, "chat-a", user_id="u1"),
)
diff --git a/src/backend/tests/llm/test_plan_update_tool_description.py b/src/backend/tests/llm/test_plan_update_tool_description.py
index dc1c28e2..5a76176f 100644
--- a/src/backend/tests/llm/test_plan_update_tool_description.py
+++ b/src/backend/tests/llm/test_plan_update_tool_description.py
@@ -17,7 +17,9 @@ class _CapturingToolkit:
def __init__(self) -> None:
self.functions: dict = {}
- def register_tool_function(self, fn, **_kwargs) -> None:
+ def append(self, tool):
+ fn = tool._func
+ _kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.functions[fn.__name__] = fn
diff --git a/src/backend/tests/llm/test_project_environment_factory.py b/src/backend/tests/llm/test_project_environment_factory.py
index 0b6f3cf5..a74d41ae 100644
--- a/src/backend/tests/llm/test_project_environment_factory.py
+++ b/src/backend/tests/llm/test_project_environment_factory.py
@@ -88,16 +88,16 @@ async def run():
# factory and Toolkit must expose only the explicitly bound private MCP,
# including when its ambient switch is off.
from unittest.mock import patch
- from core.llm.mcp.pool import make_client
+ from core.llm.mcp.lifetime import discover
connected = []
- def manifest_client(name, config, **kwargs):
+ async def manifest_client(name, config, **kwargs):
connected.append(name)
- return make_client(name, {**config, "schema_source": "cloud_manifest",
+ return await discover(name, {**config, "schema_source": "cloud_manifest",
"gateway_invoke_url": "http://127.0.0.1:1/invoke", "schema_hash": "fixture",
"manifest_tools": [{"name": "read_" + name, "description": "Read fixture",
"inputSchema": {"type": "object", "properties": {}}}]}, **kwargs)
selected_ctx = {"project_id": "selected"}
- with patch("core.llm.mcp.pool.make_client", side_effect=manifest_client):
+ with patch("core.llm.mcp.lifetime.discover", side_effect=manifest_client):
selected_agent, selected_clients = await create_agent_executor(
current_user_id="owner", project_ctx=selected_ctx, max_iters=1,
enabled_mcp_ids=["outside"],
diff --git a/src/backend/tests/llm/test_project_environment_policy.py b/src/backend/tests/llm/test_project_environment_policy.py
index 340c68f3..4e1123df 100644
--- a/src/backend/tests/llm/test_project_environment_policy.py
+++ b/src/backend/tests/llm/test_project_environment_policy.py
@@ -47,7 +47,9 @@ def test_dynamic_plugin_loader_refuses_components_outside_frozen_environment():
from core.capabilities.plugins.runtime.models import DeferredPlugin
class Collector:
- def register_tool_function(self, fn, **_):
+ def append(self, tool):
+ fn = tool._func
+ _ = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.invoke = fn
collector = Collector()
diff --git a/src/backend/tests/llm/test_read_image_native.py b/src/backend/tests/llm/test_read_image_native.py
index 345f95a1..b7992f99 100644
--- a/src/backend/tests/llm/test_read_image_native.py
+++ b/src/backend/tests/llm/test_read_image_native.py
@@ -20,7 +20,9 @@ class _Toolkit:
def __init__(self):
self.fns = {}
- def register_tool_function(self, fn, **kw):
+ def append(self, tool):
+ fn = tool._func
+ kw = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.fns[fn.__name__] = fn
diff --git a/src/backend/tests/llm/test_reasoning_budget.py b/src/backend/tests/llm/test_reasoning_budget.py
index 33329d46..7675b52f 100644
--- a/src/backend/tests/llm/test_reasoning_budget.py
+++ b/src/backend/tests/llm/test_reasoning_budget.py
@@ -8,7 +8,7 @@
"""
from core.llm.context.adapter import AgentScopeContextAdapter
-from core.llm.context.ir_state import KIND_ASSISTANT, KIND_THINKING, KIND_TOOL_CALL
+from core.llm.context.ir_constants import KIND_ASSISTANT, KIND_THINKING, KIND_TOOL_CALL
from core.llm.context.usage import BREAKDOWN_KEYS, _manifest_breakdown
_REASONING = "先查论文再对比专利," * 20
diff --git a/src/backend/tests/llm/test_responses_thinking_stream.py b/src/backend/tests/llm/test_responses_thinking_stream.py
index 4dda829d..22734b5d 100644
--- a/src/backend/tests/llm/test_responses_thinking_stream.py
+++ b/src/backend/tests/llm/test_responses_thinking_stream.py
@@ -1,10 +1,4 @@
-"""思考正文必须真的流到界面上。
-
-Responses 有两条思考通道:``reasoning_summary_text`` 是删减过的摘要(OpenAI 返回的
-那条),``reasoning_text`` 是思考原文(自建推理服务返回的那条)。AgentScope 的解析器
-只认前者。实测自建 deepseekv4-flash-vision 一轮发 144 帧 ``reasoning_text.delta``,
-全被丢掉——界面上是一个空思考块,用户看到的现象是「调到思考为高,却根本没有思考」。
-"""
+"""Responses 思考事件原样交给实际解析器,保留条目身份、分段和增量。"""
from __future__ import annotations
@@ -31,20 +25,47 @@ async def source():
@pytest.mark.asyncio
-async def test_reasoning_text_deltas_reach_the_parser():
- """原文通道的增量必须变成解析器认得的形状,否则思考一个字都到不了界面。"""
- _, seen = await _drain(
- [
- _event("response.reasoning_text.delta", delta="先"),
- _event("response.reasoning_text.delta", delta="想想"),
- _event("response.output_text.delta", delta="答案"),
- ]
- )
-
- reasoning = [e for e in seen if e.type == "response.reasoning_summary_text.delta"]
- assert [e.delta for e in reasoning] == ["先", "想想"]
- # 原始事件不再重复下发,否则同一段思考会被累积两遍。
- assert not [e for e in seen if e.type == "response.reasoning_text.delta"]
+async def test_reasoning_events_keep_identity_and_parts_in_real_parser():
+ from datetime import datetime
+ from agentscope.model import OpenAIResponseModel
+
+ events = [
+ _event("response.reasoning_text.delta", item_id="rs_1", content_index=0, delta="先"),
+ _event("response.reasoning_text.delta", item_id="rs_1", content_index=0, delta="想想"),
+ _event("response.reasoning_text.delta", item_id="rs_1", content_index=1, delta="另一段"),
+ _event("response.reasoning_text.delta", item_id="rs_2", content_index=0, delta="另一条"),
+ _event("response.reasoning_summary_text.delta", item_id="rs_1", summary_index=0, delta="摘要"),
+ _event("response.output_text.delta", item_id="msg_1", content_index=0, delta="答案"),
+ ]
+ _, seen = await _drain(events)
+ assert all(actual is original for actual, original in zip(seen, events))
+ assert len(seen) == len(events)
+
+ class Stream:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return False
+
+ def __aiter__(self):
+ async def source():
+ for event in events:
+ yield event
+ return source()
+
+ model = object.__new__(OpenAIResponseModel)
+ chunks = [
+ chunk async for chunk in model._parse_stream_response(
+ datetime.now(), _ReasoningItemCapture(Stream())
+ )
+ ]
+ blocks = [block for chunk in chunks for block in chunk.content]
+ thinking = [block for block in blocks if isinstance(block, ThinkingBlock)]
+ assert [block.thinking for block in thinking] == ["先", "想想", "另一段", "另一条", "摘要"]
+ assert thinking[0].id == thinking[1].id
+ assert len({block.id for block in thinking}) == 4
+ assert [block.text for block in blocks if isinstance(block, TextBlock)] == ["答案"]
@pytest.mark.asyncio
@@ -60,10 +81,11 @@ async def test_other_events_pass_through_untouched():
@pytest.mark.asyncio
-async def test_an_empty_delta_is_not_forwarded_as_thinking():
+async def test_an_empty_delta_is_preserved_for_native_parser():
_, seen = await _drain([_event("response.reasoning_text.delta", delta="")])
- assert seen == []
+ assert len(seen) == 1
+ assert seen[0].delta == ""
@pytest.mark.asyncio
@@ -111,3 +133,23 @@ def test_the_encrypted_blob_is_never_shown_as_thinking():
_attach_reasoning_items(blocks, items)
assert blocks[0].thinking == ""
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("interrupt", [False, True])
+async def test_capture_closes_upstream_on_completion_and_cancellation(interrupt):
+ import asyncio
+ from unittest.mock import AsyncMock
+ stream = SimpleNamespace(__aenter__=AsyncMock(), __aexit__=AsyncMock(return_value=False))
+ capture = _ReasoningItemCapture(stream)
+ if interrupt:
+ with pytest.raises(asyncio.CancelledError):
+ async with capture as entered:
+ assert entered is capture
+ raise asyncio.CancelledError()
+ else:
+ async with capture as entered:
+ assert entered is capture
+ stream.__aenter__.assert_awaited_once()
+ stream.__aexit__.assert_awaited_once()
+ assert stream.__aexit__.call_args.args[0] is (asyncio.CancelledError if interrupt else None)
diff --git a/src/backend/tests/llm/test_responses_tool_call_id.py b/src/backend/tests/llm/test_responses_tool_call_id.py
index 76805810..22a599c0 100644
--- a/src/backend/tests/llm/test_responses_tool_call_id.py
+++ b/src/backend/tests/llm/test_responses_tool_call_id.py
@@ -1,12 +1,4 @@
-"""Responses 线的工具结果配对:按调用 id 认人,不是按条目 id。
-
-Responses 线上一次函数调用带两个互不相同的 id —— 条目 id(``function_call.id``)与
-调用 id(``function_call.call_id``),结果回传只认后者。AgentScope 建
-``ToolResultBlock`` 时只留了条目 id,格式化又拿它当 ``function_call_output.call_id``
-发出去;DeepSeek 官方端点两者恰好不同(条目 id 是 uuid,调用 id 形如 ``call_00_…``),
-于是只要模型调了工具,这一轮就被打回
-``No tool call found for tool output with call_id …``。这些用例钉住配对。
-"""
+"""Native SDK call ids remain paired across Responses calls and outputs."""
from __future__ import annotations
@@ -28,10 +20,10 @@ def _formatter() -> ResponsesReplayFormatter:
def _turn(call_id: str | None) -> list[Msg]:
- extra = {"call_id": call_id} if call_id else {}
- call = ToolCallBlock(id=ITEM_ID, name="atlas_aggregate", input="{}", **extra)
+ canonical_id = call_id or ITEM_ID
+ call = ToolCallBlock(id=canonical_id, name="atlas_aggregate", input="{}")
result = ToolResultBlock(
- id=ITEM_ID,
+ id=canonical_id,
name="atlas_aggregate",
output=[TextBlock(text="ok")],
state="success",
@@ -72,7 +64,7 @@ async def test_message_items_have_explicit_type_without_changing_tool_items() ->
messages = [item for item in items if "role" in item and "content" in item]
assert messages
assert all(item.get("type") == "message" for item in messages)
- assert [item["content"][0]["type"] for item in messages] == ["input_text", "input_text"]
+ assert [item["content"][0]["type"] for item in messages] == ["input_text", "output_text"]
assert [item["type"] for item in items if item.get("type", "").startswith("function_call")] == [
"function_call", "function_call_output",
]
diff --git a/src/backend/tests/llm/test_subagent_cancellation.py b/src/backend/tests/llm/test_subagent_cancellation.py
index 938a9216..daedeb92 100644
--- a/src/backend/tests/llm/test_subagent_cancellation.py
+++ b/src/backend/tests/llm/test_subagent_cancellation.py
@@ -13,7 +13,9 @@
class CapturingToolkit:
- def register_tool_function(self, function, **kwargs):
+ def append(self, tool):
+ function = tool._func
+ kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.call = function
@@ -37,7 +39,7 @@ async def wait_for_cancel():
class Child:
state = SimpleNamespace(context=[])
- async def _reply(self, **kwargs):
+ async def reply_stream(self, **kwargs):
await wait_for_cancel()
if False:
yield None
diff --git a/src/backend/tests/llm/test_subagent_model_config.py b/src/backend/tests/llm/test_subagent_model_config.py
index 90996edc..31341584 100644
--- a/src/backend/tests/llm/test_subagent_model_config.py
+++ b/src/backend/tests/llm/test_subagent_model_config.py
@@ -11,6 +11,7 @@
from core.models.repository import create_provider
from core.models import config as model_config
from core.models.config import ModelConfigService
+from tests.db_support import share_session
@pytest.fixture
@@ -22,6 +23,7 @@ def model_api(db_session, monkeypatch):
app.include_router(routes.router)
app.dependency_overrides[require_system_settings] = lambda: None
app.dependency_overrides[get_db] = lambda: db_session
+ share_session(monkeypatch, db_session)
with TestClient(app) as client:
yield client, service, db_session
diff --git a/src/backend/tests/llm/test_subagent_tool_streaming.py b/src/backend/tests/llm/test_subagent_tool_streaming.py
index ad8d7366..10b572ae 100644
--- a/src/backend/tests/llm/test_subagent_tool_streaming.py
+++ b/src/backend/tests/llm/test_subagent_tool_streaming.py
@@ -1,5 +1,5 @@
"""Sub-agent sidebar events reuse the main streaming event mapper."""
-from core.llm.runtime import streaming_state as streaming_state_module
+from core.llm.runtime import streaming_constants as streaming_state_module
from types import SimpleNamespace
@@ -48,17 +48,20 @@ async def test_structured_subagent_content_streams_before_model_call_end():
mapper = _SubMapper()
first = await _map(stream, mapper, TextBlockDeltaEvent("hello"))
+ # Within the flush interval the next delta is merged, not sent on its own.
second = await _map(stream, mapper, TextBlockDeltaEvent(" world"))
+ tail = [sub for kind, payload in stream._deltas.drain() for sub in mapper.feed(kind, payload)]
assert first == [{"sub_type": "content", "delta": "hello"}]
- assert second == [{"sub_type": "content", "delta": " world"}]
+ assert second == []
+ assert tail == [{"sub_type": "content", "delta": " world"}]
assert mapper.finish_turn() == []
@pytest.mark.asyncio
async def test_subagent_tool_arguments_use_main_mapper(monkeypatch):
- monkeypatch.setattr(streaming_state_module, 'TOOL_CALL_DELTA_FLUSH_CHARS', 8)
- monkeypatch.setattr(streaming_state_module, 'TOOL_CALL_DELTA_FLUSH_INTERVAL_S', 999.0)
+ monkeypatch.setattr(streaming_state_module, 'DELTA_FLUSH_CHARS', 8)
+ monkeypatch.setattr(streaming_state_module, 'DELTA_FLUSH_INTERVAL_S', 999.0)
stream = _stream(structured=True)
mapper = _SubMapper()
diff --git a/src/backend/tests/llm/test_tool_permissions.py b/src/backend/tests/llm/test_tool_permissions.py
index adbe0841..38d1af2f 100644
--- a/src/backend/tests/llm/test_tool_permissions.py
+++ b/src/backend/tests/llm/test_tool_permissions.py
@@ -12,7 +12,7 @@
from agentscope.message import ToolCallBlock, ToolResultState
from agentscope.state import AgentState
from agentscope.tool._response import ToolChunk, ToolResponse
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly, function_tool
from core.sandbox.permissions.dispatch_models import CURRENT_PERMISSION_TICKET, PermissionRuntime
from core.sandbox.permissions.tool_specs import ToolPermissionRegistry, allow_tool, local_command_tool, local_path_tool
from core.llm.tool_permissions import ToolPermissionService
@@ -48,11 +48,11 @@ def test_native_tool_registration_does_not_require_a_declaration():
async def brand_new_tool() -> ToolResponse:
return ToolResponse()
- collector = ToolCollector()
- collector.register_tool_function(brand_new_tool)
+ collector = ToolAssembly()
+ collector.append(function_tool(brand_new_tool))
assert "brand_new_tool" not in collector.permission_specs
- collector.register_tool_function(brand_new_tool, permission=allow_tool())
+ collector.append(function_tool(brand_new_tool, dispatch_spec=allow_tool()))
assert collector.permission_specs["brand_new_tool"].key == "allow"
@@ -235,9 +235,9 @@ async def brand_new_tool(value: int) -> ToolChunk:
state=ToolResultState.SUCCESS,
)
- collector = ToolCollector()
- collector.register_tool_function(brand_new_tool)
- toolkit = OntologyFilteredToolkit(tools=collector.function_tools)
+ collector = ToolAssembly()
+ collector.append(function_tool(brand_new_tool))
+ toolkit = OntologyFilteredToolkit(tools=list(collector))
toolkit.set_tool_permission_service(
ToolPermissionService(
ToolPermissionRegistry(),
diff --git a/src/backend/tests/llm/test_tool_result_images.py b/src/backend/tests/llm/test_tool_result_images.py
index e3a4bfbd..c2eaeee3 100644
--- a/src/backend/tests/llm/test_tool_result_images.py
+++ b/src/backend/tests/llm/test_tool_result_images.py
@@ -25,7 +25,7 @@
from core.llm.context.compacting_agent import CompactingAgent
from core.models.constants import IMAGE_TOKEN_RESERVE
from core.llm.context.offloader import SandboxOffloader
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly, function_tool
from core.llm.tools.read_image_tool import register_read_image
from PIL import Image
@@ -109,9 +109,9 @@ async def read_bytes(*args, **kwargs):
return large_png
monkeypatch.setattr("core.llm.tools.read_image_tool._read_path_bytes", read_bytes)
- collector = ToolCollector()
+ collector = ToolAssembly()
register_read_image(collector, chat_id="shared-workspace", user_id="u1", vision_mode="native")
- toolkit = Toolkit(tools=collector.function_tools)
+ toolkit = Toolkit(tools=list(collector))
messages, files = await run_tool(toolkit, CaptureModel(), streaming=streaming)
images = wire_images(messages)
assert len(images) == 1, json.dumps(messages)[:2000]
@@ -137,10 +137,10 @@ async def screenshots():
"""Read screenshots with optional diagnostic output."""
return ToolChunk(content=[image, TextBlock(text=text), remote] if text else [image, remote])
- collector = ToolCollector()
- collector.register_tool_function(screenshots)
+ collector = ToolAssembly()
+ collector.append(function_tool(screenshots))
messages, files = await run_tool(
- Toolkit(tools=collector.function_tools), CaptureModel("screenshots", {})
+ Toolkit(tools=list(collector)), CaptureModel("screenshots", {})
)
assert wire_images(messages) == [
"data:image/png;base64," + encoded,
@@ -165,10 +165,10 @@ async def logs():
"""Return large plain text output."""
return ToolChunk(content=[TextBlock(text=text)])
- collector = ToolCollector()
- collector.register_tool_function(logs)
+ collector = ToolAssembly()
+ collector.append(function_tool(logs))
messages, files = await run_tool(
- Toolkit(tools=collector.function_tools), CaptureModel("logs", {})
+ Toolkit(tools=list(collector)), CaptureModel("logs", {})
)
assert wire_images(messages) == []
assert len(files) == 1
@@ -211,11 +211,11 @@ async def read_bytes(*args, **kwargs):
monkeypatch.setattr("core.llm.tools.read_image_tool._read_path_bytes", read_bytes)
model = CaptureModel()
monkeypatch.setattr(model, "generate_structured_output", unexpected_summary)
- collector = ToolCollector()
+ collector = ToolAssembly()
register_read_image(collector, chat_id="shared-workspace", user_id="u1", vision_mode="native")
messages, files = await run_tool(
- Toolkit(tools=collector.function_tools), model, streaming=streaming
+ Toolkit(tools=list(collector)), model, streaming=streaming
)
assert attempts == []
diff --git a/src/backend/tests/llm/test_vision_injection.py b/src/backend/tests/llm/test_vision_injection.py
index 5fa7e6f0..3c9f3000 100644
--- a/src/backend/tests/llm/test_vision_injection.py
+++ b/src/backend/tests/llm/test_vision_injection.py
@@ -241,8 +241,8 @@ def _register_bridge_read_image():
class _Toolkit:
@staticmethod
- def register_tool_function(fn, **kw):
- captured["fn"] = fn
+ def append(tool):
+ captured["fn"] = tool._func
register_read_image(_Toolkit(), chat_id="c1", user_id="u1", vision_mode="bridge")
return captured["fn"]
diff --git a/src/backend/tests/llm/test_workspace.py b/src/backend/tests/llm/test_workspace.py
index 33f31070..6cdc5141 100644
--- a/src/backend/tests/llm/test_workspace.py
+++ b/src/backend/tests/llm/test_workspace.py
@@ -77,7 +77,7 @@ def test_pin_to_workspace_tool_accepts_list(monkeypatch):
import json as _json
from agentscope.tool import Toolkit
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools.pin_tool import register_pin_to_workspace
# Stub the artifact lookup so we don't need a real artifact store.
@@ -92,10 +92,9 @@ def fake_get_artifact(fid):
monkeypatch.setattr('core.files.artifacts.store.get_artifact', fake_get_artifact)
workspace.init_state()
- # AgentScope 2.0: register_* writes into the ToolCollector; schema goes through Toolkit.get_tool_schemas().
- c = ToolCollector()
+ c = ToolAssembly()
register_pin_to_workspace(c)
- tk = Toolkit(tools=c.function_tools)
+ tk = Toolkit(tools=list(c))
schemas = asyncio.run(tk.get_tool_schemas())
pin_schema = next(s for s in schemas if s["function"]["name"] == "pin_to_workspace")
# Schema must declare file_ids as an array of strings (not a string).
@@ -103,7 +102,7 @@ def fake_get_artifact(fid):
assert fid_param["type"] == "array"
assert fid_param["items"]["type"] == "string"
- fn = c.get_tool("pin_to_workspace")._func
+ fn = next(t for t in c if t.name == "pin_to_workspace")._func
resp = asyncio.run(fn(file_ids=["fid_a", "fid_b"]))
payload = _json.loads(resp.content[0].text) # 2.0: block is a pydantic object
diff --git a/src/backend/tests/llm/test_workspace_profiles.py b/src/backend/tests/llm/test_workspace_profiles.py
index fa29e641..fc2f0184 100644
--- a/src/backend/tests/llm/test_workspace_profiles.py
+++ b/src/backend/tests/llm/test_workspace_profiles.py
@@ -57,7 +57,9 @@ async def test_skill_runtime_hint_runs_in_current_directory_with_spaces(monkeypa
(skill / "run.py").write_text("from pathlib import Path; Path('output.txt').write_text('ok')")
class Toolkit:
- def register_tool_function(self, fn, **kwargs):
+ def append(self, tool):
+ fn = tool._func
+ kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.fn = fn
loader = SimpleNamespace(
@@ -124,12 +126,6 @@ async def put_file(self, session_id, path, content, user_id=None):
)
)
- async def get_file_to_path(self, session_id, path, destination, **kwargs):
- result = await server.get_file(server.GetFileRequest(session_id=session_id, path=path))
- data = base64.b64decode(result.content_b64)
- destination.write_bytes(data)
- return len(data)
-
monkeypatch.setattr("core.sandbox.get_sandbox_provider", lambda: Provider())
monkeypatch.setattr(
"core.llm.tools.sandbox_artifact_tools._resolve_artifact_files",
@@ -138,32 +134,47 @@ async def get_file_to_path(self, session_id, path, destination, **kwargs):
None,
),
)
- captured = []
-
- def store(path, **kwargs):
- captured.append(path.read_bytes())
- return dict(
- file_id="result",
- name="output.txt",
- url="/files/result",
- mime_type="text/plain",
- size=10,
- )
-
- monkeypatch.setattr("core.llm.tools.sandbox_artifact_tools._store_generated_file_path", store)
-
- class Toolkit:
- def register_tool_function(self, fn, **kwargs):
- self.fn = fn
-
- put, get = Toolkit(), Toolkit()
- sandbox_tool.register_sandbox_put_artifact(put, chat_id="chat", user_id="owner")
- sandbox_tool.register_sandbox_get_artifact(get, chat_id="chat", user_id="owner")
+ from types import SimpleNamespace
+ from core.llm.factory.tools.definitions import ToolAssembly
+ from core.llm.tool_permissions import ToolPermissionService
+ from core.sandbox.permissions.tool_specs import ToolPermissionRegistry
+ from core.sandbox.permissions.dispatch_models import (
+ CURRENT_PERMISSION_TICKET, PermissionRuntime, PermissionEnforcementError,
+ )
+ from core.files.artifacts import store
+ from core.files.artifacts.local_reference import authorized_reference
+ from features.desktop.local.grants import add_grant
+
+ monkeypatch.setenv("HUGAGENT_HOME", str(tmp_path / "home"))
+ monkeypatch.setattr(store, "_STORE_DIR", tmp_path / "artifacts")
+ root = Path(_paths.workspace_directory("chat"))
+ root.mkdir(parents=True)
+ add_grant(str(root))
+ tools = ToolAssembly()
+ sandbox_tool.register_sandbox_put_artifact(tools, chat_id="chat", user_id="owner")
+ sandbox_tool.register_sandbox_get_artifact(tools, chat_id="chat", user_id="owner")
+ registry = ToolPermissionRegistry()
+ for name, spec in tools.permission_specs.items():
+ registry.register(name, spec, source="test")
+ permissions = ToolPermissionService(registry, PermissionRuntime(
+ chat_id="chat", user_id="owner", interactive=False, approval_available=False,
+ ))
+ functions = {tool.name: tool._func for tool in tools}
+
+ async def invoke(name, **args):
+ outcome = await permissions.authorize(SimpleNamespace(name=name, id="artifact-call", input=args))
+ if not outcome.proceed:
+ raise PermissionError(str(outcome.payload))
+ token = CURRENT_PERMISSION_TICKET.set(outcome.ticket)
+ try:
+ return await functions[name](**args)
+ finally:
+ CURRENT_PERMISSION_TICKET.reset(token)
def payload(result):
return json.loads(result.content[0].text)
- assert payload(await put.fn("upload", "input.txt"))["ok"]
+ assert payload(await invoke("sandbox_put_artifact", artifact_id="upload", dest_path="input.txt"))["ok"]
result = await run_runner(
server.ProcessRequest(
session_id="chat",
@@ -173,9 +184,25 @@ def payload(result):
)
)
assert result.exit_code == 0, result.stderr
- assert payload(await get.fn("output.txt"))["ok"]
- assert captured == [b"input-data"]
- assert "error" in payload(await get.fn("../outside.txt"))
+ with pytest.raises(PermissionEnforcementError):
+ await functions["sandbox_get_artifact"]("output.txt")
+ result = payload(await invoke("sandbox_get_artifact", src_path="output.txt"))
+ assert result["ok"]
+ reference = authorized_reference(result["file_id"], "owner")
+ assert reference["path"] == str(root / "output.txt")
+ assert Path(reference["path"]).read_bytes() == b"input-data"
+ assert authorized_reference(result["file_id"], "other-user") is None
+ with pytest.raises(PermissionError):
+ await invoke("sandbox_get_artifact", src_path="../outside.txt")
+ other_root = Path(_paths.workspace_directory("other-chat"))
+ other_root.mkdir(parents=True)
+ other = other_root / "secret.txt"
+ other.write_text("another session")
+ with pytest.raises(PermissionError):
+ await invoke("sandbox_get_artifact", src_path=str(other))
+ (root / "escape.txt").symlink_to(other)
+ with pytest.raises(PermissionError):
+ await invoke("sandbox_get_artifact", src_path="escape.txt")
def test_desktop_runner_creates_no_aliases(monkeypatch, tmp_path):
diff --git a/src/backend/tests/mcp/test_internet_search.py b/src/backend/tests/mcp/test_internet_search.py
index 9d744f67..2486924c 100644
--- a/src/backend/tests/mcp/test_internet_search.py
+++ b/src/backend/tests/mcp/test_internet_search.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from core.config.store import config_keys
from core.config.store import system_config
-from core.config.store import system_config_state
+from core.config.store import system_config_constants
import asyncio
@@ -110,7 +110,7 @@ async def _fake_test_langsearch(api_key: str) -> dict:
def test_langsearch_has_an_independent_system_config_key() -> None:
- seed_keys = {item[0] for item in system_config_state.SEED_CONFIGS}
+ seed_keys = {item[0] for item in system_config_constants.SEED_CONFIGS}
assert "internet_search.langsearch_api_key" in seed_keys
assert (
diff --git a/src/backend/tests/mcp/test_wiki_tool_gating.py b/src/backend/tests/mcp/test_wiki_tool_gating.py
index 41cbee22..a3a63fcd 100644
--- a/src/backend/tests/mcp/test_wiki_tool_gating.py
+++ b/src/backend/tests/mcp/test_wiki_tool_gating.py
@@ -22,10 +22,9 @@
async def _list_tool_names() -> set[str]:
- """走 lowlevel handler,覆盖到真实的 list_tools 链路而不是内部函数。"""
- handler = srv.mcp._mcp_server.request_handlers[types.ListToolsRequest]
- result = await handler(types.ListToolsRequest(method="tools/list"))
- return {tool.name for tool in result.root.tools}
+ """The public override is also used by the SDK tools/list handler."""
+ return {tool.name for tool in await srv.mcp.list_tools()}
+
@pytest.fixture
diff --git a/src/backend/tests/mcp_servers/test_search_batch.py b/src/backend/tests/mcp_servers/test_search_batch.py
index a0827689..89f7526b 100644
--- a/src/backend/tests/mcp_servers/test_search_batch.py
+++ b/src/backend/tests/mcp_servers/test_search_batch.py
@@ -299,7 +299,8 @@ def test_two_mcp_sessions_share_capacity_and_close_client(monkeypatch):
import json
from extensions.mcp_servers.internet_search_mcp import impl, server
- from mcp.shared.memory import create_connected_server_and_client_session
+ from mcp import Client
+ from tests.llm.test_mcp_parallel_execution import probe_server
monkeypatch.setattr(
impl,
@@ -337,8 +338,8 @@ def client_factory(**kwargs):
monkeypatch.setattr(server.httpx, "AsyncClient", client_factory)
async def run():
- async with create_connected_server_and_client_session(server.mcp) as first:
- async with create_connected_server_and_client_session(server.mcp) as second:
+ async with probe_server(server.mcp) as (url, _):
+ async with Client(url) as first, Client(url) as second:
assert len(clients) == 1
calls = [
asyncio.create_task(
@@ -358,12 +359,12 @@ async def run():
assert started == 8
release.set()
results = await asyncio.gather(*calls)
- assert all(not result.isError for result in results)
+ assert all(not result.is_error for result in results)
assert maximum == 8
data = json.loads(results[0].content[0].text)
assert data["result"]["status"] == "complete"
bad = await first.call_tool("internet_search", {"query": "a", "cn_only": True})
- assert bad.isError
+ assert bad.is_error
assert not clients[0].is_closed
assert clients[0].is_closed
@@ -418,14 +419,6 @@ def test_round_robin_caps_at_eight_sources():
def test_search_transport_allows_structured_deadline_result():
- from core.llm.mcp.pool import make_client
-
- class Client:
- def __init__(self, **kwargs):
- self.kwargs = kwargs
-
- cfg = {"transport": "streamable_http", "url": "http://localhost:9102/mcp"}
- client = make_client("internet_search", cfg, client_cls=Client)
- assert client.kwargs["mcp_config"].timeout == 50.0
- client = make_client("internet_search", {**cfg, "transport_timeout": 12}, client_cls=Client)
- assert client.kwargs["mcp_config"].timeout == 12.0
+ from core.llm.mcp.client import _http_transport_timeout
+ assert _http_transport_timeout("internet_search", {}, 120) == 50.0
+ assert _http_transport_timeout("internet_search", {"transport_timeout": 12}, 120) == 12.0
diff --git a/src/backend/tests/memory/outbox_test_support.py b/src/backend/tests/memory/outbox_test_support.py
index 9ab0a514..788fbeb0 100644
--- a/src/backend/tests/memory/outbox_test_support.py
+++ b/src/backend/tests/memory/outbox_test_support.py
@@ -24,6 +24,7 @@ def _configure_db(db_session, monkeypatch):
monkeypatch.setattr(outbox_common, "SessionLocal", factory)
monkeypatch.setattr(E, "SessionLocal", factory)
monkeypatch.setattr(PS, "SessionLocal", factory)
+ monkeypatch.setattr("core.db.engine.SessionLocal", factory)
return factory
diff --git a/src/backend/tests/memory/test_mem0_real_lifecycle.py b/src/backend/tests/memory/test_mem0_real_lifecycle.py
index e2fb2adf..a5a51336 100644
--- a/src/backend/tests/memory/test_mem0_real_lifecycle.py
+++ b/src/backend/tests/memory/test_mem0_real_lifecycle.py
@@ -182,6 +182,7 @@ async def test_durable_foreign_actor_replay_cannot_modify_owned_memory(
from core.memory.access import authorized_memory_context
monkeypatch.setattr(common, "SessionLocal", sessionmaker(bind=db_session.get_bind()))
+ monkeypatch.setattr("core.db.engine.SessionLocal", sessionmaker(bind=db_session.get_bind()))
ident = await service.save_procedure_entry(ctx=context(), content="Keep original authority")
assert ident
assert await authorized_memory_context(ident, context().user_id)
diff --git a/src/backend/tests/orchestration/automation_test_support.py b/src/backend/tests/orchestration/automation_test_support.py
new file mode 100644
index 00000000..5de1de04
--- /dev/null
+++ b/src/backend/tests/orchestration/automation_test_support.py
@@ -0,0 +1,17 @@
+"""Exercise the scheduler through durable acceptance, not a private executor."""
+import asyncio
+from core.db import engine
+from features.automation.service import AutomationService
+
+
+def request(task_id, user_id):
+ with engine.SessionLocal() as db:
+ service = AutomationService(db)
+ service.request_manual_trigger(service.get_task(task_id, user_id))
+
+
+async def execute_queued(scheduler, task_id, user_id):
+ await asyncio.to_thread(request, task_id, user_id)
+ before = set(scheduler._executions)
+ await scheduler._dispatch_pending()
+ await asyncio.gather(*(scheduler._executions - before))
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 8b551bee..40071194 100644
--- a/src/backend/tests/orchestration/streaming_tool_call_dedupe_selftest.py
+++ b/src/backend/tests/orchestration/streaming_tool_call_dedupe_selftest.py
@@ -11,7 +11,7 @@
"""
from __future__ import annotations
-from core.llm.runtime import streaming_state as streaming_state_module
+from core.llm.runtime import streaming_constants as streaming_state_module
import asyncio
import json
@@ -49,13 +49,14 @@ async def reply_stream(inputs=None): # noqa: ANN001, ARG001
chat_id="dedupe_case",
apply_request_context=lambda ctx, text: None,
context=SimpleNamespace(extend=lambda messages: None),
+ pending_mcp_progress=[],
)
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_state_module.TOOL_CALL_DELTA_FLUSH_CHARS = 256
- streaming_state_module.TOOL_CALL_DELTA_FLUSH_INTERVAL_S = 999.0
+ streaming_state_module.DELTA_FLUSH_CHARS = 256
+ streaming_state_module.DELTA_FLUSH_INTERVAL_S = 999.0
output: list[tuple[str, Any]] = []
streamer = StreamingAgent(agent, mcp_clients=[])
@@ -82,7 +83,8 @@ def main() -> int:
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
+ # The first fragment goes out at once; the rest are merged 256 characters at a time.
+ max_expected_deltas = 1 + (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"
diff --git a/src/backend/tests/orchestration/test_agent_runtime_resources.py b/src/backend/tests/orchestration/test_agent_runtime_resources.py
new file mode 100644
index 00000000..1efdf2f6
--- /dev/null
+++ b/src/backend/tests/orchestration/test_agent_runtime_resources.py
@@ -0,0 +1,20 @@
+"""Native MCP cleanup must run even when stopping the producer fails."""
+import asyncio
+from types import SimpleNamespace
+import pytest
+from core.llm.runtime.streaming import StreamingAgent
+
+@pytest.mark.asyncio
+async def test_shutdown_releases_all_resources_when_producer_cancelled():
+ closed = []
+ class Resource:
+ async def close(self):
+ closed.append(self)
+ async def stop():
+ raise asyncio.CancelledError()
+ clients = [Resource(), Resource()]
+ agent = SimpleNamespace(stop_producer=stop, mcp_clients=clients)
+ with pytest.raises(asyncio.CancelledError):
+ await StreamingAgent.shutdown(agent)
+ assert closed == clients
+ assert agent.mcp_clients == []
diff --git a/src/backend/tests/orchestration/test_agentscope_hook_adapter.py b/src/backend/tests/orchestration/test_agentscope_hook_adapter.py
index 6caa126a..cd815808 100644
--- a/src/backend/tests/orchestration/test_agentscope_hook_adapter.py
+++ b/src/backend/tests/orchestration/test_agentscope_hook_adapter.py
@@ -10,6 +10,7 @@
from agentscope.middleware import MiddlewareBase
from agentscope.tool._response import ToolResponse
from core.harness.hooks import Decision, HookBus, HookContractError, HookSpec, HookStage
+from core.harness.observation_writer import flush_observations
from core.harness.usage import MemoryUsageRecorder
from core.llm.runtime.agentscope_hook_adapter import AgentScopeHookAdapter, adapt_agentscope_event
from core.llm.middleware.migration_map import MIDDLEWARE_MIGRATION, deletion_checklist
@@ -237,6 +238,7 @@ async def handler(**kwargs):
events = [item async for item in adapter.on_reply(agent, {"inputs": None}, handler)]
assert len(events) == 1
+ assert flush_observations()
rows = usage.attempts("run-adapter")
assert [(row.kind, row.operation_name, row.status) for row in rows] == [
("hook", "reply-policy", "success")
diff --git a/src/backend/tests/orchestration/test_automation_admission.py b/src/backend/tests/orchestration/test_automation_admission.py
index 241718a6..6a4e3301 100644
--- a/src/backend/tests/orchestration/test_automation_admission.py
+++ b/src/backend/tests/orchestration/test_automation_admission.py
@@ -1,4 +1,5 @@
"""Admission cleanup for scheduled conversations."""
+from tests.orchestration.automation_test_support import execute_queued
import asyncio
@@ -30,7 +31,7 @@ async def noop(*args):
return None
monkeypatch.setattr(scheduler, "_send_notification", noop)
- await scheduler.execute_task(task_id, "owner")
+ await execute_queued(scheduler, task_id, "owner")
with factory() as db:
run = db.get(ChatRun, accepted_id)
assert run.status == "failed"
@@ -58,7 +59,7 @@ async def noop(*args):
return None
with pytest.raises(asyncio.CancelledError):
- await scheduler.execute_task(task_id, "owner")
+ await execute_queued(scheduler, task_id, "owner")
with factory() as db:
run = db.get(ChatRun, accepted_id)
assert run.status == "failed"
@@ -90,7 +91,7 @@ async def noop(*args):
return None
monkeypatch.setattr(scheduler, "_send_notification", noop)
- await scheduler.execute_task(task_id, "owner")
+ await execute_queued(scheduler, task_id, "owner")
assert captured["enabled_agents"] == ["analyst"]
assert captured["enabled_skills"] == ["research"]
assert captured["enabled_mcps"] == ["search"]
@@ -105,3 +106,29 @@ async def noop(*args):
assert payload["chat_mode"] == captured["chat_mode"]
assert payload["enable_thinking"] == captured["enable_thinking"]
assert captured["run_id"] and captured["message_id"]
+
+
+async def test_child_acceptance_already_contains_parent_link_and_recovery(live_env, monkeypatch):
+ from core.db.models import ChatRun
+ from features.automation.models import ScheduledTaskRun
+ from orchestration.automations import automation_conversation
+
+ factory, _, task_id = live_env
+ observed = []
+
+ async def crash_before_launch(**kwargs):
+ child_id = kwargs["accepted_run"].run_id
+ with factory() as db:
+ child = db.get(ChatRun, child_id)
+ parent = db.query(ScheduledTaskRun).filter_by(task_id=task_id).one()
+ assert child.status == "pending"
+ assert parent.chat_id == child.chat_id
+ assert child.recovery_snapshot["kind"] == "chat"
+ assert child.recovery_snapshot["worker_args"]["effective_user_message"] == "Research"
+ assert child.recovery_snapshot["worker_args"]["context"]["automation_run"] is True
+ observed.append(child_id)
+ raise RuntimeError("worker died after durable acceptance")
+
+ monkeypatch.setattr(automation_conversation.admission, "start_run", crash_before_launch)
+ await execute_queued(AutomationScheduler(), task_id, "owner")
+ assert len(observed) == 1
diff --git a/src/backend/tests/orchestration/test_automation_handoff.py b/src/backend/tests/orchestration/test_automation_handoff.py
index c32b7c64..d5b57b71 100644
--- a/src/backend/tests/orchestration/test_automation_handoff.py
+++ b/src/backend/tests/orchestration/test_automation_handoff.py
@@ -1,4 +1,5 @@
"""Late user instructions remain part of a scheduled conversation's outcome."""
+from tests.orchestration.automation_test_support import execute_queued
import asyncio
@@ -46,7 +47,7 @@ async def noop(*args):
return None
monkeypatch.setattr(scheduler, "_send_notification", noop)
- executing = asyncio.create_task(scheduler.execute_task(task_id, "owner"))
+ executing = asyncio.create_task(execute_queued(scheduler, task_id, "owner"))
try:
await asyncio.wait_for(entered.wait(), 5)
await asyncio.sleep(0.2)
@@ -70,6 +71,9 @@ async def noop(*args):
if outcome == "complete":
assert scheduled.status == "success"
assert scheduled.result_summary == "Revised final result"
+ elif outcome == "shutdown":
+ assert scheduled.status == "running"
+ assert runtime.get_run(runs[1]).status == "running"
else:
assert scheduled.status == "failed"
assert runtime.get_run(runs[1]).status == "cancelled"
@@ -82,7 +86,7 @@ async def noop(*args):
@pytest.mark.asyncio
-async def test_shutdown_cancels_successor_when_completion_wins_cancel_race(live_env, monkeypatch):
+async def test_shutdown_does_not_cancel_executor_or_successor(live_env, monkeypatch):
factory, _, task_id = live_env
source_entered, finish_source = asyncio.Event(), asyncio.Event()
successor_entered, finish_successor = asyncio.Event(), asyncio.Event()
@@ -123,15 +127,17 @@ async def completion_wins_cancel(run_id, *, user_id, **kwargs):
async def noop(*args):
return None
- executing = asyncio.create_task(scheduler.execute_task(task_id, "owner"))
+ executing = asyncio.create_task(execute_queued(scheduler, task_id, "owner"))
try:
await asyncio.wait_for(source_entered.wait(), 5)
executing.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(executing, 5)
- assert cancelled == runs
- assert runtime.get_run(runs[1]).status == "cancelled"
- assert runtime.get_active_run_for_chat(runtime.get_run(runs[0]).chat_id, "owner") is None
+ assert cancelled == []
+ assert runtime.get_run(runs[0]).status == "running"
+ finish_source.set()
+ await asyncio.wait_for(successor_entered.wait(), 5)
+ assert runtime.get_run(runs[1]).status == "running"
finally:
finish_source.set()
finish_successor.set()
diff --git a/src/backend/tests/orchestration/test_automation_live_conversations.py b/src/backend/tests/orchestration/test_automation_live_conversations.py
index 9507a911..2890a050 100644
--- a/src/backend/tests/orchestration/test_automation_live_conversations.py
+++ b/src/backend/tests/orchestration/test_automation_live_conversations.py
@@ -1,4 +1,5 @@
"""Scheduled runs expose the same live conversation boundary as interactive runs."""
+from tests.orchestration.automation_test_support import execute_queued
from core.chat.projection.codec import chat_projection_codec
import asyncio
@@ -81,7 +82,7 @@ async def noop(*args):
return None
monkeypatch.setattr(scheduler, "_send_notification", noop)
- executing = asyncio.create_task(scheduler.execute_task(task_id, "owner"))
+ executing = asyncio.create_task(execute_queued(scheduler, task_id, "owner"))
try:
await asyncio.wait_for(entered.wait(), 5)
with factory() as db:
@@ -140,7 +141,7 @@ async def notify(*args):
notices.append(args)
monkeypatch.setattr(scheduler, "_send_notification", notify)
- executing = asyncio.create_task(scheduler.execute_task(task_id, "owner"))
+ executing = asyncio.create_task(execute_queued(scheduler, task_id, "owner"))
try:
await asyncio.wait_for(entered.wait(), 5)
with factory() as db:
@@ -191,7 +192,7 @@ async def noop(*args):
return None
monkeypatch.setattr(scheduler, "_send_notification", noop)
- executing = asyncio.create_task(scheduler.execute_task(task_id, "owner"))
+ executing = asyncio.create_task(execute_queued(scheduler, task_id, "owner"))
try:
await asyncio.sleep(0.6)
assert not executing.done()
@@ -253,7 +254,7 @@ async def noop(*args):
return None
monkeypatch.setattr(scheduler, "_send_notification", noop)
- executing = asyncio.create_task(scheduler.execute_task(task_id, "owner"))
+ executing = asyncio.create_task(execute_queued(scheduler, task_id, "owner"))
try:
await asyncio.wait_for(entered.wait(), 5)
with factory() as db:
@@ -293,10 +294,11 @@ async def unlock(task_id):
def fail(*args, **kwargs):
raise RuntimeError("database unavailable")
- monkeypatch.setattr(AutomationService, "record_run_start", fail)
+ monkeypatch.setattr("features.automation.admission.admit", fail)
with pytest.raises(RuntimeError, match="database unavailable"):
- await scheduler.execute_task(task_id, "owner")
- assert released == [task_id]
+ await execute_queued(scheduler, task_id, "owner")
+ assert released == []
+ assert not scheduler._leases
@pytest.mark.asyncio
@@ -333,7 +335,7 @@ async def noop(*args):
return None
monkeypatch.setattr(scheduler, "_send_notification", noop)
- executing = asyncio.create_task(scheduler.execute_task(task_id, "owner"))
+ executing = asyncio.create_task(execute_queued(scheduler, task_id, "owner"))
await asyncio.wait_for(entered.wait(), 5)
with factory() as db:
chat_id = AutomationService(db).get_task_runs(task_id, "owner")[0].chat_id
@@ -348,7 +350,7 @@ async def noop(*args):
@pytest.mark.asyncio
-async def test_stopping_scheduler_fences_already_started_conversation(live_env, monkeypatch):
+async def test_stopping_scheduler_preserves_durable_child_for_recovery(live_env, monkeypatch):
factory, _, task_id = live_env
entered = asyncio.Event()
@@ -363,16 +365,19 @@ async def workflow(**kwargs):
async def noop(*args):
return None
- await scheduler.start()
- scheduler._launch_execution(task_id, "owner")
+ from tests.orchestration.automation_test_support import request
+ request(task_id, "owner")
+ await scheduler._dispatch_pending()
await asyncio.wait_for(entered.wait(), 5)
await asyncio.wait_for(scheduler.stop(), 5)
assert not scheduler._executions
with factory() as db:
run = AutomationService(db).get_task_runs(task_id, "owner")[0]
- assert run.status == "failed"
+ assert run.status == "running"
assert run.chat_id
- assert runtime.get_active_run_for_chat(run.chat_id, "owner") is None
+ active = runtime.get_active_run_for_chat(run.chat_id, "owner")
+ assert active is not None
+ await runtime.cancel_run(active.run_id, user_id="owner")
@pytest.mark.asyncio
@@ -444,7 +449,7 @@ async def noop(*args):
return None
monkeypatch.setattr(scheduler, "_send_notification", noop)
- executing = asyncio.create_task(scheduler.execute_task(task_id, "owner"))
+ executing = asyncio.create_task(execute_queued(scheduler, task_id, "owner"))
try:
await asyncio.wait_for(reached.wait(), 5)
with factory() as db:
diff --git a/src/backend/tests/orchestration/test_automation_unbounded.py b/src/backend/tests/orchestration/test_automation_unbounded.py
index 66592b1c..1df84eeb 100644
--- a/src/backend/tests/orchestration/test_automation_unbounded.py
+++ b/src/backend/tests/orchestration/test_automation_unbounded.py
@@ -1,4 +1,6 @@
"""Long automation runs retain ownership without runtime-based recovery."""
+from tests.orchestration.automation_test_support import execute_queued
+from tests.features.automation.run_fixtures import running_run
import asyncio
from datetime import timedelta
@@ -88,21 +90,21 @@ async def test_recovery_uses_ownership_instead_of_run_age(live_env):
factory, _, task_id = live_env
with factory() as db:
svc = AutomationService(db)
- old = svc.record_run_start(task_id)
+ old = running_run(db, task_id)
old.started_at = utc_now() - timedelta(days=3)
db.commit()
owner, recovering = AutomationScheduler(), AutomationScheduler()
assert await owner._acquire_lock(task_id)
try:
- await recovering._recover_stuck_running_runs()
+ await recovering._dispatch_pending()
with factory() as db:
assert AutomationService(db).get_task_runs(task_id, "owner")[0].status == "running"
finally:
await owner._release_lock(task_id)
# Even a newly started orphan is recoverable once ownership is absent.
with factory() as db:
- AutomationService(db).record_run_start(task_id)
- await recovering._recover_stuck_running_runs()
+ running_run(db, task_id)
+ await recovering._dispatch_pending()
await asyncio.gather(*recovering._executions)
with factory() as db:
runs = AutomationService(db).get_task_runs(task_id, "owner")
@@ -122,10 +124,10 @@ async def test_missed_one_shot_cannot_bypass_another_workers_lock(live_env):
owner, recovering = AutomationScheduler(), AutomationScheduler()
assert await owner._acquire_lock(task_id)
try:
- await recovering._recover_missed_tasks()
+ await recovering._dispatch_pending()
assert not recovering._executions
with factory() as db:
- assert AutomationService(db).get_task_runs(task_id, "owner") == []
+ assert AutomationService(db).get_task_runs(task_id, "owner")[0].status == "pending"
finally:
await owner._release_lock(task_id)
@@ -137,16 +139,16 @@ async def test_direct_execution_on_same_scheduler_cannot_share_ownership(live_en
entered, release = asyncio.Event(), asyncio.Event()
calls = []
- async def dispatch(*args):
+ async def dispatch(*args, **kwargs):
calls.append(args)
entered.set()
await release.wait()
monkeypatch.setattr('orchestration.automations.automation_dispatch.execute_task', dispatch)
- first = asyncio.create_task(scheduler.execute_task(task_id, "owner"))
+ first = asyncio.create_task(execute_queued(scheduler, task_id, "owner"))
try:
await entered.wait()
- await scheduler.execute_task(task_id, "owner")
+ await execute_queued(scheduler, task_id, "owner")
assert len(calls) == 1
finally:
release.set()
@@ -171,7 +173,7 @@ async def workflow(**kwargs):
monkeypatch.setattr(runner, "astream_chat_workflow", workflow)
with factory() as db:
- run_id = AutomationService(db).record_run_start(task_id).run_id
+ run_id = running_run(db, task_id).run_id
child_observer = asyncio.create_task(
execute_prompt(
user_id="owner",
@@ -192,10 +194,10 @@ async def noop(*args):
monkeypatch.setattr(scheduler, "_send_notification", noop)
try:
await asyncio.wait_for(entered.wait(), 5)
- await scheduler._recover_stuck_running_runs()
+ await scheduler._dispatch_pending()
await asyncio.sleep(0.1)
assert scheduler._executions
- await scheduler.execute_task(task_id, "owner")
+ await execute_queued(scheduler, task_id, "owner")
assert len(calls) == 1
release.set()
await asyncio.wait_for(asyncio.gather(*scheduler._executions), 5)
@@ -224,7 +226,7 @@ async def workflow(**kwargs):
monkeypatch.setattr(runner, "astream_chat_workflow", workflow)
with factory() as db:
- run_id = AutomationService(db).record_run_start(task_id).run_id
+ run_id = running_run(db, task_id).run_id
chat_id, _, _ = await execute_prompt(
user_id="owner",
task_name="Report",
@@ -264,7 +266,7 @@ async def notify(*args):
notices.append(args)
monkeypatch.setattr(scheduler, "_send_notification", notify)
- await scheduler._recover_stuck_running_runs()
+ await scheduler._dispatch_pending()
await asyncio.wait_for(asyncio.gather(*scheduler._executions), 5)
with factory() as db:
runs = AutomationService(db).get_task_runs(task_id, "owner")
@@ -278,7 +280,9 @@ async def test_stop_releases_lease_even_before_execution_coroutine_starts(live_e
scheduler = AutomationScheduler()
assert await scheduler._acquire_lock(task_id)
lease = scheduler._leases[task_id]
- scheduler._launch_execution(task_id, "owner", lease=lease)
+ task = asyncio.create_task(scheduler.execute_task(task_id))
+ scheduler._executions.add(task)
+ task.add_done_callback(scheduler._executions.discard)
await scheduler.stop()
assert scheduler._leases == {}
assert lease.renewal.done()
diff --git a/src/backend/tests/orchestration/test_background_commands.py b/src/backend/tests/orchestration/test_background_commands.py
index 2c24a4fc..c178da7b 100644
--- a/src/backend/tests/orchestration/test_background_commands.py
+++ b/src/backend/tests/orchestration/test_background_commands.py
@@ -118,9 +118,12 @@ async def test_real_process_background_concurrency_and_cancel(command_db, http_p
monkeypatch.setattr("core.config.local_mode.local_mode_enabled", lambda: False)
class Tools:
def __init__(self): self.functions = {}
- def register_tool_function(self, fn, **_): self.functions[fn.__name__] = fn
+ def append(self, tool):
+ fn = tool._func
+ _ = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
+ self.functions[fn.__name__] = fn
tools = Tools()
- register_bash(tools, loader=None, loaded_skill_ids=set(), chat_id="c", user_id="u")
+ register_bash(tools, loader=None, chat_id="c", user_id="u")
def data(response): return json.loads(response.content[0].text)
marker = tmp_path / "launches"
command = f"printf x >> '{marker}'; printf first; sleep 1; printf second"
@@ -196,6 +199,8 @@ async def test_busy_react_delivery_and_final_output_race(command_db):
class Model(ChatModelBase):
def __init__(self):
super().__init__(None, "deterministic", self.Parameters(), stream=False, max_retries=0)
+ from agentscope.formatter import OpenAIChatFormatter
+ self.formatter = OpenAIChatFormatter()
async def count_tokens(self, **kwargs): return 10
async def _call_api(self, model_name, messages, **kwargs):
seen.append([str(m.content) for m in messages])
diff --git a/src/backend/tests/orchestration/test_chat_resume_checkpoint.py b/src/backend/tests/orchestration/test_chat_resume_checkpoint.py
index 5f79349c..9933bfb2 100644
--- a/src/backend/tests/orchestration/test_chat_resume_checkpoint.py
+++ b/src/backend/tests/orchestration/test_chat_resume_checkpoint.py
@@ -72,7 +72,7 @@ async def absent_confirmation(*_):
with recovery_env() as db:
response = run_views.chat_run_subscription(
"run-trimmed",
- user=UserContext(user_id="user-1", user_center_id="center-1", username="tester"), db=db,
+ user=UserContext(user_id="user-1", user_center_id="center-1", username="tester"),
)
iterator = response.body_iterator
try:
diff --git a/src/backend/tests/orchestration/test_chat_run_recovery.py b/src/backend/tests/orchestration/test_chat_run_recovery.py
index 1503e0bb..ae4f030b 100644
--- a/src/backend/tests/orchestration/test_chat_run_recovery.py
+++ b/src/backend/tests/orchestration/test_chat_run_recovery.py
@@ -249,6 +249,8 @@ async def exploding_probe(value: int):
raise DeveloperOrientedException("adapter response was lost")
class ToolModel:
+ from agentscope.formatter import OpenAIChatFormatter
+ formatter = OpenAIChatFormatter()
model = "ambiguous-tool"
context_size = 32768
diff --git a/src/backend/tests/orchestration/test_command_events.py b/src/backend/tests/orchestration/test_command_events.py
index ff3b9dbf..d68234c7 100644
--- a/src/backend/tests/orchestration/test_command_events.py
+++ b/src/backend/tests/orchestration/test_command_events.py
@@ -180,7 +180,7 @@ async def test_real_http_sse_is_authenticated_and_pushes_without_polling(command
monkeypatch.setattr(commands_subscription, "get_command_events", lambda: bus)
monkeypatch.setattr(commands_subscription, "HEARTBEAT_SECONDS", .03)
revoked = False
- async def authenticated(req, credentials, db):
+ async def authenticated(req, credentials):
if credentials is None or revoked:
raise HTTPException(401, "Authentication expired")
return SimpleNamespace(user_id=credentials.credentials,
@@ -293,6 +293,6 @@ def test_followup_active_run_inherits_execution_mode(command_db, monkeypatch, mo
assert response["data"]["kind"] == "chat"
from api.routes.v1.chats.run_views import chat_run_subscription
native = chat_run_subscription(followup_id,
- user=UserContext(user_id="u", user_center_id="u", username="test"), db=db)
+ user=UserContext(user_id="u", user_center_id="u", username="test"))
assert native.status_code == 200
assert native.media_type == "text/event-stream"
diff --git a/src/backend/tests/orchestration/test_command_recovery.py b/src/backend/tests/orchestration/test_command_recovery.py
index 825217cf..6537e3a5 100644
--- a/src/backend/tests/orchestration/test_command_recovery.py
+++ b/src/backend/tests/orchestration/test_command_recovery.py
@@ -251,7 +251,7 @@ def test_command_sideband_does_not_cross_chat_or_user(command_db):
def test_active_completion_context_retains_harness_provenance():
from core.llm.context.adapter import AgentScopeContextAdapter
- from core.llm.context.ir_state import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
+ from core.llm.context.ir_constants import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
from core.sandbox.commands.context import completion_context
message = completion_context({"task_id":"cmd_event", "status":"completed", "exit_code":0})
assert SESSION_CONTEXT_META_KEY in message
@@ -266,7 +266,7 @@ def test_active_completion_context_retains_harness_provenance():
def test_idle_completion_message_retains_harness_provenance(command_db):
from core.llm.context.adapter import AgentScopeContextAdapter
- from core.llm.context.ir_state import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
+ from core.llm.context.ir_constants import KIND_REMINDER, POLICY_NEVER, SESSION_CONTEXT_META_KEY
from core.chat.compaction.history import normalize_rows
with command_db() as db:
source = ChatSequencer(db).accept_main_run(chat_id="c", user_id="u",
@@ -290,3 +290,81 @@ def test_idle_completion_message_retains_harness_provenance(command_db):
assert item.trust == "system"
assert item.truncation_policy == POLICY_NEVER
assert item.priority == 950
+
+
+async def test_observation_failure_preserves_cancellation_control(command_db):
+ polls = 0
+ async def poll(pid, **kwargs):
+ nonlocal polls
+ polls += 1
+ if polls == 1:
+ raise TimeoutError("transient output read")
+ if kwargs["chars"]:
+ return {"status": "exited", "exit_code": 130}
+ return {"status": "running", "session_id": "p"}
+ provider = SimpleNamespace(name="test", touch_session=AsyncMock(),
+ start_process=AsyncMock(return_value={"status":"running","session_id":"p"}),
+ write_stdin=poll)
+ owner = CommandRuntime()
+ result = await owner.start(provider, request(), chat_id="c", source_run_id=None, background=True)
+ await asyncio.sleep(0.1)
+ assert store.snapshot(result["task_id"])["status"] == "running"
+ await owner.cancel(result["task_id"], "u", "c")
+ assert store.snapshot(result["task_id"])["status"] == "cancelled"
+ assert provider.start_process.await_count == 1
+ await owner.shutdown()
+
+async def test_failed_stop_keeps_shared_ownership_across_workers(command_db, monkeypatch):
+ monkeypatch.setattr(store, "LEASE_SECONDS", 0.3)
+ can_stop = False
+ async def poll(pid, **kwargs):
+ if can_stop and kwargs["chars"]:
+ return {"status": "exited", "exit_code": 130}
+ raise TimeoutError("process still alive; stop RPC unavailable")
+ provider = SimpleNamespace(name="test", touch_session=AsyncMock(),
+ start_process=AsyncMock(return_value={"status": "running", "session_id": "p"}),
+ write_stdin=poll)
+ owner, other = CommandRuntime(), CommandRuntime()
+ result = await owner.start(provider, request(), chat_id="c", source_run_id=None, background=True)
+ tid = result["task_id"]
+ # Force the observer down its failed-stop path, retaining the process handle.
+ task = owner.tasks[tid]
+ await asyncio.sleep(0.02)
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ for _ in range(20):
+ await owner.recover(provider)
+ await other.recover(provider)
+ await asyncio.sleep(0.04)
+ row = store.snapshot(tid)
+ assert row["status"] == "running"
+ assert row["owner"] == owner.owner
+ assert tid in owner.providers
+ can_stop = True
+ await owner.recover(provider)
+ await asyncio.sleep(0.1)
+ assert store.snapshot(tid)["status"] == "failed"
+ assert tid not in owner.providers
+ await owner.shutdown()
+ await other.shutdown()
+
+async def test_lost_owner_must_stop_local_process_before_forgetting_handle(command_db):
+ stopped = asyncio.Event()
+ provider = SimpleNamespace(name="test", touch_session=AsyncMock(),
+ start_process=AsyncMock(return_value={"status": "running", "session_id": "p"}))
+ async def poll(pid, **kwargs):
+ assert kwargs["chars"] == chr(3)
+ stopped.set()
+ return {"status": "exited", "exit_code": 130}
+ provider.write_stdin = poll
+ owner = CommandRuntime()
+ result = await owner.start(provider, request(), chat_id="c", source_run_id=None, background=True)
+ tid = result["task_id"]
+ with command_db() as db:
+ row = db.get(models.CommandTask, tid)
+ row.owner, row.status = "successor", "unknown"
+ db.commit()
+ await asyncio.wait_for(stopped.wait(), 2)
+ await asyncio.sleep(0.05)
+ assert tid not in owner.providers
+ await owner.shutdown()
diff --git a/src/backend/tests/orchestration/test_command_worker_lifecycle.py b/src/backend/tests/orchestration/test_command_worker_lifecycle.py
index 53cca27e..9230c76f 100644
--- a/src/backend/tests/orchestration/test_command_worker_lifecycle.py
+++ b/src/backend/tests/orchestration/test_command_worker_lifecycle.py
@@ -73,7 +73,7 @@ async def test_managed_read_never_holds_model_for_long_wait(command_db, monkeypa
monkeypatch.setattr(process_state(), "commands", instance)
monkeypatch.setenv("SANDBOX_TOOLS_ENABLED", "true")
tools = Toolkit()
- register_bash(tools, loader=None, loaded_skill_ids=set(), chat_id="c", user_id="u")
+ register_bash(tools, loader=None, chat_id="c", user_id="u")
await tools.functions["write_stdin"](row["task_id"], yield_time_ms=100000)
instance.wait.assert_awaited_once_with(row["task_id"], "u", "c", 3000)
instance.wait.reset_mock()
diff --git a/src/backend/tests/orchestration/test_context_provenance.py b/src/backend/tests/orchestration/test_context_provenance.py
index 1a3f9d76..c8d7931d 100644
--- a/src/backend/tests/orchestration/test_context_provenance.py
+++ b/src/backend/tests/orchestration/test_context_provenance.py
@@ -3,7 +3,7 @@
from core.agent_runtime.skills import _build_skill_injection
from core.llm.context.adapter import AgentScopeContextAdapter
from core.llm.context.rendering import render_session_input
-from core.llm.context.ir_state import SESSION_CONTEXT_META_KEY
+from core.llm.context.ir_constants import SESSION_CONTEXT_META_KEY
from core.llm.runtime.message_compat import session_to_msgs
from orchestration.plans.mode import _build_plan_generation_messages
diff --git a/src/backend/tests/orchestration/test_execution_manifest_binding.py b/src/backend/tests/orchestration/test_execution_manifest_binding.py
index 50307408..27c625fb 100644
--- a/src/backend/tests/orchestration/test_execution_manifest_binding.py
+++ b/src/backend/tests/orchestration/test_execution_manifest_binding.py
@@ -13,7 +13,6 @@
from types import SimpleNamespace
import core.agent_runtime.integrations.memory as memory
-import core.agent_runtime.resources as resources
import core.agent_runtime.runner as runner
import core.agent_runtime.targets as targets
import core.capabilities.catalog.resolver as catalog_resolver
@@ -99,7 +98,6 @@ async def no_compaction(_chat_id, messages, **_kwargs):
monkeypatch.setattr(catalog_resolver, "enabled_kb_ids_from_context", lambda _ctx: [])
monkeypatch.setattr(targets, "_resolve_mode_spec", lambda _ctx: None)
monkeypatch.setattr(streaming_module, 'StreamingAgent', FakeStreamingAgent)
- monkeypatch.setattr(resources.process_state(), 'persistent_clients', [])
stream = runner.astream_chat_workflow(
session_messages=[{"role": "user", "content": "continue"}],
@@ -327,6 +325,8 @@ def get_skill_dir(self, *_args, **_kwargs):
return None
class CaptureModel:
+ from agentscope.formatter import OpenAIChatFormatter
+ formatter = OpenAIChatFormatter()
model = "capture-model"
context_size = 32768
def __init__(self):
diff --git a/src/backend/tests/orchestration/test_harness_usage_ledger.py b/src/backend/tests/orchestration/test_harness_usage_ledger.py
index 38bee51a..e00e379c 100644
--- a/src/backend/tests/orchestration/test_harness_usage_ledger.py
+++ b/src/backend/tests/orchestration/test_harness_usage_ledger.py
@@ -193,6 +193,28 @@ async def _call_api_with_structured_output(self, model_name, **kwargs):
assert structured_rows[0].usage == AttemptUsage(7, 3)
+def test_attempt_batch_reserves_consecutive_sequences_in_one_write(ledger_env):
+ ledger = HarnessUsageLedger(ledger_env)
+ ledger.record_attempt(
+ UsageAttempt(run_id="run-ledger", kind="model", operation_name="m",
+ status="success", latency_ms=1)
+ )
+ batch = ledger.record_attempts(
+ UsageAttempt(run_id="run-ledger", kind="hook", operation_name=name,
+ status="success", latency_ms=1)
+ for name in ("a", "b", "c")
+ )
+ assert [row.attempt_seq for row in batch] == [2, 3, 4]
+ assert [row.operation_name for row in ledger.attempts("run-ledger")] == ["m", "a", "b", "c"]
+ with pytest.raises(ValueError):
+ ledger.record_attempts([
+ UsageAttempt(run_id="run-ledger", kind="hook", operation_name="x",
+ status="success", latency_ms=1),
+ UsageAttempt(run_id="other", kind="hook", operation_name="y",
+ status="success", latency_ms=1),
+ ])
+
+
@pytest.mark.asyncio
async def test_durable_event_store_allocates_sequence_before_observers(ledger_env):
sink = EventSink(DurableEventStore(ledger_env))
diff --git a/src/backend/tests/orchestration/test_hook_bus.py b/src/backend/tests/orchestration/test_hook_bus.py
index af49eb23..12ad053c 100644
--- a/src/backend/tests/orchestration/test_hook_bus.py
+++ b/src/backend/tests/orchestration/test_hook_bus.py
@@ -10,6 +10,7 @@
import pytest
from core.harness.events import Event, EventSink
+from core.harness.observation_writer import flush_observations
from core.harness.hooks import (
Decision,
DecisionKind,
@@ -255,6 +256,7 @@ async def test_hook_external_usage_is_recorded_as_its_own_attempt():
data={"model": "m", "messages": [], "parameters": {}},
)
)
+ assert flush_observations()
rows = usage.attempts("run-usage")
assert len(rows) == 1
assert rows[0].kind == "hook"
@@ -303,3 +305,99 @@ async def short_watchdog(coro, timeout):
with pytest.raises(asyncio.CancelledError):
await task
assert cancelled.is_set()
+
+
+@pytest.mark.asyncio
+async def test_sync_handlers_run_inline_without_executor_or_timeout(monkeypatch):
+ import threading
+
+ async def saturated(*args, **kwargs):
+ raise AssertionError("sync hooks must not wait for the default executor")
+
+ loop_thread = threading.get_ident()
+ seen = []
+
+ def inline(invocation):
+ seen.append(threading.get_ident())
+ return Decision()
+
+ bus = HookBus()
+ bus.register(HookSpec("durable", HookStage.BEFORE_TOOL, inline, timeout_ms=1))
+ monkeypatch.setattr(asyncio, "to_thread", saturated)
+ outcome = await bus.run(
+ Invocation.create(
+ run_id="run-inline",
+ stage=HookStage.BEFORE_TOOL,
+ operation_name="write",
+ )
+ )
+ assert outcome.decision.kind == DecisionKind.CONTINUE
+ assert seen == [loop_thread]
+
+
+@pytest.mark.asyncio
+async def test_stage_writes_one_event_and_one_usage_batch():
+ class BatchRecorder(MemoryUsageRecorder):
+ def __init__(self):
+ super().__init__()
+ self.batches = []
+
+ def record_attempts(self, attempts):
+ self.batches.append(len(attempts))
+ return super().record_attempts(attempts)
+
+ usage = BatchRecorder()
+ bus = HookBus(usage_recorder=usage)
+ for name, priority in (("one", 10), ("two", 20), ("three", 30)):
+ bus.register(HookSpec(name, HookStage.TRANSFORM_CONTEXT, lambda invocation: None,
+ priority=priority))
+ await bus.run(
+ Invocation.create(
+ run_id="run-batch",
+ stage=HookStage.TRANSFORM_CONTEXT,
+ operation_name="agent_reasoning",
+ )
+ )
+ events = bus.event_sink.events()
+ assert [event.event_type for event in events] == ["hook_stage_finished"]
+ assert flush_observations()
+ assert [item["hook"] for item in events[0].payload["attempts"]] == ["one", "two", "three"]
+ assert usage.batches == [3]
+ assert [row.attempt_seq for row in usage.attempts("run-batch")] == [1, 2, 3]
+
+ await bus.run(
+ Invocation.create(
+ run_id="run-batch",
+ stage=HookStage.BEFORE_MODEL,
+ operation_name="model",
+ )
+ )
+ assert len(bus.event_sink.events()) == 1
+ assert flush_observations()
+ assert usage.batches == [3]
+
+
+@pytest.mark.asyncio
+async def test_cancelled_hook_still_flushes_the_stage_batch():
+ usage = MemoryUsageRecorder()
+ bus = HookBus(usage_recorder=usage)
+
+ async def cancelled(invocation):
+ raise asyncio.CancelledError
+
+ bus.register(HookSpec("first", HookStage.BEFORE_TOOL, lambda invocation: None, priority=10))
+ bus.register(HookSpec("cancel", HookStage.BEFORE_TOOL, cancelled, priority=20))
+ bus.register(HookSpec("never", HookStage.BEFORE_TOOL, lambda invocation: None, priority=30))
+ with pytest.raises(asyncio.CancelledError):
+ await bus.run(
+ Invocation.create(
+ run_id="run-cancel",
+ stage=HookStage.BEFORE_TOOL,
+ operation_name="write",
+ )
+ )
+ assert flush_observations()
+ assert [(row.operation_name, row.status) for row in usage.attempts("run-cancel")] == [
+ ("first", "success"),
+ ("cancel", "cancelled"),
+ ]
diff --git a/src/backend/tests/orchestration/test_job_conflict_policy.py b/src/backend/tests/orchestration/test_job_conflict_policy.py
index 00aeb446..1aff0059 100644
--- a/src/backend/tests/orchestration/test_job_conflict_policy.py
+++ b/src/backend/tests/orchestration/test_job_conflict_policy.py
@@ -41,7 +41,8 @@ class _Toolkit:
def __init__(self):
self.fn = None
- def register_tool_function(self, fn):
+ def append(self, tool):
+ fn = tool._func
self.fn = fn
diff --git a/src/backend/tests/orchestration/test_job_derived_agent.py b/src/backend/tests/orchestration/test_job_derived_agent.py
index 2fadf575..69e13480 100644
--- a/src/backend/tests/orchestration/test_job_derived_agent.py
+++ b/src/backend/tests/orchestration/test_job_derived_agent.py
@@ -350,7 +350,8 @@ async def binding(**_kwargs):
monkeypatch.setattr(run_journal, "durable_run_binding", binding)
class Toolkit:
- def register_tool_function(self, fn):
+ def append(self, tool):
+ fn = tool._func
self.call = fn
runtime = snapshot_parent_runtime(
diff --git a/src/backend/tests/orchestration/test_job_managed_runtime.py b/src/backend/tests/orchestration/test_job_managed_runtime.py
index f898e921..0e6a7fad 100644
--- a/src/backend/tests/orchestration/test_job_managed_runtime.py
+++ b/src/backend/tests/orchestration/test_job_managed_runtime.py
@@ -78,8 +78,8 @@ def sandbox(monkeypatch):
)
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)
- for module in (runtime, state):
- monkeypatch.setattr(module, "SessionLocal", session)
+ monkeypatch.setattr(state, "SessionLocal", session)
+ monkeypatch.setattr("core.db.engine.SessionLocal", session)
monkeypatch.setattr(jobs_owner.process_state(), 'loop', None)
monkeypatch.setattr(jobs_supervisor.process_state(), 'entries', {})
box = Sandbox()
diff --git a/src/backend/tests/orchestration/test_job_orphan_reaper.py b/src/backend/tests/orchestration/test_job_orphan_reaper.py
index 0d39267c..43dbf2f2 100644
--- a/src/backend/tests/orchestration/test_job_orphan_reaper.py
+++ b/src/backend/tests/orchestration/test_job_orphan_reaper.py
@@ -52,7 +52,7 @@ def db_session(monkeypatch):
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
- monkeypatch.setattr(jr, "SessionLocal", Session)
+ monkeypatch.setattr("core.db.engine.SessionLocal", Session)
monkeypatch.setattr(state, "SessionLocal", Session)
monkeypatch.setattr(jobs_owner.process_state(), 'loop', None)
monkeypatch.setattr(supervisor.process_state(), 'entries', {})
diff --git a/src/backend/tests/orchestration/test_job_wake_message_visibility.py b/src/backend/tests/orchestration/test_job_wake_message_visibility.py
index 841121e2..a35e62b7 100644
--- a/src/backend/tests/orchestration/test_job_wake_message_visibility.py
+++ b/src/backend/tests/orchestration/test_job_wake_message_visibility.py
@@ -19,7 +19,6 @@
import asyncio
import core.chat.runtime.admission as admission
-import core.chat.runtime.job_wakeup as runtime_job_wakeup
import core.db.engine as db_engine
import core.runs.runtime as runtime
import core.chat.operations.session_context as chat_session_context
@@ -40,9 +39,6 @@ def db_session(monkeypatch):
Session = sessionmaker(bind=engine)
monkeypatch.setattr(db_engine, "SessionLocal", Session)
- import core.chat.runtime.job_wakeup as job_wakeup
-
- monkeypatch.setattr(runtime_job_wakeup, "SessionLocal", Session, raising=False)
return Session
@@ -86,6 +82,10 @@ def get_session(self, chat_id, user_id):
.first()
)
+ def get_session_with_access(self, chat_id, user_id):
+ session = self.get_session(chat_id, user_id)
+ return (session, "admin") if session is not None else None
+
async def fake_start_run(**kw):
return "run_1"
diff --git a/src/backend/tests/orchestration/test_job_wake_mode.py b/src/backend/tests/orchestration/test_job_wake_mode.py
index f83983ec..ce36edfe 100644
--- a/src/backend/tests/orchestration/test_job_wake_mode.py
+++ b/src/backend/tests/orchestration/test_job_wake_mode.py
@@ -19,7 +19,6 @@
from typing import Any, Dict, List
import core.chat.runtime.admission as admission
-import core.chat.runtime.job_wakeup as runtime_job_wakeup
import core.db.engine as db_engine
import core.runs.runtime as runtime
import core.chat.operations.session_context as chat_session_context
@@ -40,9 +39,6 @@ def db_session(monkeypatch):
Session = sessionmaker(bind=engine)
monkeypatch.setattr(db_engine, "SessionLocal", Session)
- import core.chat.runtime.job_wakeup as job_wakeup
-
- monkeypatch.setattr(runtime_job_wakeup, "SessionLocal", Session, raising=False)
return Session
@@ -75,8 +71,6 @@ def _seed(db_session, *, chat_meta: Dict[str, Any], status: str = "completed") -
def _capture_started_runs(monkeypatch, db_session) -> List[Dict[str, Any]]:
"""把唤醒真正入队的那一发拦下来,只留 context 供断言。"""
- import core.chat.runtime.job_wakeup as job_wakeup
-
started: List[Dict[str, Any]] = []
def fake_load_session_messages(_svc, _chat_id, _user_id):
@@ -99,6 +93,10 @@ def get_session(self, chat_id, user_id):
.first()
)
+ def get_session_with_access(self, chat_id, user_id):
+ session = self.get_session(chat_id, user_id)
+ return (session, "admin") if session is not None else None
+
async def fake_start_run(**kw):
started.append(kw)
return "run_1"
@@ -111,8 +109,6 @@ async def fake_start_run(**kw):
monkeypatch.setattr(resolver_mod, "resolve_all_runtime_enabled", fake_resolve_all)
monkeypatch.setattr(service_module, 'ChatService', _FakeChatService)
monkeypatch.setattr(admission, "start_run", fake_start_run)
- # 唤醒文案本身不是本用例的关注点,但它要能生成——JobService 走的是同一个假库
- monkeypatch.setattr(runtime_job_wakeup, "SessionLocal", db_session, raising=False)
return started
diff --git a/src/backend/tests/orchestration/test_model_progress_signal.py b/src/backend/tests/orchestration/test_model_progress_signal.py
index 0b125be7..7ca2e70b 100644
--- a/src/backend/tests/orchestration/test_model_progress_signal.py
+++ b/src/backend/tests/orchestration/test_model_progress_signal.py
@@ -1,11 +1,12 @@
-"""Tool-call delta batching and model_progress liveness tests.
+"""Delta coalescing and model_progress liveness tests.
-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``.
+Small argument / text fragments are merged by the stream's delta coalescer to
+avoid a write per token; a buffered fragment is flushed on its deadline, before
+any other event and at the end of the stream, and the completed JSON is emitted
+exactly once as ``tool_call``. ``model_progress`` keeps the watchdog alive only
+while upstream events map to nothing at all.
"""
-from core.llm.runtime import streaming_state as streaming_state_module
+from core.llm.runtime import streaming_constants as streaming_state_module
import asyncio
from types import SimpleNamespace
@@ -46,6 +47,7 @@ async def reply_stream(inputs=None):
chat_id="c1",
apply_request_context=lambda ctx, text: None,
context=SimpleNamespace(extend=lambda msgs: None),
+ pending_mcp_progress=[],
)
return SimpleNamespace(state=state, model=None, reply_stream=reply_stream)
@@ -58,10 +60,16 @@ async def _collect(agent):
return out
+class ToolResultTextDeltaEvent: # noqa: D101 - accumulated, never forwarded
+ def __init__(self, tid="t1", delta="x"):
+ self.tool_call_id = tid
+ self.delta = delta
+
+
@pytest.mark.asyncio
-async def test_small_buffered_deltas_emit_model_progress_until_flushed(monkeypatch):
+async def test_swallowed_events_emit_model_progress(monkeypatch):
monkeypatch.setattr(streaming_state_module, 'MODEL_PROGRESS_MIN_INTERVAL_S', 0.0)
- events = [ToolCallStartEvent()] + [ToolCallDeltaEvent(delta="chunk") for _ in range(5)]
+ events = [ToolCallStartEvent()] + [ToolResultTextDeltaEvent() for _ in range(5)]
out = await _collect(_fake_agent(events))
types = [t for t, _ in out]
assert "tool_call_start" in types
@@ -69,18 +77,29 @@ async def test_small_buffered_deltas_emit_model_progress_until_flushed(monkeypat
@pytest.mark.asyncio
-async def test_throttle_suppresses_model_progress():
- # Default 5s throttle: a fast burst of swallowed deltas must not spam signals.
+async def test_burst_of_deltas_is_merged_instead_of_signalling_progress(monkeypatch):
+ monkeypatch.setattr(streaming_state_module, 'MODEL_PROGRESS_MIN_INTERVAL_S', 0.0)
events = [ToolCallStartEvent()] + [ToolCallDeltaEvent(delta="chunk") for _ in range(5)]
out = await _collect(_fake_agent(events))
+ deltas = [p["delta"] for t, p in out if t == "tool_call_delta"]
+ assert "".join(deltas) == "chunk" * 5
+ assert len(deltas) < 5
+ assert [t for t, _ in out].count("model_progress") == 0
+
+
+@pytest.mark.asyncio
+async def test_throttle_suppresses_model_progress():
+ # Default 5s throttle: a fast burst of swallowed events must not spam signals.
+ events = [ToolCallStartEvent()] + [ToolResultTextDeltaEvent() for _ in range(5)]
+ out = await _collect(_fake_agent(events))
types = [t for t, _ in out]
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_state_module, 'TOOL_CALL_DELTA_FLUSH_CHARS', 8)
- monkeypatch.setattr(streaming_state_module, 'TOOL_CALL_DELTA_FLUSH_INTERVAL_S', 999.0)
+ monkeypatch.setattr(streaming_state_module, 'DELTA_FLUSH_CHARS', 8)
+ monkeypatch.setattr(streaming_state_module, 'DELTA_FLUSH_INTERVAL_S', 999.0)
events = [
ToolCallStartEvent(),
ToolCallDeltaEvent(delta='{"command"'),
@@ -109,6 +128,7 @@ async def reply_stream(inputs=None):
chat_id="c1",
apply_request_context=lambda ctx, text: None,
context=SimpleNamespace(extend=lambda msgs: None),
+ pending_mcp_progress=[],
)
return SimpleNamespace(state=state, model=None, reply_stream=reply_stream)
diff --git a/src/backend/tests/orchestration/test_model_steps_plan_projection.py b/src/backend/tests/orchestration/test_model_steps_plan_projection.py
index 40418215..2207a5cf 100644
--- a/src/backend/tests/orchestration/test_model_steps_plan_projection.py
+++ b/src/backend/tests/orchestration/test_model_steps_plan_projection.py
@@ -15,7 +15,6 @@
from types import SimpleNamespace
import core.agent_runtime.integrations.memory as memory
-import core.agent_runtime.resources as resources
import core.agent_runtime.runner as runner
import core.agent_runtime.targets as targets
import core.chat.plan_progress as plan_progress
@@ -63,6 +62,7 @@ async def stream(self, _messages, _context):
state="success",
)
state = SimpleNamespace(
+ pending_mcp_progress=[],
user_id="",
chat_id="",
run_id="",
@@ -148,8 +148,7 @@ async def no_compaction(_chat_id, messages, **_kwargs):
monkeypatch.setattr(catalog_resolver, "enabled_mcp_ids_from_context", lambda _ctx: [])
monkeypatch.setattr(catalog_resolver, "enabled_kb_ids_from_context", lambda _ctx: [])
monkeypatch.setattr(targets, "_resolve_mode_spec", lambda _ctx: None)
- monkeypatch.setattr("core.agent_runtime.prepare_main.streaming_module", SimpleNamespace(StreamingAgent=FakeStreamingAgent))
- monkeypatch.setattr(resources.process_state(), 'persistent_clients', [])
+ monkeypatch.setattr("core.agent_runtime.prepare_main.streaming", SimpleNamespace(StreamingAgent=FakeStreamingAgent))
if not persist_result:
original_refresh = services_ChatService_module.ChatService.refresh_streaming_message
diff --git a/src/backend/tests/orchestration/test_observation_writer.py b/src/backend/tests/orchestration/test_observation_writer.py
new file mode 100644
index 00000000..a8d144ae
--- /dev/null
+++ b/src/backend/tests/orchestration/test_observation_writer.py
@@ -0,0 +1,146 @@
+"""Hook-path observability is written in the background, bounded and lossy."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import threading
+import time
+
+import pytest
+from core.db.engine import Base
+from core.harness.events import Event, EventSink
+from core.harness.hooks import Decision, HookBus, HookSpec, HookStage, Invocation
+from core.harness.ledger import DurableEventStore
+from core.harness.observation_writer import ObservationWriter, flush_observations
+from core.harness.usage import MemoryUsageRecorder
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+
+def _stage(run_id: str = "run-bg") -> Invocation:
+ return Invocation.create(run_id=run_id, stage=HookStage.BEFORE_MODEL, operation_name="model")
+
+
+@pytest.mark.asyncio
+async def test_slow_durable_writes_never_block_the_stage():
+ release = threading.Event()
+
+ class SlowRecorder(MemoryUsageRecorder):
+ def record_attempts(self, attempts):
+ release.wait(5)
+ return super().record_attempts(attempts)
+
+ def slow_store(event):
+ release.wait(5)
+ return event
+
+ usage = SlowRecorder()
+ bus = HookBus(event_sink=EventSink(slow_store), usage_recorder=usage)
+ bus.register(HookSpec("policy", HookStage.BEFORE_MODEL, lambda invocation: None))
+ started = time.monotonic()
+ for _ in range(5):
+ await bus.run(_stage())
+ assert time.monotonic() - started < 0.5
+ # The in-memory view is available before anything reaches the store.
+ assert len(bus.event_sink.events()) == 5
+ assert usage.attempts("run-bg") == ()
+
+ release.set()
+ assert flush_observations()
+ assert len(usage.attempts("run-bg")) == 5
+
+
+@pytest.mark.asyncio
+async def test_failing_writes_do_not_change_the_outcome(caplog):
+ class BrokenRecorder:
+ def record_attempts(self, attempts):
+ raise RuntimeError("database down")
+
+ def broken_store(event):
+ raise RuntimeError("database down")
+
+ bus = HookBus(event_sink=EventSink(broken_store), usage_recorder=BrokenRecorder())
+ bus.register(HookSpec("policy", HookStage.BEFORE_MODEL, lambda invocation: Decision.reject("no")))
+ with caplog.at_level(logging.WARNING, logger="core.harness.observation_writer"):
+ outcome = await bus.run(_stage("run-broken"))
+ assert flush_observations()
+ assert outcome.decision.reason == "no"
+ assert "database down" in caplog.text
+
+
+def test_full_queue_drops_without_blocking(caplog):
+ writer = ObservationWriter(max_pending=2)
+ gate = threading.Event()
+ written = []
+
+ def write(sink, records):
+ gate.wait(5)
+ written.extend(records)
+
+ with caplog.at_level(logging.WARNING, logger="core.harness.observation_writer"):
+ assert writer.submit("sink", write, "run", ["first"])
+ time.sleep(0.05) # the writer thread is now holding "first"
+ started = time.monotonic()
+ results = [writer.submit("sink", write, "run", [index]) for index in range(10)]
+ assert time.monotonic() - started < 0.1
+ assert results[:2] == [True, True] and not any(results[2:])
+ assert writer.dropped == 8
+ assert "Observation queue full" in caplog.text
+
+ gate.set()
+ assert writer.flush(5)
+ assert written == ["first", 0, 1]
+
+
+def test_queued_records_are_batched_per_sink_and_run():
+ writer = ObservationWriter()
+ gate = threading.Event()
+ calls = []
+
+ def write(sink, records):
+ gate.wait(5)
+ calls.append((sink, list(records)))
+
+ writer.submit("a", write, "blocker", ["x"])
+ time.sleep(0.05)
+ for record in range(3):
+ writer.submit("a", write, "run-1", [record])
+ writer.submit("b", write, "run-1", [record])
+ writer.submit("a", write, "run-2", ["y"])
+ gate.set()
+ assert writer.flush(5)
+ assert calls == [
+ ("a", ["x"]),
+ ("a", [0, 1, 2]),
+ ("b", [0, 1, 2]),
+ ("a", ["y"]),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_published_events_reach_the_durable_store_in_order(tmp_path):
+ engine = create_engine(f"sqlite:///{tmp_path / 'events.sqlite'}")
+ Base.metadata.create_all(engine)
+ sessions = sessionmaker(bind=engine, expire_on_commit=False)
+ store = DurableEventStore(sessions)
+ sink = EventSink(store)
+ for index in range(4):
+ published = await sink.publish(
+ Event.create(run_id="run-events", event_type=f"e{index}", phase="before_run")
+ )
+ assert published.event_seq is None
+ assert flush_observations()
+ batch = store.store_many(
+ [Event.create(run_id="run-events", event_type="late", phase="before_run")]
+ )
+ assert batch[0].event_seq == 5
+ from core.db.models import HarnessEventLog
+
+ with sessions() as db:
+ rows = db.query(HarnessEventLog).order_by(HarnessEventLog.event_seq).all()
+ assert [(row.event_seq, row.event_type) for row in rows] == [
+ (1, "e0"), (2, "e1"), (3, "e2"), (4, "e3"), (5, "late"),
+ ]
+ engine.dispose()
+ await asyncio.sleep(0)
diff --git a/src/backend/tests/orchestration/test_pending_workflows.py b/src/backend/tests/orchestration/test_pending_workflows.py
new file mode 100644
index 00000000..0863806d
--- /dev/null
+++ b/src/backend/tests/orchestration/test_pending_workflows.py
@@ -0,0 +1,68 @@
+"""Restart between durable admission and local worker registration."""
+import asyncio
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from core.db import engine, models
+from core.runs import ownership, tasks, runtime
+from orchestration import pending
+from tests.orchestration.test_background_commands import command_db
+
+
+@pytest.mark.parametrize("kind", ["plan_execute", "autonomous_loop"])
+async def test_pending_admission_restarts_once_but_running_never_replays(command_db, monkeypatch, kind):
+ monkeypatch.setattr(engine, "SessionLocal", command_db)
+ real_dispatch = pending.dispatch_pending
+ async def no_dispatch(run):
+ pass
+ monkeypatch.setattr(pending, "dispatch_pending", no_dispatch)
+ if kind == "plan_execute":
+ from orchestration.plans import admission, execute_run as module
+ worker_name = "_run_plan_execute_workflow"
+ run = await admission.start_plan_execute_run(
+ plan_id="plan", chat_id="c", user_id="u",
+ session_messages=[{"role": "user", "content": "original input"}],
+ enabled_agent_ids=["analyst"], model_name="accepted-model",
+ )
+ else:
+ from orchestration.loops import admission, run as module
+ worker_name = "_run_autonomous_loop_workflow"
+ run = await admission.start_autonomous_loop_run(
+ loop_id="loop", chat_id="c", user_id="u",
+ goal_spec={"goal": "original goal"}, budget={"max_iterations": 7},
+ worker_max_iters=23, model_name="accepted-model",
+ )
+ executed = []
+ async def worker(**args):
+ if ownership.journal().claim(args["run_id"], owner="worker",
+ lease_seconds=30, pending_only=True):
+ executed.append(args)
+ monkeypatch.setattr(module, worker_name, worker)
+ monkeypatch.setattr(pending, "dispatch_pending", real_dispatch)
+ # Fresh DB read represents the successor scheduler, without any local launch state.
+ with command_db() as db:
+ accepted = db.get(models.ChatRun, run.run_id)
+ assert accepted.status == "pending"
+ assert accepted.recovery_snapshot["initial_args"]["model_name"] == "accepted-model"
+ accepted = runtime.get_run(run.run_id)
+ assert await real_dispatch(accepted)
+ await real_dispatch(accepted)
+ await asyncio.sleep(0.05)
+ assert len(executed) == 1
+ if kind == "plan_execute":
+ assert executed[0]["session_messages"][0]["content"] == "original input"
+ assert executed[0]["enabled_agent_ids"] == ["analyst"]
+ else:
+ assert executed[0]["worker_max_iters"] == 23
+ assert executed[0]["goal_spec"]["goal"] == "original goal"
+ # Even a stale pending snapshot cannot replay an already-started execution.
+ with command_db() as db:
+ row = db.get(models.ChatRun, run.run_id)
+ row.lease_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
+ db.commit()
+ assert not await real_dispatch(accepted)
+ await asyncio.sleep(0.05)
+ assert len(executed) == 1
+
+ assert not ownership.journal().claim(run.run_id, owner="late-worker",
+ lease_seconds=30, pending_only=True)
diff --git a/src/backend/tests/orchestration/test_plan_progress_closeout.py b/src/backend/tests/orchestration/test_plan_progress_closeout.py
index a878c61f..08eb5cd6 100644
--- a/src/backend/tests/orchestration/test_plan_progress_closeout.py
+++ b/src/backend/tests/orchestration/test_plan_progress_closeout.py
@@ -14,7 +14,6 @@
**不伪造**步骤状态(停在 2/5 的计划收尾后仍是 2/5)。
"""
-import core.chat.runtime.job_wakeup as job_wakeup
import core.db.engine as db_engine
import pytest
from core.db.engine import Base
@@ -45,10 +44,8 @@ def db_session(monkeypatch):
monkeypatch.setattr(db_engine, "SessionLocal", Session)
import core.chat.plan_progress as pp_mod
- import core.chat.runtime.job_wakeup as job_wakeup
monkeypatch.setattr(pp_mod, "SessionLocal", Session, raising=False)
- monkeypatch.setattr(job_wakeup, "SessionLocal", Session, raising=False)
with Session() as db:
db.add(ChatSession(chat_id="chat_1", user_id="u1", title="批量分类", extra_data={}))
diff --git a/src/backend/tests/orchestration/test_project_init_stream_input.py b/src/backend/tests/orchestration/test_project_init_stream_input.py
index 050b8434..6bdaacea 100644
--- a/src/backend/tests/orchestration/test_project_init_stream_input.py
+++ b/src/backend/tests/orchestration/test_project_init_stream_input.py
@@ -3,7 +3,7 @@
from types import SimpleNamespace
import pytest
-from core.llm.context.ir_state import SESSION_CONTEXT_META_KEY
+from core.llm.context.ir_constants import SESSION_CONTEXT_META_KEY
from core.llm.runtime.streaming import StreamingAgent
diff --git a/src/backend/tests/orchestration/test_runtime_phase_lifecycle.py b/src/backend/tests/orchestration/test_runtime_phase_lifecycle.py
index 1b22b734..823e405c 100644
--- a/src/backend/tests/orchestration/test_runtime_phase_lifecycle.py
+++ b/src/backend/tests/orchestration/test_runtime_phase_lifecycle.py
@@ -11,7 +11,7 @@
from types import SimpleNamespace
import pytest
-from core.agent_runtime import budget, resources, runner, targets
+from core.agent_runtime import budget, runner, targets
from core.agent_runtime.integrations import evolution, memory
from core.capabilities.catalog import resolver as catalog_resolver
from core.llm.citations import anchor as citation_anchor
@@ -113,7 +113,6 @@ async def finish(*args, **kwargs):
monkeypatch.setattr(log_writer, 'start_subagent_log', empty)
monkeypatch.setattr(log_writer, 'finish_subagent_log', finish)
monkeypatch.setattr(evolution, "_assemble_episode_background", lambda **_: None)
- monkeypatch.setattr(resources.process_state(), 'persistent_clients', [])
return observed
diff --git a/src/backend/tests/orchestration/test_thinking_block_persistence.py b/src/backend/tests/orchestration/test_thinking_block_persistence.py
index eee71084..5e0396d5 100644
--- a/src/backend/tests/orchestration/test_thinking_block_persistence.py
+++ b/src/backend/tests/orchestration/test_thinking_block_persistence.py
@@ -22,7 +22,7 @@
import pytest
from core.db.engine import Base
from core.db.models import ChatMessage, ChatSession
-from core.chat.service_state import CONTENT_MAX as _CONTENT_MAX, THINKING_MAX as _THINKING_MAX
+from core.chat.limits import CONTENT_MAX as _CONTENT_MAX, THINKING_MAX as _THINKING_MAX
from core.chat.limits import clamp_thinking
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
diff --git a/src/backend/tests/orchestration/test_tool_effect_ledger.py b/src/backend/tests/orchestration/test_tool_effect_ledger.py
index 90746d6f..8e0b22cb 100644
--- a/src/backend/tests/orchestration/test_tool_effect_ledger.py
+++ b/src/backend/tests/orchestration/test_tool_effect_ledger.py
@@ -1,85 +1,58 @@
-"""Durable, idempotent tool side-effect execution and recovery."""
-
-from __future__ import annotations
-from core.harness import log_records
-
-import asyncio
-import json
-from concurrent.futures import ThreadPoolExecutor
-from datetime import datetime, timedelta, timezone
-from threading import Barrier
-from types import SimpleNamespace
-
-import pytest
-from agentscope.agent import Agent, ReActConfig
-from agentscope.message import TextBlock, ToolCallBlock, ToolResultState, UserMsg
-from agentscope.model import ChatResponse, ChatUsage
-from agentscope.permission import PermissionContext, PermissionMode
-from agentscope.tool import FunctionTool, Toolkit
-from agentscope.tool._response import ToolChunk, ToolResponse
-from core.db.engine import Base
-from core.db.models import ChatRun, ChatSession, ToolCallLog, ToolEffectLease, ToolEffectLedger, ToolEffectReceipt
-from features.automation.models import ScheduledTask
-from core.llm.runtime.state import AgentRuntimeState
-from tests.orchestration.hook_support import make_adapter, durable_adapter
-from core.runs.tool_effect_recovery import _plain_replay_args, reconcile_scheduled_task_intent
-from core.runs.journal import RunJournal
-from core.runs.tool_effects.registry import DEFAULT_TOOL_RECOVERY_REGISTRY, ToolRecoveryRegistry
-from core.runs.tool_effects.errors import ToolEffectError, ToolIdempotencyConflict, ToolIntentCommitError, ToolOutcomeUnknown
-from core.runs.tool_effects.gateway import ToolEffectGateway
-from core.runs.tool_effects.journal import ToolEffectJournal
-from core.runs.tool_effects.types import ToolIntent
-from core.runs.tool_effects.recovery import recover_incomplete_tool_effects
-from sqlalchemy import create_engine
-from sqlalchemy.orm import sessionmaker
-from sqlalchemy.pool import QueuePool
-
-
-class MutableClock:
- def __init__(self) -> None:
- self.now = datetime.now(timezone.utc)
-
- def __call__(self) -> datetime:
- return self.now
-
- def advance(self, seconds: float) -> None:
- self.now += timedelta(seconds=seconds)
-
-
-@pytest.fixture()
-def effect_env(tmp_path):
- engine = create_engine(
- f"sqlite:///{tmp_path / 'tool-effects.sqlite'}",
- connect_args={"check_same_thread": False},
- )
- Base.metadata.create_all(engine)
- sessions = sessionmaker(bind=engine, expire_on_commit=False)
-
- def make_run(
- run_id: str,
- *,
- owner: str = "worker",
- user_id: str = "user-1",
- chat_id: str = "chat-1",
- ) -> None:
- with sessions() as db:
- if db.get(ChatSession, chat_id) is None:
- db.add(ChatSession(chat_id=chat_id, user_id=user_id, title="test"))
- db.commit()
- journal = RunJournal(sessions)
- journal.accept(
- run_id=run_id,
- message_id=f"msg-{run_id}",
- chat_id=chat_id,
- user_id=user_id,
- request_payload={"kind": "chat"},
- recovery_snapshot={"kind": "chat", "worker_args": {}},
- )
- assert journal.claim(run_id, owner=owner, lease_seconds=300)
-
- yield sessions, make_run
- engine.dispose()
-
+from tests.orchestration.tool_effect_support import (
+ log_records,
+ asyncio,
+ json,
+ ThreadPoolExecutor,
+ datetime,
+ timedelta,
+ timezone,
+ Barrier,
+ SimpleNamespace,
+ pytest,
+ Agent,
+ ReActConfig,
+ TextBlock,
+ ToolCallBlock,
+ ToolResultState,
+ UserMsg,
+ ChatResponse,
+ ChatUsage,
+ PermissionContext,
+ PermissionMode,
+ FunctionTool,
+ Toolkit,
+ ToolChunk,
+ ToolResponse,
+ Base,
+ ChatRun,
+ ChatSession,
+ ToolCallLog,
+ ToolEffectLease,
+ ToolEffectLedger,
+ ToolEffectReceipt,
+ ScheduledTask,
+ AgentRuntimeState,
+ make_adapter,
+ durable_adapter,
+ _plain_replay_args,
+ reconcile_scheduled_task_intent,
+ RunJournal,
+ DEFAULT_TOOL_RECOVERY_REGISTRY,
+ ToolRecoveryRegistry,
+ ToolEffectError,
+ ToolIdempotencyConflict,
+ ToolIntentCommitError,
+ ToolOutcomeUnknown,
+ ToolEffectGateway,
+ ToolEffectJournal,
+ ToolIntent,
+ recover_incomplete_tool_effects,
+ create_engine,
+ sessionmaker,
+ QueuePool,
+ MutableClock,
+ effect_env,
+)
@pytest.mark.asyncio
async def test_intent_commit_failure_never_calls_tool_function():
@@ -498,1281 +471,3 @@ async def tool():
)
assert result == {"replayed": True}
assert invoked == 1
-
-
-@pytest.mark.asyncio
-async def test_startup_replayer_does_not_alias_a_later_same_args_provider_call(effect_env):
- sessions, make_run = effect_env
- make_run("run-startup-replay")
- clock = MutableClock()
- replayed = 0
-
- async def replay(_intent):
- nonlocal replayed
- replayed += 1
- return {"from_adapter": True}
-
- registry = ToolRecoveryRegistry()
- registry.register("safe_read", "replay_safe", replayer=replay)
- ledger = ToolEffectJournal(sessions, clock=clock)
- original = ledger.begin_intent(
- run_id="run-startup-replay",
- owner="worker",
- claim_owner="crashed",
- tool_call_id="old-provider-id",
- tool_name="safe_read",
- args={"query": "same"},
- recovery_policy="replay_safe",
- lease_seconds=1,
- )
- clock.advance(2)
- with sessions() as db:
- db.query(ChatRun).filter(ChatRun.run_id == "run-startup-replay").update(
- {"lease_expires_at": clock() - timedelta(seconds=1)}
- )
- db.commit()
- decisions = await recover_incomplete_tool_effects(journal=ledger, registry=registry)
- assert [(item.effect_id, item.action) for item in decisions] == [
- (original.effect_id, "replayed")
- ]
- assert RunJournal(sessions, clock=clock).claim(
- "run-startup-replay", owner="new-worker", lease_seconds=300
- )
- invoked = 0
-
- async def later_call():
- nonlocal invoked
- invoked += 1
- return {"wrong": True}
-
- outcome = await ToolEffectGateway(ledger, registry).execute_outcome(
- run_id="run-startup-replay",
- owner="new-worker",
- tool_call_id="new-provider-id",
- tool_name="safe_read",
- args={"query": "same"},
- invoke=later_call,
- )
- assert outcome.result == {"wrong": True}
- assert outcome.effect_id != original.effect_id
- assert outcome.invoked is True
- assert replayed == 1
- assert invoked == 1
-
-
-@pytest.mark.asyncio
-async def test_invocation_heartbeat_keeps_long_call_claimed(effect_env):
- sessions, make_run = effect_env
- make_run("run-fenced", owner="old-worker")
- registry = ToolRecoveryRegistry()
- registry.register("safe", "replay_safe")
- ledger = ToolEffectJournal(sessions)
- gateway = ToolEffectGateway(ledger, registry, poll_interval=0.005, lease_seconds=1)
- entered = asyncio.Event()
- release = asyncio.Event()
- invoked = 0
-
- async def slow_tool():
- nonlocal invoked
- invoked += 1
- entered.set()
- await release.wait()
- return {"ok": True}
-
- first = asyncio.create_task(
- gateway.execute(
- run_id="run-fenced",
- owner="old-worker",
- tool_call_id="call-old",
- tool_name="safe",
- args={"q": 1},
- idempotency_key="fenced-key",
- invoke=slow_tool,
- )
- )
- await entered.wait()
- await asyncio.sleep(1.2)
- assert not RunJournal(sessions).claim("run-fenced", owner="new-worker", lease_seconds=300)
- assert invoked == 1
- release.set()
- assert await first == {"ok": True}
- assert invoked == 1
-
-
-def test_sqlite_atomic_recovery_claim_has_one_winner(effect_env):
- sessions, make_run = effect_env
- make_run("run-sqlite-cas")
- ledger = ToolEffectJournal(sessions)
- intent = ledger.begin_intent(
- run_id="run-sqlite-cas",
- owner="worker",
- claim_owner="crashed",
- tool_call_id="sqlite-cas-call",
- tool_name="safe",
- args={"q": 1},
- recovery_policy="replay_safe",
- lease_seconds=1,
- )
- expired = datetime.now(timezone.utc) - timedelta(seconds=1)
- with sessions() as db:
- db.query(ChatRun).filter(ChatRun.run_id == "run-sqlite-cas").update(
- {"lease_expires_at": expired}
- )
- db.query(ToolEffectLease).filter(ToolEffectLease.effect_id == intent.effect_id).update(
- {"lease_expires_at": expired}
- )
- db.commit()
-
- barrier = Barrier(2)
-
- def claim(owner: str):
- barrier.wait()
- return ToolEffectJournal(sessions).claim_recovery_intent(
- intent.effect_id,
- recovery_owner=owner,
- lease_seconds=30,
- )
-
- with ThreadPoolExecutor(max_workers=2) as pool:
- results = list(pool.map(claim, ("recovery-a", "recovery-b")))
- assert sum(result is not None for result in results) == 1
-
-
-@pytest.mark.asyncio
-async def test_reconcile_intent_queries_adapter_before_execution(effect_env):
- sessions, make_run = effect_env
- make_run("run-reconcile")
- clock = MutableClock()
- reconciled = 0
-
- async def reconcile(intent):
- nonlocal reconciled
- reconciled += 1
- assert intent.redacted_args == {"amount": 8}
- return {"external_status": "already_applied"}
-
- registry = ToolRecoveryRegistry()
- registry.register("charge_account", "reconcile", reconciler=reconcile)
- ledger = ToolEffectJournal(sessions, clock=clock)
- ledger.begin_intent(
- run_id="run-reconcile",
- owner="worker",
- claim_owner="crashed-invocation",
- tool_call_id="old-call",
- tool_name="charge_account",
- args={"amount": 8},
- recovery_policy="reconcile",
- idempotency_key="charge-key",
- lease_seconds=1,
- )
- clock.advance(2)
- with sessions() as db:
- db.query(ChatRun).filter(ChatRun.run_id == "run-reconcile").update(
- {"lease_expires_at": clock() - timedelta(seconds=1)}
- )
- db.commit()
- assert RunJournal(sessions, clock=clock).claim(
- "run-reconcile", owner="recovery-worker", lease_seconds=300
- )
- invoked = 0
-
- async def must_not_execute():
- nonlocal invoked
- invoked += 1
- return {"wrong": True}
-
- result = await ToolEffectGateway(ledger, registry).execute(
- run_id="run-reconcile",
- owner="recovery-worker",
- tool_call_id="new-call",
- tool_name="charge_account",
- args={"amount": 8},
- idempotency_key="charge-key",
- invoke=must_not_execute,
- )
- assert result == {"external_status": "already_applied"}
- assert reconciled == 1
- assert invoked == 0
-
-
-@pytest.mark.asyncio
-async def test_unknown_tool_defaults_never_replay_and_pauses_run(effect_env):
- sessions, make_run = effect_env
- make_run("run-unknown")
- clock = MutableClock()
- registry = ToolRecoveryRegistry()
- assert registry.resolve("unregistered_write").policy == "never_replay"
- ledger = ToolEffectJournal(sessions, clock=clock)
- ledger.begin_intent(
- run_id="run-unknown",
- owner="worker",
- claim_owner="crashed-invocation",
- tool_call_id="old-call",
- tool_name="unregistered_write",
- args={"target": "outside"},
- recovery_policy="never_replay",
- idempotency_key="unknown-key",
- lease_seconds=1,
- )
- clock.advance(2)
- with sessions() as db:
- db.query(ChatRun).filter(ChatRun.run_id == "run-unknown").update(
- {"lease_expires_at": clock() - timedelta(seconds=1)}
- )
- db.commit()
- assert RunJournal(sessions, clock=clock).claim(
- "run-unknown", owner="recovery-worker", lease_seconds=300
- )
- invoked = 0
-
- async def must_not_execute():
- nonlocal invoked
- invoked += 1
- return {"wrong": True}
-
- with pytest.raises(ToolOutcomeUnknown):
- await ToolEffectGateway(ledger, registry).execute(
- run_id="run-unknown",
- owner="recovery-worker",
- tool_call_id="new-call",
- tool_name="unregistered_write",
- args={"target": "outside"},
- idempotency_key="unknown-key",
- invoke=must_not_execute,
- )
- assert invoked == 0
- with sessions() as db:
- assert db.get(ChatRun, "run-unknown").status == "needs_attention"
- events = (
- db.query(ToolEffectLedger)
- .filter(ToolEffectLedger.run_id == "run-unknown")
- .order_by(ToolEffectLedger.created_at)
- .all()
- )
- assert [event.event_type for event in events] == [
- "intent",
- "recovery_claim",
- "unknown_outcome",
- ]
- with pytest.raises(ToolOutcomeUnknown):
- await ToolEffectGateway(ledger, registry).execute(
- run_id="run-unknown",
- owner="recovery-worker",
- tool_call_id="another-call",
- tool_name="unregistered_write",
- args={"target": "outside"},
- idempotency_key="unknown-key",
- invoke=must_not_execute,
- )
-
-
-def test_result_commit_retry_is_idempotent_and_args_are_redacted(effect_env):
- sessions, make_run = effect_env
- make_run("run-result")
- ledger = ToolEffectJournal(sessions)
- prepared = ledger.begin_intent(
- run_id="run-result",
- owner="worker",
- claim_owner="invocation",
- tool_call_id="call-result",
- tool_name="write",
- args={"path": "/tmp/a", "password": "super-secret"},
- recovery_policy="never_replay",
- idempotency_key="result-key",
- )
- first = ledger.commit_result(
- prepared.effect_id,
- run_owner="worker",
- claim_owner="invocation",
- result={"ok": True},
- )
- second = ledger.commit_result(
- prepared.effect_id,
- run_owner="worker",
- claim_owner="invocation",
- result={"ok": True},
- )
- assert first == second == {"ok": True}
- with sessions() as db:
- events = (
- db.query(ToolEffectLedger)
- .filter(ToolEffectLedger.effect_id == prepared.effect_id)
- .order_by(ToolEffectLedger.created_at)
- .all()
- )
- assert [event.event_type for event in events] == ["intent", "result"]
- assert events[0].redacted_args["password"] != "super-secret"
- assert "super-secret" not in str(events[0].redacted_args)
- assert len(events[0].args_hash) == 64
-
-
-def test_concurrent_result_commit_retry_is_idempotent(effect_env):
- sessions, make_run = effect_env
- make_run("run-result-race")
- ledger = ToolEffectJournal(sessions)
- prepared = ledger.begin_intent(
- run_id="run-result-race",
- owner="worker",
- claim_owner="same-claim",
- tool_call_id="result-race",
- tool_name="Read",
- args={"path": "/a"},
- recovery_policy="replay_safe",
- )
- barrier = Barrier(2)
-
- def commit():
- barrier.wait(timeout=5)
- return ledger.commit_result(
- prepared.effect_id,
- run_owner="worker",
- claim_owner="same-claim",
- result={"ok": True},
- )
-
- with ThreadPoolExecutor(max_workers=2) as pool:
- assert list(pool.map(lambda _index: commit(), range(2))) == [
- {"ok": True},
- {"ok": True},
- ]
- with sessions() as db:
- assert (
- db.query(ToolEffectLedger)
- .filter(
- ToolEffectLedger.effect_id == prepared.effect_id,
- ToolEffectLedger.event_type == "result",
- )
- .count()
- == 1
- )
-
-
-def test_default_recovery_policies_have_real_adapters():
- assert DEFAULT_TOOL_RECOVERY_REGISTRY.resolve("Read").replayer is not None
- scheduled = DEFAULT_TOOL_RECOVERY_REGISTRY.resolve("create_scheduled_task")
- assert scheduled.policy == "reconcile"
- assert scheduled.reconciler is not None
- assert scheduled.replayer is not None
- assert DEFAULT_TOOL_RECOVERY_REGISTRY.resolve("unregistered_write").policy == "never_replay"
-
-
-def test_recovery_rejects_nested_withheld_arguments():
- intent = ToolIntent(
- effect_id="eff-nested-secret",
- result_id="result-nested-secret",
- run_id="run-nested-secret",
- operation_seq=1,
- tool_name="Read",
- tool_call_id="call-nested-secret",
- args_hash="hash",
- redacted_args={
- "request": {"steps": [{"command": {"_withheld": True, "sha256": "abc", "length": 3}}]}
- },
- idempotency_key="key",
- recovery_policy="replay_safe",
- )
- with pytest.raises(ToolEffectError, match="withheld sensitive free text"):
- _plain_replay_args(intent)
-
-
-@pytest.mark.asyncio
-async def test_scheduled_create_reconciliation_uses_exact_atomic_receipt(
- effect_env,
- monkeypatch,
-):
- sessions, make_run = effect_env
- make_run("run-scheduled-receipt")
- ledger = ToolEffectJournal(sessions)
- intent = ledger.begin_intent(
- run_id="run-scheduled-receipt",
- owner="worker",
- claim_owner="scheduled-call",
- tool_call_id="scheduled-call",
- tool_name="create_scheduled_task",
- args={
- "cron_expression": "0 9 * * *",
- "prompt": "send report",
- "name": "daily",
- },
- recovery_policy="reconcile",
- ).intent
- with sessions() as db:
- db.add(
- ScheduledTask(
- task_id="pre-existing-same-content",
- user_id="user-1",
- task_type="prompt",
- prompt="send report",
- cron_expression="0 9 * * *",
- name="daily",
- status="active",
- )
- )
- db.commit()
-
- import core.db.engine as engine_module
- import core.runs.tool_effect_recovery as recovery_module
- from extensions.mcp_servers.automation_task_mcp import impl
-
- monkeypatch.setattr(engine_module, "SessionLocal", sessions)
- monkeypatch.setattr(recovery_module, "SessionLocal", sessions)
- before = await reconcile_scheduled_task_intent(intent)
- assert before.outcome == "not_applied"
-
- result = impl.create_task(
- user_id="user-1",
- cron_expression="0 9 * * *",
- prompt="send report",
- name="daily",
- tool_effect_id=intent.effect_id,
- )
- assert result["ok"] is True
- again = impl.create_task(
- user_id="user-1",
- cron_expression="0 9 * * *",
- prompt="send report",
- name="daily",
- tool_effect_id=intent.effect_id,
- )
- assert again == result
- after = await reconcile_scheduled_task_intent(intent)
- assert after.outcome == "applied"
- assert after.result == result
- with sessions() as db:
- assert db.query(ScheduledTask).count() == 2
- assert db.get(ToolEffectReceipt, intent.effect_id).result_payload == result
-
-
-def test_embedded_secrets_and_free_text_are_not_persisted(effect_env):
- sessions, make_run = effect_env
- make_run("run-secrets")
- secret = "sk-123456789012345678901234567890"
- ledger = ToolEffectJournal(sessions)
- intent = ledger.begin_intent(
- run_id="run-secrets",
- owner="worker",
- claim_owner="invocation",
- tool_call_id="secret-call",
- tool_name="Bash",
- args={
- "command": f"curl -H 'Authorization: Bearer {secret}' https://x",
- "url": f"https://example.test/?token={secret}",
- },
- recovery_policy="never_replay",
- )
- with sessions() as db:
- row = (
- db.query(ToolEffectLedger)
- .filter(
- ToolEffectLedger.effect_id == intent.effect_id,
- ToolEffectLedger.event_type == "intent",
- )
- .one()
- )
- assert secret not in json.dumps(row.redacted_args)
- assert row.redacted_args["command"]["_withheld"] is True
- assert "[REDACTED]" in row.redacted_args["url"]
-
-
-@pytest.mark.asyncio
-async def test_missing_run_binding_fails_closed_before_tool_invocation(effect_env):
- sessions, _make_run = effect_env
- middleware = durable_adapter(sessions)
- agent = SimpleNamespace(state=SimpleNamespace(run_id=None, journal_owner=None))
- invoked = 0
-
- async def tool(**_kwargs):
- nonlocal invoked
- invoked += 1
- yield ToolResponse(content=[TextBlock(text="wrong")])
-
- with pytest.raises(ToolIntentCommitError):
- _ = [
- item
- async for item in middleware.on_acting(
- agent,
- {"tool_call": ToolCallBlock(id="x", name="Read", input="{}")},
- tool,
- )
- ]
- assert invoked == 0
-
-
-@pytest.mark.asyncio
-async def test_middleware_leaves_adapter_exception_pending_for_recovery(effect_env):
- sessions, make_run = effect_env
- make_run("run-tool-failure")
- middleware = durable_adapter(sessions)
- agent = SimpleNamespace(
- state=SimpleNamespace(
- run_id="run-tool-failure",
- journal_owner="worker",
- tool_effect_links={},
- )
- )
-
- async def broken(**_kwargs):
- if False:
- yield None
- raise RuntimeError("adapter exploded")
-
- with pytest.raises(ToolOutcomeUnknown) as raised:
- _ = [
- item
- async for item in middleware.on_acting(
- agent,
- {"tool_call": ToolCallBlock(id="failure-call", name="Read", input="{}")},
- broken,
- )
- ]
- assert isinstance(raised.value.__cause__, RuntimeError)
- with sessions() as db:
- events = db.query(ToolEffectLedger).order_by(ToolEffectLedger.event_id).all()
- assert [event.event_type for event in events] == ["intent"]
-
-
-@pytest.mark.asyncio
-async def test_cancelled_run_settles_pending_intent_without_delayed_replay(effect_env):
- sessions, make_run = effect_env
- make_run("run-cancelled-effect")
- registry = ToolRecoveryRegistry()
- invoked = 0
-
- async def replay(_intent):
- nonlocal invoked
- invoked += 1
- return {"should_not": "run"}
-
- registry.register("Read", "replay_safe", replayer=replay)
- ledger = ToolEffectJournal(sessions)
- ledger.begin_intent(
- run_id="run-cancelled-effect",
- owner="worker",
- claim_owner="dead-call",
- tool_call_id="cancelled-call",
- tool_name="Read",
- args={"path": "/workspace/a.txt"},
- recovery_policy="replay_safe",
- )
- assert RunJournal(sessions).cancel("run-cancelled-effect", reason="user cancelled")
-
- decisions = await recover_incomplete_tool_effects(journal=ledger, registry=registry)
-
- assert [item.action for item in decisions] == ["terminal_unknown"]
- assert invoked == 0
- with sessions() as db:
- run = db.get(ChatRun, "run-cancelled-effect")
- events = (
- db.query(ToolEffectLedger)
- .filter(ToolEffectLedger.run_id == run.run_id)
- .order_by(ToolEffectLedger.event_id)
- .all()
- )
- assert run.status == "cancelled"
- assert run.run_phase == "cancelled"
- assert [item.event_type for item in events] == ["intent", "unknown_outcome"]
-
-
-@pytest.mark.asyncio
-async def test_middleware_commits_error_tool_response_as_failure(effect_env):
- sessions, make_run = effect_env
- make_run("run-tool-error-response")
- middleware = durable_adapter(sessions)
- agent = SimpleNamespace(
- state=SimpleNamespace(
- run_id="run-tool-error-response",
- journal_owner="worker",
- tool_effect_links={},
- )
- )
-
- async def failed_response(**_kwargs):
- yield ToolResponse(
- content=[TextBlock(text="tool rejected input")],
- state=ToolResultState.ERROR,
- )
-
- output = [
- item
- async for item in middleware.on_acting(
- agent,
- {"tool_call": ToolCallBlock(id="error-response-call", name="Read", input="{}")},
- failed_response,
- )
- ]
- assert output[-1].state == ToolResultState.ERROR
- with sessions() as db:
- events = db.query(ToolEffectLedger).order_by(ToolEffectLedger.event_id).all()
- assert [event.event_type for event in events] == ["intent", "failure"]
-
-
-@pytest.mark.asyncio
-async def test_middleware_returns_explicit_error_for_unregistered_tool(effect_env):
- sessions, make_run = effect_env
- make_run("run-unregistered-tool-error")
- middleware = durable_adapter(sessions)
- agent = SimpleNamespace(
- state=SimpleNamespace(
- run_id="run-unregistered-tool-error",
- journal_owner="worker",
- tool_effect_links={},
- )
- )
- original_error = "Remote API returned 404: resource not found or not authorized"
-
- async def failed_response(**_kwargs):
- yield ToolResponse(
- content=[TextBlock(text=original_error)],
- state=ToolResultState.ERROR,
- )
-
- output = [
- item
- async for item in middleware.on_acting(
- agent,
- {
- "tool_call": ToolCallBlock(
- id="unregistered-error-call",
- name="remote_resource_lookup",
- input='{"resource":"missing-or-private"}',
- )
- },
- failed_response,
- )
- ]
-
- assert output[-1].state == ToolResultState.ERROR
- assert original_error in str(output[-1].content)
- with sessions() as db:
- events = db.query(ToolEffectLedger).order_by(ToolEffectLedger.event_id).all()
- assert [event.event_type for event in events] == ["intent", "failure"]
- assert events[0].recovery_policy == "never_replay"
- assert original_error in str(events[-1].result_payload)
-
-
-@pytest.mark.asyncio
-async def test_agentscope_middleware_commits_intent_before_call_and_replays_same_shape(
- effect_env,
-):
- sessions, make_run = effect_env
- make_run("run-middleware")
- middleware = durable_adapter(sessions)
- agent = SimpleNamespace(state=SimpleNamespace(run_id="run-middleware", journal_owner="worker"))
- invoked = 0
- saw_durable_intent = False
-
- async def execute_once(**_kwargs):
- nonlocal invoked, saw_durable_intent
- invoked += 1
- with sessions() as db:
- saw_durable_intent = (
- db.query(ToolEffectLedger)
- .filter(
- ToolEffectLedger.run_id == "run-middleware",
- ToolEffectLedger.event_type == "intent",
- )
- .count()
- == 1
- )
- block = TextBlock(text="same SSE-compatible result")
- yield ToolChunk(content=[block], state=ToolResultState.RUNNING)
- yield ToolResponse(content=[block], state=ToolResultState.SUCCESS)
-
- first_call = ToolCallBlock(
- id="provider-call-1",
- name="Read",
- input='{"path":"/workspace/a.txt"}',
- )
- first = [
- item
- async for item in middleware.on_acting(
- agent,
- {"tool_call": first_call},
- execute_once,
- )
- ]
-
- async def must_not_execute(**_kwargs):
- nonlocal invoked
- invoked += 1
- yield ToolResponse(content=[TextBlock(text="wrong")])
-
- replay_call = ToolCallBlock(
- id="provider-call-1",
- name="Read",
- input='{"path":"/workspace/a.txt"}',
- )
- replayed = [
- item
- async for item in middleware.on_acting(
- agent,
- {"tool_call": replay_call},
- must_not_execute,
- )
- ]
-
- assert saw_durable_intent is True
- assert invoked == 1
- assert [type(item) for item in first] == [ToolChunk, ToolResponse]
- assert [type(item) for item in replayed] == [ToolChunk, ToolResponse]
- assert replayed[-1].state == ToolResultState.SUCCESS
- assert replayed[-1].content[0].text == "same SSE-compatible result"
-
-
-@pytest.mark.asyncio
-async def test_real_agentscope_public_reply_commits_intent_before_adapter(effect_env):
- sessions, make_run = effect_env
- make_run("run-public-agent")
- saw_intent = False
- invoked = 0
-
- async def probe(value: int) -> ToolChunk:
- """Return a test value."""
- nonlocal saw_intent, invoked
- invoked += 1
- with sessions() as db:
- saw_intent = (
- db.query(ToolEffectLedger)
- .filter(
- ToolEffectLedger.run_id == "run-public-agent",
- ToolEffectLedger.event_type == "intent",
- )
- .count()
- == 1
- )
- return ToolChunk(
- content=[TextBlock(text=f"value={value}")],
- state=ToolResultState.SUCCESS,
- )
-
- class ToolThenAnswerModel:
- model = "tool-then-answer"
- context_size = 32768
-
- def __init__(self):
- self.calls = 0
-
- async def __call__(self, messages, tools=None, **_kwargs):
- del messages
- self.calls += 1
- if self.calls == 1:
- assert any(item["function"]["name"] == "probe" for item in tools)
- return ChatResponse(
- content=[ToolCallBlock(id="public-call", name="probe", input='{"value":7}')],
- is_last=False,
- usage=ChatUsage(input_tokens=1, output_tokens=1, time=0.01),
- )
- return ChatResponse(
- content=[TextBlock(text="done")],
- is_last=True,
- usage=ChatUsage(input_tokens=1, output_tokens=1, time=0.01),
- )
-
- async def count_tokens(self, messages, tools=None):
- del messages, tools
- return 1
-
- state = AgentRuntimeState(
- run_id="run-public-agent",
- journal_owner="worker",
- permission_context=PermissionContext(mode=PermissionMode.BYPASS),
- )
- agent = Agent(
- name="ledger-public-seam",
- system_prompt="Use the probe tool once.",
- model=ToolThenAnswerModel(),
- toolkit=Toolkit(tools=[FunctionTool(probe)]),
- middlewares=[durable_adapter(sessions)],
- state=state,
- react_config=ReActConfig(max_iters=2),
- )
- reply = await agent.reply(UserMsg(name="user", content="run probe"))
- assert reply.get_text_content() == "done"
- assert invoked == 1
- assert saw_intent is True
- with sessions() as db:
- assert [
- row.event_type for row in db.query(ToolEffectLedger).order_by(ToolEffectLedger.event_id)
- ] == ["intent", "result"]
-
-
-@pytest.mark.asyncio
-async def test_public_reply_cancellation_stops_tool_without_late_terminal_write(effect_env):
- sessions, make_run = effect_env
- make_run("run-public-cancel")
- entered = asyncio.Event()
- release = asyncio.Event()
- side_effects = 0
-
- async def blocking_probe(value: int) -> ToolChunk:
- """Wait until the caller releases the test tool."""
- nonlocal side_effects
- entered.set()
- await release.wait()
- side_effects += value
- return ToolChunk(
- content=[TextBlock(text=f"value={value}")],
- state=ToolResultState.SUCCESS,
- )
-
- class BlockingToolModel:
- model = "blocking-tool"
- context_size = 32768
-
- async def __call__(self, messages, tools=None, **_kwargs):
- del messages
- assert any(item["function"]["name"] == "blocking_probe" for item in tools)
- return ChatResponse(
- content=[
- ToolCallBlock(
- id="cancel-call",
- name="blocking_probe",
- input='{"value":1}',
- )
- ],
- is_last=False,
- usage=ChatUsage(input_tokens=1, output_tokens=1, time=0.01),
- )
-
- async def count_tokens(self, messages, tools=None):
- del messages, tools
- return 1
-
- agent = Agent(
- name="ledger-cancel-seam",
- system_prompt="Use the blocking probe.",
- model=BlockingToolModel(),
- toolkit=Toolkit(tools=[FunctionTool(blocking_probe)]),
- middlewares=[durable_adapter(sessions)],
- state=AgentRuntimeState(
- run_id="run-public-cancel",
- journal_owner="worker",
- permission_context=PermissionContext(mode=PermissionMode.BYPASS),
- ),
- react_config=ReActConfig(max_iters=2),
- )
- reply_task = asyncio.create_task(agent.reply(UserMsg(name="user", content="start")))
- await entered.wait()
- assert RunJournal(sessions).cancel("run-public-cancel", reason="test cancellation")
- reply_task.cancel()
- await asyncio.sleep(0)
- release.set()
- with pytest.raises(asyncio.CancelledError):
- await reply_task
- assert side_effects == 0
- with sessions() as db:
- run = db.get(ChatRun, "run-public-cancel")
- assert run.status == "cancelled"
- assert run.run_phase == "cancelled"
- assert [
- row.event_type
- for row in db.query(ToolEffectLedger)
- .filter(ToolEffectLedger.run_id == "run-public-cancel")
- .order_by(ToolEffectLedger.event_id)
- ] == ["intent"]
-
-
-@pytest.mark.asyncio
-async def test_restart_sweep_classifies_replay_reconcile_and_never_replay(effect_env):
- sessions, make_run = effect_env
- clock = MutableClock()
- registry = ToolRecoveryRegistry()
-
- async def replay(_intent):
- return {"replayed": True}
-
- registry.register("safe_read", "replay_safe", replayer=replay)
-
- async def reconcile(_intent):
- return {"external_status": "committed"}
-
- registry.register("external_write", "reconcile", reconciler=reconcile)
- ledger = ToolEffectJournal(sessions, clock=clock)
- effects = {}
- for run_id, tool_name, policy in (
- ("run-sweep-replay", "safe_read", "replay_safe"),
- ("run-sweep-reconcile", "external_write", "reconcile"),
- ("run-sweep-never", "unknown_write", "never_replay"),
- ):
- make_run(run_id)
- effects[run_id] = ledger.begin_intent(
- run_id=run_id,
- owner="worker",
- claim_owner="crashed-tool",
- tool_call_id=f"call-{run_id}",
- tool_name=tool_name,
- args={"target": run_id},
- recovery_policy=policy,
- idempotency_key=f"key-{run_id}",
- lease_seconds=1,
- ).effect_id
- clock.advance(2)
- with sessions() as db:
- db.query(ChatRun).filter(ChatRun.run_id.in_(effects)).update(
- {"lease_expires_at": clock() - timedelta(seconds=1)},
- synchronize_session=False,
- )
- db.commit()
-
- decisions = await recover_incomplete_tool_effects(journal=ledger, registry=registry)
- assert {(item.run_id, item.action) for item in decisions} == {
- ("run-sweep-replay", "replayed"),
- ("run-sweep-reconcile", "reconciled"),
- ("run-sweep-never", "needs_attention"),
- }
- with sessions() as db:
- assert db.get(ChatRun, "run-sweep-replay").status == "running"
- assert db.get(ChatRun, "run-sweep-reconcile").run_phase == "tool_result_committed"
- assert db.get(ChatRun, "run-sweep-never").status == "needs_attention"
- reconciled = (
- db.query(ToolEffectLedger)
- .filter(
- ToolEffectLedger.effect_id == effects["run-sweep-reconcile"],
- ToolEffectLedger.event_type == "result",
- )
- .one()
- )
- assert reconciled.result_payload == {"external_status": "committed"}
-
-
-@pytest.mark.asyncio
-async def test_recovery_adapter_failure_does_not_starve_later_intent(effect_env):
- sessions, make_run = effect_env
- clock = MutableClock()
- registry = ToolRecoveryRegistry()
-
- async def broken(_intent):
- raise RuntimeError("temporary adapter outage")
-
- async def healthy(_intent):
- return {"ok": True}
-
- registry.register("broken_read", "replay_safe", replayer=broken)
- registry.register("healthy_read", "replay_safe", replayer=healthy)
- ledger = ToolEffectJournal(sessions, clock=clock)
- effects = []
- for run_id, tool_name in (("run-broken", "broken_read"), ("run-healthy", "healthy_read")):
- make_run(run_id)
- effects.append(
- ledger.begin_intent(
- run_id=run_id,
- owner="worker",
- claim_owner="crashed",
- tool_call_id=f"call-{run_id}",
- tool_name=tool_name,
- args={"q": run_id},
- recovery_policy="replay_safe",
- lease_seconds=1,
- ).effect_id
- )
- clock.advance(2)
- with sessions() as db:
- db.query(ChatRun).filter(ChatRun.run_id.in_(["run-broken", "run-healthy"])).update(
- {"lease_expires_at": clock() - timedelta(seconds=1)}, synchronize_session=False
- )
- db.commit()
- decisions = await recover_incomplete_tool_effects(journal=ledger, registry=registry)
- assert {(item.run_id, item.action) for item in decisions} == {
- ("run-broken", "retry"),
- ("run-healthy", "replayed"),
- }
- assert ledger.terminal_decision(effects[0]) is None
- assert ledger.terminal_decision(effects[1]).result == {"ok": True}
- run_recovery = RunJournal(sessions, clock=clock).recover()
- assert "run-broken" not in {item.run_id for item in run_recovery}
-
-
-@pytest.mark.asyncio
-async def test_recovery_heartbeat_prevents_second_sweep_from_replaying_long_adapter(effect_env):
- sessions, make_run = effect_env
- make_run("run-long-recovery")
- ledger = ToolEffectJournal(sessions)
- intent = ledger.begin_intent(
- run_id="run-long-recovery",
- owner="worker",
- claim_owner="crashed",
- tool_call_id="long-recovery-call",
- tool_name="long_read",
- args={"query": "slow"},
- recovery_policy="replay_safe",
- lease_seconds=1,
- )
- expired = datetime.now(timezone.utc) - timedelta(seconds=1)
- with sessions() as db:
- db.query(ChatRun).filter(ChatRun.run_id == "run-long-recovery").update(
- {"lease_expires_at": expired}
- )
- db.query(ToolEffectLease).filter(ToolEffectLease.effect_id == intent.effect_id).update(
- {"lease_expires_at": expired}
- )
- db.commit()
-
- started = asyncio.Event()
- release = asyncio.Event()
- replays = 0
-
- async def replay(_intent):
- nonlocal replays
- replays += 1
- started.set()
- await release.wait()
- return {"ok": True}
-
- registry = ToolRecoveryRegistry()
- registry.register("long_read", "replay_safe", replayer=replay)
- first = asyncio.create_task(
- recover_incomplete_tool_effects(
- journal=ledger,
- registry=registry,
- lease_seconds=1,
- heartbeat_interval=0.1,
- )
- )
- await started.wait()
- await asyncio.sleep(1.1)
- second = await recover_incomplete_tool_effects(
- journal=ledger,
- registry=registry,
- lease_seconds=1,
- heartbeat_interval=0.1,
- )
- assert second == []
- assert replays == 1
- release.set()
- decisions = await first
- assert [(item.effect_id, item.action) for item in decisions] == [(intent.effect_id, "replayed")]
-
-
-def test_tool_call_log_is_a_projection_linked_to_authoritative_effect(effect_env, monkeypatch):
- sessions, make_run = effect_env
- make_run("run-projection")
- ledger = ToolEffectJournal(sessions)
- intent = ledger.begin_intent(
- run_id="run-projection",
- owner="worker",
- claim_owner="projection-invocation",
- tool_call_id="projection-call",
- tool_name="Read",
- args={"path": "/workspace/a.txt"},
- recovery_policy="replay_safe",
- )
- ledger.commit_result(
- intent.effect_id,
- run_owner="worker",
- claim_owner="projection-invocation",
- result={"ok": True},
- )
-
- from core.harness import log_writer as log_service
-
- monkeypatch.setattr(log_records, "SessionLocal", sessions)
- projection_secret = "projection-secret-token-123456"
- log_records.write_tool_call_sync(
- {
- "run_id": "run-projection",
- "chat_id": "chat-1",
- "user_id": "user-1",
- "tool_name": "Read",
- "tool_call_id": "projection-call",
- "tool_args": {
- "path": "/workspace/a.txt",
- "command": f"curl -H 'Authorization: Bearer {projection_secret}' https://x",
- "url": f"https://example.test/?token={projection_secret}",
- "callback": f"https://example.test/cb?password={projection_secret}",
- },
- "tool_result": "contents",
- "status": "success",
- }
- )
- with sessions() as db:
- projection = db.query(ToolCallLog).one()
- assert projection.effect_id == intent.effect_id
- assert projection.id == intent.intent.result_id
- assert projection.tool_result == "contents"
- assert projection_secret not in json.dumps(projection.tool_args)
- assert projection.tool_args["command"]["_withheld"] is True
- assert "[REDACTED]" in projection.tool_args["url"]
- assert "[REDACTED]" in projection.tool_args["callback"]
-
- log_records.write_tool_call_sync(
- {
- "run_id": "run-projection",
- "chat_id": "chat-1",
- "user_id": "user-1",
- "tool_name": "Read",
- "tool_call_id": "new-provider-call-after-recovery",
- "effect_id": intent.effect_id,
- "result_id": intent.intent.result_id,
- "tool_args": {"path": "/workspace/a.txt"},
- "tool_result": "replayed contents",
- "status": "success",
- }
- )
- with sessions() as db:
- assert db.query(ToolCallLog).count() == 1
- projection = db.query(ToolCallLog).one()
- assert projection.id == intent.intent.result_id
- assert projection.tool_call_id == "new-provider-call-after-recovery"
- assert projection.tool_result == "replayed contents"
-
-
-def test_prune_settled_drops_only_old_rows_of_settled_runs(effect_env):
- sessions, make_run = effect_env
- clock = MutableClock()
- ledger = ToolEffectJournal(sessions, clock=clock)
- make_run("run-old")
- make_run("run-live", chat_id="chat-2")
- old = ledger.begin_intent(
- run_id="run-old",
- owner="worker",
- claim_owner="invocation",
- tool_call_id="call-old",
- tool_name="write",
- args={"path": "/tmp/a"},
- recovery_policy="never_replay",
- idempotency_key="prune-old",
- )
- ledger.commit_result(
- old.effect_id,
- run_owner="worker",
- claim_owner="invocation",
- result={"ok": True},
- )
- live = ledger.begin_intent(
- run_id="run-live",
- owner="worker",
- claim_owner="invocation",
- tool_call_id="call-live",
- tool_name="write",
- args={"path": "/tmp/b"},
- recovery_policy="never_replay",
- idempotency_key="prune-live",
- )
- from core.db.models import ChatRunOperation, HarnessEventLog, HarnessUsageAttempt
-
- with sessions() as db:
- run = db.get(ChatRun, "run-old")
- run.status = "completed"
- db.add(
- HarnessUsageAttempt(
- attempt_id="hat-old",
- run_id="run-old",
- attempt_seq=1,
- kind="tool",
- operation_name="write",
- status="success",
- )
- )
- db.add(
- HarnessEventLog(
- event_id="hev-old",
- run_id="run-old",
- event_seq=1,
- event_type="tool_result",
- phase="post",
- payload={},
- )
- )
- db.commit()
- clock.advance(8 * 24 * 3600)
- assert ledger.prune_settled() >= 4
- with sessions() as db:
- for model in (
- ToolEffectLedger,
- HarnessUsageAttempt,
- HarnessEventLog,
- ChatRunOperation,
- ):
- assert db.query(model).filter(model.run_id == "run-old").count() == 0
- assert (
- db.query(ToolEffectLedger)
- .filter(ToolEffectLedger.run_id == "run-live")
- .count()
- == 1
- )
- assert (
- db.query(ChatRunOperation)
- .filter(ChatRunOperation.run_id == "run-live")
- .count()
- > 0
- )
- assert ledger.pending_effect_ids() == [live.effect_id]
-
-
-def test_prune_settled_eventually_reclaims_needs_attention_runs(effect_env):
- """needs_attention 保留得更久,但不是永远。
-
- 原先它被整个排除在清理之外,等于永不回收:生产库里每一条超出保留期的
- journal 记录——四万条——都属于 needs_attention 的 run,被永久钉住,还要被
- 每五分钟一轮的清理反复全表扫。保留期该是"更长",不是"无限"。
- """
- from core.db.models import HarnessEventLog
- from core.runs.tool_effects.journal_state import ATTENTION_RETENTION_FACTOR as _ATTENTION_RETENTION_FACTOR
-
- sessions, make_run = effect_env
- clock = MutableClock()
- ledger = ToolEffectJournal(sessions, clock=clock)
- make_run("run-attention")
- intent = ledger.begin_intent(
- run_id="run-attention",
- owner="worker",
- claim_owner="invocation",
- tool_call_id="call-attention",
- tool_name="write",
- args={"path": "/tmp/a"},
- recovery_policy="never_replay",
- idempotency_key="prune-attention",
- )
- ledger.commit_result(
- intent.effect_id,
- run_owner="worker",
- claim_owner="invocation",
- result={"ok": True},
- )
- with sessions() as db:
- run = db.get(ChatRun, "run-attention")
- run.status = "needs_attention"
- # recovery_snapshot 是整轮对话的快照,库里最大的单个字段;它跟 journal
- # 走同一套分档,不能因为状态是 needs_attention 就永久留着
- run.completed_at = clock.now
- db.add(
- HarnessEventLog(
- event_id="hev-attention",
- run_id="run-attention",
- event_seq=1,
- event_type="tool_result",
- phase="post",
- payload={},
- )
- )
- db.commit()
-
- # 保留期显式传入,两次推进都从它算——否则 prune_settled 的默认值一改,
- # 这里的天数就悄悄测不到边界了
- retention_days = 7
-
- # 刚过普通终态的保留期:记录必须还在
- clock.advance((retention_days + 1) * 24 * 3600)
- ledger.prune_settled(retention_days=retention_days)
- with sessions() as db:
- assert (
- db.query(ToolEffectLedger).filter(ToolEffectLedger.run_id == "run-attention").count()
- > 0
- )
- assert (
- db.query(HarnessEventLog).filter(HarnessEventLog.run_id == "run-attention").count() == 1
- )
- assert db.get(ChatRun, "run-attention").recovery_snapshot is not None
-
- # 过了加长保留期:回收,不能无限期占着库
- clock.advance(retention_days * _ATTENTION_RETENTION_FACTOR * 24 * 3600)
- ledger.prune_settled(retention_days=retention_days)
- with sessions() as db:
- assert (
- db.query(ToolEffectLedger).filter(ToolEffectLedger.run_id == "run-attention").count()
- == 0
- )
- assert (
- db.query(HarnessEventLog).filter(HarnessEventLog.run_id == "run-attention").count() == 0
- )
- assert db.get(ChatRun, "run-attention").recovery_snapshot is None
diff --git a/src/backend/tests/orchestration/test_tool_effect_ledger_2.py b/src/backend/tests/orchestration/test_tool_effect_ledger_2.py
new file mode 100644
index 00000000..45944831
--- /dev/null
+++ b/src/backend/tests/orchestration/test_tool_effect_ledger_2.py
@@ -0,0 +1,429 @@
+from tests.orchestration.tool_effect_support import (
+ log_records,
+ asyncio,
+ json,
+ ThreadPoolExecutor,
+ datetime,
+ timedelta,
+ timezone,
+ Barrier,
+ SimpleNamespace,
+ pytest,
+ Agent,
+ ReActConfig,
+ TextBlock,
+ ToolCallBlock,
+ ToolResultState,
+ UserMsg,
+ ChatResponse,
+ ChatUsage,
+ PermissionContext,
+ PermissionMode,
+ FunctionTool,
+ Toolkit,
+ ToolChunk,
+ ToolResponse,
+ Base,
+ ChatRun,
+ ChatSession,
+ ToolCallLog,
+ ToolEffectLease,
+ ToolEffectLedger,
+ ToolEffectReceipt,
+ ScheduledTask,
+ AgentRuntimeState,
+ make_adapter,
+ durable_adapter,
+ _plain_replay_args,
+ reconcile_scheduled_task_intent,
+ RunJournal,
+ DEFAULT_TOOL_RECOVERY_REGISTRY,
+ ToolRecoveryRegistry,
+ ToolEffectError,
+ ToolIdempotencyConflict,
+ ToolIntentCommitError,
+ ToolOutcomeUnknown,
+ ToolEffectGateway,
+ ToolEffectJournal,
+ ToolIntent,
+ recover_incomplete_tool_effects,
+ create_engine,
+ sessionmaker,
+ QueuePool,
+ MutableClock,
+ effect_env,
+)
+
+@pytest.mark.asyncio
+async def test_startup_replayer_does_not_alias_a_later_same_args_provider_call(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-startup-replay")
+ clock = MutableClock()
+ replayed = 0
+
+ async def replay(_intent):
+ nonlocal replayed
+ replayed += 1
+ return {"from_adapter": True}
+
+ registry = ToolRecoveryRegistry()
+ registry.register("safe_read", "replay_safe", replayer=replay)
+ ledger = ToolEffectJournal(sessions, clock=clock)
+ original = ledger.begin_intent(
+ run_id="run-startup-replay",
+ owner="worker",
+ claim_owner="crashed",
+ tool_call_id="old-provider-id",
+ tool_name="safe_read",
+ args={"query": "same"},
+ recovery_policy="replay_safe",
+ lease_seconds=1,
+ )
+ clock.advance(2)
+ with sessions() as db:
+ db.query(ChatRun).filter(ChatRun.run_id == "run-startup-replay").update(
+ {"lease_expires_at": clock() - timedelta(seconds=1)}
+ )
+ db.commit()
+ decisions = await recover_incomplete_tool_effects(journal=ledger, registry=registry)
+ assert [(item.effect_id, item.action) for item in decisions] == [
+ (original.effect_id, "replayed")
+ ]
+ assert RunJournal(sessions, clock=clock).claim(
+ "run-startup-replay", owner="new-worker", lease_seconds=300
+ )
+ invoked = 0
+
+ async def later_call():
+ nonlocal invoked
+ invoked += 1
+ return {"wrong": True}
+
+ outcome = await ToolEffectGateway(ledger, registry).execute_outcome(
+ run_id="run-startup-replay",
+ owner="new-worker",
+ tool_call_id="new-provider-id",
+ tool_name="safe_read",
+ args={"query": "same"},
+ invoke=later_call,
+ )
+ assert outcome.result == {"wrong": True}
+ assert outcome.effect_id != original.effect_id
+ assert outcome.invoked is True
+ assert replayed == 1
+ assert invoked == 1
+
+
+@pytest.mark.asyncio
+async def test_invocation_heartbeat_keeps_long_call_claimed(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-fenced", owner="old-worker")
+ registry = ToolRecoveryRegistry()
+ registry.register("safe", "replay_safe")
+ ledger = ToolEffectJournal(sessions)
+ gateway = ToolEffectGateway(ledger, registry, poll_interval=0.005, lease_seconds=1)
+ entered = asyncio.Event()
+ release = asyncio.Event()
+ invoked = 0
+
+ async def slow_tool():
+ nonlocal invoked
+ invoked += 1
+ entered.set()
+ await release.wait()
+ return {"ok": True}
+
+ first = asyncio.create_task(
+ gateway.execute(
+ run_id="run-fenced",
+ owner="old-worker",
+ tool_call_id="call-old",
+ tool_name="safe",
+ args={"q": 1},
+ idempotency_key="fenced-key",
+ invoke=slow_tool,
+ )
+ )
+ await entered.wait()
+ await asyncio.sleep(1.2)
+ assert not RunJournal(sessions).claim("run-fenced", owner="new-worker", lease_seconds=300)
+ assert invoked == 1
+ release.set()
+ assert await first == {"ok": True}
+ assert invoked == 1
+
+
+def test_sqlite_atomic_recovery_claim_has_one_winner(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-sqlite-cas")
+ ledger = ToolEffectJournal(sessions)
+ intent = ledger.begin_intent(
+ run_id="run-sqlite-cas",
+ owner="worker",
+ claim_owner="crashed",
+ tool_call_id="sqlite-cas-call",
+ tool_name="safe",
+ args={"q": 1},
+ recovery_policy="replay_safe",
+ lease_seconds=1,
+ )
+ expired = datetime.now(timezone.utc) - timedelta(seconds=1)
+ with sessions() as db:
+ db.query(ChatRun).filter(ChatRun.run_id == "run-sqlite-cas").update(
+ {"lease_expires_at": expired}
+ )
+ db.query(ToolEffectLease).filter(ToolEffectLease.effect_id == intent.effect_id).update(
+ {"lease_expires_at": expired}
+ )
+ db.commit()
+
+ barrier = Barrier(2)
+
+ def claim(owner: str):
+ barrier.wait()
+ return ToolEffectJournal(sessions).claim_recovery_intent(
+ intent.effect_id,
+ recovery_owner=owner,
+ lease_seconds=30,
+ )
+
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ results = list(pool.map(claim, ("recovery-a", "recovery-b")))
+ assert sum(result is not None for result in results) == 1
+
+
+@pytest.mark.asyncio
+async def test_reconcile_intent_queries_adapter_before_execution(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-reconcile")
+ clock = MutableClock()
+ reconciled = 0
+
+ async def reconcile(intent):
+ nonlocal reconciled
+ reconciled += 1
+ assert intent.redacted_args == {"amount": 8}
+ return {"external_status": "already_applied"}
+
+ registry = ToolRecoveryRegistry()
+ registry.register("charge_account", "reconcile", reconciler=reconcile)
+ ledger = ToolEffectJournal(sessions, clock=clock)
+ ledger.begin_intent(
+ run_id="run-reconcile",
+ owner="worker",
+ claim_owner="crashed-invocation",
+ tool_call_id="old-call",
+ tool_name="charge_account",
+ args={"amount": 8},
+ recovery_policy="reconcile",
+ idempotency_key="charge-key",
+ lease_seconds=1,
+ )
+ clock.advance(2)
+ with sessions() as db:
+ db.query(ChatRun).filter(ChatRun.run_id == "run-reconcile").update(
+ {"lease_expires_at": clock() - timedelta(seconds=1)}
+ )
+ db.commit()
+ assert RunJournal(sessions, clock=clock).claim(
+ "run-reconcile", owner="recovery-worker", lease_seconds=300
+ )
+ invoked = 0
+
+ async def must_not_execute():
+ nonlocal invoked
+ invoked += 1
+ return {"wrong": True}
+
+ result = await ToolEffectGateway(ledger, registry).execute(
+ run_id="run-reconcile",
+ owner="recovery-worker",
+ tool_call_id="new-call",
+ tool_name="charge_account",
+ args={"amount": 8},
+ idempotency_key="charge-key",
+ invoke=must_not_execute,
+ )
+ assert result == {"external_status": "already_applied"}
+ assert reconciled == 1
+ assert invoked == 0
+
+
+@pytest.mark.asyncio
+async def test_unknown_tool_defaults_never_replay_and_pauses_run(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-unknown")
+ clock = MutableClock()
+ registry = ToolRecoveryRegistry()
+ assert registry.resolve("unregistered_write").policy == "never_replay"
+ ledger = ToolEffectJournal(sessions, clock=clock)
+ ledger.begin_intent(
+ run_id="run-unknown",
+ owner="worker",
+ claim_owner="crashed-invocation",
+ tool_call_id="old-call",
+ tool_name="unregistered_write",
+ args={"target": "outside"},
+ recovery_policy="never_replay",
+ idempotency_key="unknown-key",
+ lease_seconds=1,
+ )
+ clock.advance(2)
+ with sessions() as db:
+ db.query(ChatRun).filter(ChatRun.run_id == "run-unknown").update(
+ {"lease_expires_at": clock() - timedelta(seconds=1)}
+ )
+ db.commit()
+ assert RunJournal(sessions, clock=clock).claim(
+ "run-unknown", owner="recovery-worker", lease_seconds=300
+ )
+ invoked = 0
+
+ async def must_not_execute():
+ nonlocal invoked
+ invoked += 1
+ return {"wrong": True}
+
+ with pytest.raises(ToolOutcomeUnknown):
+ await ToolEffectGateway(ledger, registry).execute(
+ run_id="run-unknown",
+ owner="recovery-worker",
+ tool_call_id="new-call",
+ tool_name="unregistered_write",
+ args={"target": "outside"},
+ idempotency_key="unknown-key",
+ invoke=must_not_execute,
+ )
+ assert invoked == 0
+ with sessions() as db:
+ assert db.get(ChatRun, "run-unknown").status == "needs_attention"
+ events = (
+ db.query(ToolEffectLedger)
+ .filter(ToolEffectLedger.run_id == "run-unknown")
+ .order_by(ToolEffectLedger.created_at)
+ .all()
+ )
+ assert [event.event_type for event in events] == [
+ "intent",
+ "recovery_claim",
+ "unknown_outcome",
+ ]
+ with pytest.raises(ToolOutcomeUnknown):
+ await ToolEffectGateway(ledger, registry).execute(
+ run_id="run-unknown",
+ owner="recovery-worker",
+ tool_call_id="another-call",
+ tool_name="unregistered_write",
+ args={"target": "outside"},
+ idempotency_key="unknown-key",
+ invoke=must_not_execute,
+ )
+
+
+def test_result_commit_retry_is_idempotent_and_args_are_redacted(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-result")
+ ledger = ToolEffectJournal(sessions)
+ prepared = ledger.begin_intent(
+ run_id="run-result",
+ owner="worker",
+ claim_owner="invocation",
+ tool_call_id="call-result",
+ tool_name="write",
+ args={"path": "/tmp/a", "password": "super-secret"},
+ recovery_policy="never_replay",
+ idempotency_key="result-key",
+ )
+ first = ledger.commit_result(
+ prepared.effect_id,
+ run_owner="worker",
+ claim_owner="invocation",
+ result={"ok": True},
+ )
+ second = ledger.commit_result(
+ prepared.effect_id,
+ run_owner="worker",
+ claim_owner="invocation",
+ result={"ok": True},
+ )
+ assert first == second == {"ok": True}
+ with sessions() as db:
+ events = (
+ db.query(ToolEffectLedger)
+ .filter(ToolEffectLedger.effect_id == prepared.effect_id)
+ .order_by(ToolEffectLedger.created_at)
+ .all()
+ )
+ assert [event.event_type for event in events] == ["intent", "result"]
+ assert events[0].redacted_args["password"] != "super-secret"
+ assert "super-secret" not in str(events[0].redacted_args)
+ assert len(events[0].args_hash) == 64
+
+
+def test_concurrent_result_commit_retry_is_idempotent(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-result-race")
+ ledger = ToolEffectJournal(sessions)
+ prepared = ledger.begin_intent(
+ run_id="run-result-race",
+ owner="worker",
+ claim_owner="same-claim",
+ tool_call_id="result-race",
+ tool_name="Read",
+ args={"path": "/a"},
+ recovery_policy="replay_safe",
+ )
+ barrier = Barrier(2)
+
+ def commit():
+ barrier.wait(timeout=5)
+ return ledger.commit_result(
+ prepared.effect_id,
+ run_owner="worker",
+ claim_owner="same-claim",
+ result={"ok": True},
+ )
+
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ assert list(pool.map(lambda _index: commit(), range(2))) == [
+ {"ok": True},
+ {"ok": True},
+ ]
+ with sessions() as db:
+ assert (
+ db.query(ToolEffectLedger)
+ .filter(
+ ToolEffectLedger.effect_id == prepared.effect_id,
+ ToolEffectLedger.event_type == "result",
+ )
+ .count()
+ == 1
+ )
+
+
+def test_default_recovery_policies_have_real_adapters():
+ assert DEFAULT_TOOL_RECOVERY_REGISTRY.resolve("Read").replayer is not None
+ scheduled = DEFAULT_TOOL_RECOVERY_REGISTRY.resolve("create_scheduled_task")
+ assert scheduled.policy == "reconcile"
+ assert scheduled.reconciler is not None
+ assert scheduled.replayer is not None
+ assert DEFAULT_TOOL_RECOVERY_REGISTRY.resolve("unregistered_write").policy == "never_replay"
+
+
+def test_recovery_rejects_nested_withheld_arguments():
+ intent = ToolIntent(
+ effect_id="eff-nested-secret",
+ result_id="result-nested-secret",
+ run_id="run-nested-secret",
+ operation_seq=1,
+ tool_name="Read",
+ tool_call_id="call-nested-secret",
+ args_hash="hash",
+ redacted_args={
+ "request": {"steps": [{"command": {"_withheld": True, "sha256": "abc", "length": 3}}]}
+ },
+ idempotency_key="key",
+ recovery_policy="replay_safe",
+ )
+ with pytest.raises(ToolEffectError, match="withheld sensitive free text"):
+ _plain_replay_args(intent)
diff --git a/src/backend/tests/orchestration/test_tool_effect_ledger_3.py b/src/backend/tests/orchestration/test_tool_effect_ledger_3.py
new file mode 100644
index 00000000..77549da5
--- /dev/null
+++ b/src/backend/tests/orchestration/test_tool_effect_ledger_3.py
@@ -0,0 +1,475 @@
+from tests.orchestration.tool_effect_support import (
+ log_records,
+ asyncio,
+ json,
+ ThreadPoolExecutor,
+ datetime,
+ timedelta,
+ timezone,
+ Barrier,
+ SimpleNamespace,
+ pytest,
+ Agent,
+ ReActConfig,
+ TextBlock,
+ ToolCallBlock,
+ ToolResultState,
+ UserMsg,
+ ChatResponse,
+ ChatUsage,
+ PermissionContext,
+ PermissionMode,
+ FunctionTool,
+ Toolkit,
+ ToolChunk,
+ ToolResponse,
+ Base,
+ ChatRun,
+ ChatSession,
+ ToolCallLog,
+ ToolEffectLease,
+ ToolEffectLedger,
+ ToolEffectReceipt,
+ ScheduledTask,
+ AgentRuntimeState,
+ make_adapter,
+ durable_adapter,
+ _plain_replay_args,
+ reconcile_scheduled_task_intent,
+ RunJournal,
+ DEFAULT_TOOL_RECOVERY_REGISTRY,
+ ToolRecoveryRegistry,
+ ToolEffectError,
+ ToolIdempotencyConflict,
+ ToolIntentCommitError,
+ ToolOutcomeUnknown,
+ ToolEffectGateway,
+ ToolEffectJournal,
+ ToolIntent,
+ recover_incomplete_tool_effects,
+ create_engine,
+ sessionmaker,
+ QueuePool,
+ MutableClock,
+ effect_env,
+)
+
+@pytest.mark.asyncio
+async def test_scheduled_create_reconciliation_uses_exact_atomic_receipt(
+ effect_env,
+ monkeypatch,
+):
+ sessions, make_run = effect_env
+ make_run("run-scheduled-receipt")
+ ledger = ToolEffectJournal(sessions)
+ intent = ledger.begin_intent(
+ run_id="run-scheduled-receipt",
+ owner="worker",
+ claim_owner="scheduled-call",
+ tool_call_id="scheduled-call",
+ tool_name="create_scheduled_task",
+ args={
+ "cron_expression": "0 9 * * *",
+ "prompt": "send report",
+ "name": "daily",
+ },
+ recovery_policy="reconcile",
+ ).intent
+ with sessions() as db:
+ db.add(
+ ScheduledTask(
+ task_id="pre-existing-same-content",
+ user_id="user-1",
+ task_type="prompt",
+ prompt="send report",
+ cron_expression="0 9 * * *",
+ name="daily",
+ status="active",
+ )
+ )
+ db.commit()
+
+ import core.db.engine as engine_module
+ from extensions.mcp_servers.automation_task_mcp import impl
+
+ monkeypatch.setattr(engine_module, "SessionLocal", sessions)
+ before = await reconcile_scheduled_task_intent(intent)
+ assert before.outcome == "not_applied"
+
+ result = impl.create_task(
+ user_id="user-1",
+ cron_expression="0 9 * * *",
+ prompt="send report",
+ name="daily",
+ tool_effect_id=intent.effect_id,
+ )
+ assert result["ok"] is True
+ again = impl.create_task(
+ user_id="user-1",
+ cron_expression="0 9 * * *",
+ prompt="send report",
+ name="daily",
+ tool_effect_id=intent.effect_id,
+ )
+ assert again == result
+ after = await reconcile_scheduled_task_intent(intent)
+ assert after.outcome == "applied"
+ assert after.result == result
+ with sessions() as db:
+ assert db.query(ScheduledTask).count() == 2
+ assert db.get(ToolEffectReceipt, intent.effect_id).result_payload == result
+
+
+def test_embedded_secrets_and_free_text_are_not_persisted(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-secrets")
+ secret = "sk-123456789012345678901234567890"
+ ledger = ToolEffectJournal(sessions)
+ intent = ledger.begin_intent(
+ run_id="run-secrets",
+ owner="worker",
+ claim_owner="invocation",
+ tool_call_id="secret-call",
+ tool_name="Bash",
+ args={
+ "command": f"curl -H 'Authorization: Bearer {secret}' https://x",
+ "url": f"https://example.test/?token={secret}",
+ },
+ recovery_policy="never_replay",
+ )
+ with sessions() as db:
+ row = (
+ db.query(ToolEffectLedger)
+ .filter(
+ ToolEffectLedger.effect_id == intent.effect_id,
+ ToolEffectLedger.event_type == "intent",
+ )
+ .one()
+ )
+ assert secret not in json.dumps(row.redacted_args)
+ assert row.redacted_args["command"]["_withheld"] is True
+ assert "[REDACTED]" in row.redacted_args["url"]
+
+
+@pytest.mark.asyncio
+async def test_missing_run_binding_fails_closed_before_tool_invocation(effect_env):
+ sessions, _make_run = effect_env
+ middleware = durable_adapter(sessions)
+ agent = SimpleNamespace(state=SimpleNamespace(run_id=None, journal_owner=None))
+ invoked = 0
+
+ async def tool(**_kwargs):
+ nonlocal invoked
+ invoked += 1
+ yield ToolResponse(content=[TextBlock(text="wrong")])
+
+ with pytest.raises(ToolIntentCommitError):
+ _ = [
+ item
+ async for item in middleware.on_acting(
+ agent,
+ {"tool_call": ToolCallBlock(id="x", name="Read", input="{}")},
+ tool,
+ )
+ ]
+ assert invoked == 0
+
+
+@pytest.mark.asyncio
+async def test_middleware_leaves_adapter_exception_pending_for_recovery(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-tool-failure")
+ middleware = durable_adapter(sessions)
+ agent = SimpleNamespace(
+ state=SimpleNamespace(
+ run_id="run-tool-failure",
+ journal_owner="worker",
+ tool_effect_links={},
+ )
+ )
+
+ async def broken(**_kwargs):
+ if False:
+ yield None
+ raise RuntimeError("adapter exploded")
+
+ with pytest.raises(ToolOutcomeUnknown) as raised:
+ _ = [
+ item
+ async for item in middleware.on_acting(
+ agent,
+ {"tool_call": ToolCallBlock(id="failure-call", name="Read", input="{}")},
+ broken,
+ )
+ ]
+ assert isinstance(raised.value.__cause__, RuntimeError)
+ with sessions() as db:
+ events = db.query(ToolEffectLedger).order_by(ToolEffectLedger.event_id).all()
+ assert [event.event_type for event in events] == ["intent"]
+
+
+@pytest.mark.asyncio
+async def test_cancelled_run_settles_pending_intent_without_delayed_replay(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-cancelled-effect")
+ registry = ToolRecoveryRegistry()
+ invoked = 0
+
+ async def replay(_intent):
+ nonlocal invoked
+ invoked += 1
+ return {"should_not": "run"}
+
+ registry.register("Read", "replay_safe", replayer=replay)
+ ledger = ToolEffectJournal(sessions)
+ ledger.begin_intent(
+ run_id="run-cancelled-effect",
+ owner="worker",
+ claim_owner="dead-call",
+ tool_call_id="cancelled-call",
+ tool_name="Read",
+ args={"path": "/workspace/a.txt"},
+ recovery_policy="replay_safe",
+ )
+ assert RunJournal(sessions).cancel("run-cancelled-effect", reason="user cancelled")
+
+ decisions = await recover_incomplete_tool_effects(journal=ledger, registry=registry)
+
+ assert [item.action for item in decisions] == ["terminal_unknown"]
+ assert invoked == 0
+ with sessions() as db:
+ run = db.get(ChatRun, "run-cancelled-effect")
+ events = (
+ db.query(ToolEffectLedger)
+ .filter(ToolEffectLedger.run_id == run.run_id)
+ .order_by(ToolEffectLedger.event_id)
+ .all()
+ )
+ assert run.status == "cancelled"
+ assert run.run_phase == "cancelled"
+ assert [item.event_type for item in events] == ["intent", "unknown_outcome"]
+
+
+@pytest.mark.asyncio
+async def test_middleware_commits_error_tool_response_as_failure(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-tool-error-response")
+ middleware = durable_adapter(sessions)
+ agent = SimpleNamespace(
+ state=SimpleNamespace(
+ run_id="run-tool-error-response",
+ journal_owner="worker",
+ tool_effect_links={},
+ )
+ )
+
+ async def failed_response(**_kwargs):
+ yield ToolResponse(
+ content=[TextBlock(text="tool rejected input")],
+ state=ToolResultState.ERROR,
+ )
+
+ output = [
+ item
+ async for item in middleware.on_acting(
+ agent,
+ {"tool_call": ToolCallBlock(id="error-response-call", name="Read", input="{}")},
+ failed_response,
+ )
+ ]
+ assert output[-1].state == ToolResultState.ERROR
+ with sessions() as db:
+ events = db.query(ToolEffectLedger).order_by(ToolEffectLedger.event_id).all()
+ assert [event.event_type for event in events] == ["intent", "failure"]
+
+
+@pytest.mark.asyncio
+async def test_middleware_returns_explicit_error_for_unregistered_tool(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-unregistered-tool-error")
+ middleware = durable_adapter(sessions)
+ agent = SimpleNamespace(
+ state=SimpleNamespace(
+ run_id="run-unregistered-tool-error",
+ journal_owner="worker",
+ tool_effect_links={},
+ )
+ )
+ original_error = "Remote API returned 404: resource not found or not authorized"
+
+ async def failed_response(**_kwargs):
+ yield ToolResponse(
+ content=[TextBlock(text=original_error)],
+ state=ToolResultState.ERROR,
+ )
+
+ output = [
+ item
+ async for item in middleware.on_acting(
+ agent,
+ {
+ "tool_call": ToolCallBlock(
+ id="unregistered-error-call",
+ name="remote_resource_lookup",
+ input='{"resource":"missing-or-private"}',
+ )
+ },
+ failed_response,
+ )
+ ]
+
+ assert output[-1].state == ToolResultState.ERROR
+ assert original_error in str(output[-1].content)
+ with sessions() as db:
+ events = db.query(ToolEffectLedger).order_by(ToolEffectLedger.event_id).all()
+ assert [event.event_type for event in events] == ["intent", "failure"]
+ assert events[0].recovery_policy == "never_replay"
+ assert original_error in str(events[-1].result_payload)
+
+
+@pytest.mark.asyncio
+async def test_agentscope_middleware_commits_intent_before_call_and_replays_same_shape(
+ effect_env,
+):
+ sessions, make_run = effect_env
+ make_run("run-middleware")
+ middleware = durable_adapter(sessions)
+ agent = SimpleNamespace(state=SimpleNamespace(run_id="run-middleware", journal_owner="worker"))
+ invoked = 0
+ saw_durable_intent = False
+
+ async def execute_once(**_kwargs):
+ nonlocal invoked, saw_durable_intent
+ invoked += 1
+ with sessions() as db:
+ saw_durable_intent = (
+ db.query(ToolEffectLedger)
+ .filter(
+ ToolEffectLedger.run_id == "run-middleware",
+ ToolEffectLedger.event_type == "intent",
+ )
+ .count()
+ == 1
+ )
+ block = TextBlock(text="same SSE-compatible result")
+ yield ToolChunk(content=[block], state=ToolResultState.RUNNING)
+ yield ToolResponse(content=[block], state=ToolResultState.SUCCESS)
+
+ first_call = ToolCallBlock(
+ id="provider-call-1",
+ name="Read",
+ input='{"path":"/workspace/a.txt"}',
+ )
+ first = [
+ item
+ async for item in middleware.on_acting(
+ agent,
+ {"tool_call": first_call},
+ execute_once,
+ )
+ ]
+
+ async def must_not_execute(**_kwargs):
+ nonlocal invoked
+ invoked += 1
+ yield ToolResponse(content=[TextBlock(text="wrong")])
+
+ replay_call = ToolCallBlock(
+ id="provider-call-1",
+ name="Read",
+ input='{"path":"/workspace/a.txt"}',
+ )
+ replayed = [
+ item
+ async for item in middleware.on_acting(
+ agent,
+ {"tool_call": replay_call},
+ must_not_execute,
+ )
+ ]
+
+ assert saw_durable_intent is True
+ assert invoked == 1
+ assert [type(item) for item in first] == [ToolChunk, ToolResponse]
+ assert [type(item) for item in replayed] == [ToolChunk, ToolResponse]
+ assert replayed[-1].state == ToolResultState.SUCCESS
+ assert replayed[-1].content[0].text == "same SSE-compatible result"
+
+
+@pytest.mark.asyncio
+async def test_real_agentscope_public_reply_commits_intent_before_adapter(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-public-agent")
+ saw_intent = False
+ invoked = 0
+
+ async def probe(value: int) -> ToolChunk:
+ """Return a test value."""
+ nonlocal saw_intent, invoked
+ invoked += 1
+ with sessions() as db:
+ saw_intent = (
+ db.query(ToolEffectLedger)
+ .filter(
+ ToolEffectLedger.run_id == "run-public-agent",
+ ToolEffectLedger.event_type == "intent",
+ )
+ .count()
+ == 1
+ )
+ return ToolChunk(
+ content=[TextBlock(text=f"value={value}")],
+ state=ToolResultState.SUCCESS,
+ )
+
+ class ToolThenAnswerModel:
+ from agentscope.formatter import OpenAIChatFormatter
+ formatter = OpenAIChatFormatter()
+ model = "tool-then-answer"
+ context_size = 32768
+
+ def __init__(self):
+ self.calls = 0
+
+ async def __call__(self, messages, tools=None, **_kwargs):
+ del messages
+ self.calls += 1
+ if self.calls == 1:
+ assert any(item["function"]["name"] == "probe" for item in tools)
+ return ChatResponse(
+ content=[ToolCallBlock(id="public-call", name="probe", input='{"value":7}')],
+ is_last=False,
+ usage=ChatUsage(input_tokens=1, output_tokens=1, time=0.01),
+ )
+ return ChatResponse(
+ content=[TextBlock(text="done")],
+ is_last=True,
+ usage=ChatUsage(input_tokens=1, output_tokens=1, time=0.01),
+ )
+
+ async def count_tokens(self, messages, tools=None):
+ del messages, tools
+ return 1
+
+ state = AgentRuntimeState(
+ run_id="run-public-agent",
+ journal_owner="worker",
+ permission_context=PermissionContext(mode=PermissionMode.BYPASS),
+ )
+ agent = Agent(
+ name="ledger-public-seam",
+ system_prompt="Use the probe tool once.",
+ model=ToolThenAnswerModel(),
+ toolkit=Toolkit(tools=[FunctionTool(probe)]),
+ middlewares=[durable_adapter(sessions)],
+ state=state,
+ react_config=ReActConfig(max_iters=2),
+ )
+ reply = await agent.reply(UserMsg(name="user", content="run probe"))
+ assert reply.get_text_content() == "done"
+ assert invoked == 1
+ assert saw_intent is True
+ with sessions() as db:
+ assert [
+ row.event_type for row in db.query(ToolEffectLedger).order_by(ToolEffectLedger.event_id)
+ ] == ["intent", "result"]
diff --git a/src/backend/tests/orchestration/test_tool_effect_ledger_4.py b/src/backend/tests/orchestration/test_tool_effect_ledger_4.py
new file mode 100644
index 00000000..8d2b031c
--- /dev/null
+++ b/src/backend/tests/orchestration/test_tool_effect_ledger_4.py
@@ -0,0 +1,459 @@
+from tests.orchestration.tool_effect_support import (
+ log_records,
+ asyncio,
+ json,
+ ThreadPoolExecutor,
+ datetime,
+ timedelta,
+ timezone,
+ Barrier,
+ SimpleNamespace,
+ pytest,
+ Agent,
+ ReActConfig,
+ TextBlock,
+ ToolCallBlock,
+ ToolResultState,
+ UserMsg,
+ ChatResponse,
+ ChatUsage,
+ PermissionContext,
+ PermissionMode,
+ FunctionTool,
+ Toolkit,
+ ToolChunk,
+ ToolResponse,
+ Base,
+ ChatRun,
+ ChatSession,
+ ToolCallLog,
+ ToolEffectLease,
+ ToolEffectLedger,
+ ToolEffectReceipt,
+ ScheduledTask,
+ AgentRuntimeState,
+ make_adapter,
+ durable_adapter,
+ _plain_replay_args,
+ reconcile_scheduled_task_intent,
+ RunJournal,
+ DEFAULT_TOOL_RECOVERY_REGISTRY,
+ ToolRecoveryRegistry,
+ ToolEffectError,
+ ToolIdempotencyConflict,
+ ToolIntentCommitError,
+ ToolOutcomeUnknown,
+ ToolEffectGateway,
+ ToolEffectJournal,
+ ToolIntent,
+ recover_incomplete_tool_effects,
+ create_engine,
+ sessionmaker,
+ QueuePool,
+ MutableClock,
+ effect_env,
+)
+
+@pytest.mark.asyncio
+async def test_public_reply_cancellation_stops_tool_without_late_terminal_write(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-public-cancel")
+ entered = asyncio.Event()
+ release = asyncio.Event()
+ side_effects = 0
+
+ async def blocking_probe(value: int) -> ToolChunk:
+ """Wait until the caller releases the test tool."""
+ nonlocal side_effects
+ entered.set()
+ await release.wait()
+ side_effects += value
+ return ToolChunk(
+ content=[TextBlock(text=f"value={value}")],
+ state=ToolResultState.SUCCESS,
+ )
+
+ class BlockingToolModel:
+ from agentscope.formatter import OpenAIChatFormatter
+ formatter = OpenAIChatFormatter()
+ model = "blocking-tool"
+ context_size = 32768
+
+ async def __call__(self, messages, tools=None, **_kwargs):
+ del messages
+ assert any(item["function"]["name"] == "blocking_probe" for item in tools)
+ return ChatResponse(
+ content=[
+ ToolCallBlock(
+ id="cancel-call",
+ name="blocking_probe",
+ input='{"value":1}',
+ )
+ ],
+ is_last=False,
+ usage=ChatUsage(input_tokens=1, output_tokens=1, time=0.01),
+ )
+
+ async def count_tokens(self, messages, tools=None):
+ del messages, tools
+ return 1
+
+ agent = Agent(
+ name="ledger-cancel-seam",
+ system_prompt="Use the blocking probe.",
+ model=BlockingToolModel(),
+ toolkit=Toolkit(tools=[FunctionTool(blocking_probe, is_concurrency_safe=False)]),
+ middlewares=[durable_adapter(sessions)],
+ state=AgentRuntimeState(
+ run_id="run-public-cancel",
+ journal_owner="worker",
+ permission_context=PermissionContext(mode=PermissionMode.BYPASS),
+ ),
+ react_config=ReActConfig(max_iters=2, interruption_raise_cancelled_error=True),
+ )
+ reply_task = asyncio.create_task(agent.reply(UserMsg(name="user", content="start")))
+ await entered.wait()
+ assert RunJournal(sessions).cancel("run-public-cancel", reason="test cancellation")
+ reply_task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(reply_task, timeout=5)
+ release.set()
+ await asyncio.sleep(0)
+ assert side_effects == 0
+ with sessions() as db:
+ run = db.get(ChatRun, "run-public-cancel")
+ assert run.status == "cancelled"
+ assert run.run_phase == "cancelled"
+ assert [
+ row.event_type
+ for row in db.query(ToolEffectLedger)
+ .filter(ToolEffectLedger.run_id == "run-public-cancel")
+ .order_by(ToolEffectLedger.event_id)
+ ] == ["intent"]
+
+
+@pytest.mark.asyncio
+async def test_restart_sweep_classifies_replay_reconcile_and_never_replay(effect_env):
+ sessions, make_run = effect_env
+ clock = MutableClock()
+ registry = ToolRecoveryRegistry()
+
+ async def replay(_intent):
+ return {"replayed": True}
+
+ registry.register("safe_read", "replay_safe", replayer=replay)
+
+ async def reconcile(_intent):
+ return {"external_status": "committed"}
+
+ registry.register("external_write", "reconcile", reconciler=reconcile)
+ ledger = ToolEffectJournal(sessions, clock=clock)
+ effects = {}
+ for run_id, tool_name, policy in (
+ ("run-sweep-replay", "safe_read", "replay_safe"),
+ ("run-sweep-reconcile", "external_write", "reconcile"),
+ ("run-sweep-never", "unknown_write", "never_replay"),
+ ):
+ make_run(run_id)
+ effects[run_id] = ledger.begin_intent(
+ run_id=run_id,
+ owner="worker",
+ claim_owner="crashed-tool",
+ tool_call_id=f"call-{run_id}",
+ tool_name=tool_name,
+ args={"target": run_id},
+ recovery_policy=policy,
+ idempotency_key=f"key-{run_id}",
+ lease_seconds=1,
+ ).effect_id
+ clock.advance(2)
+ with sessions() as db:
+ db.query(ChatRun).filter(ChatRun.run_id.in_(effects)).update(
+ {"lease_expires_at": clock() - timedelta(seconds=1)},
+ synchronize_session=False,
+ )
+ db.commit()
+
+ decisions = await recover_incomplete_tool_effects(journal=ledger, registry=registry)
+ assert {(item.run_id, item.action) for item in decisions} == {
+ ("run-sweep-replay", "replayed"),
+ ("run-sweep-reconcile", "reconciled"),
+ ("run-sweep-never", "needs_attention"),
+ }
+ with sessions() as db:
+ assert db.get(ChatRun, "run-sweep-replay").status == "running"
+ assert db.get(ChatRun, "run-sweep-reconcile").run_phase == "tool_result_committed"
+ assert db.get(ChatRun, "run-sweep-never").status == "needs_attention"
+ reconciled = (
+ db.query(ToolEffectLedger)
+ .filter(
+ ToolEffectLedger.effect_id == effects["run-sweep-reconcile"],
+ ToolEffectLedger.event_type == "result",
+ )
+ .one()
+ )
+ assert reconciled.result_payload == {"external_status": "committed"}
+
+
+@pytest.mark.asyncio
+async def test_recovery_adapter_failure_does_not_starve_later_intent(effect_env):
+ sessions, make_run = effect_env
+ clock = MutableClock()
+ registry = ToolRecoveryRegistry()
+
+ async def broken(_intent):
+ raise RuntimeError("temporary adapter outage")
+
+ async def healthy(_intent):
+ return {"ok": True}
+
+ registry.register("broken_read", "replay_safe", replayer=broken)
+ registry.register("healthy_read", "replay_safe", replayer=healthy)
+ ledger = ToolEffectJournal(sessions, clock=clock)
+ effects = []
+ for run_id, tool_name in (("run-broken", "broken_read"), ("run-healthy", "healthy_read")):
+ make_run(run_id)
+ effects.append(
+ ledger.begin_intent(
+ run_id=run_id,
+ owner="worker",
+ claim_owner="crashed",
+ tool_call_id=f"call-{run_id}",
+ tool_name=tool_name,
+ args={"q": run_id},
+ recovery_policy="replay_safe",
+ lease_seconds=1,
+ ).effect_id
+ )
+ clock.advance(2)
+ with sessions() as db:
+ db.query(ChatRun).filter(ChatRun.run_id.in_(["run-broken", "run-healthy"])).update(
+ {"lease_expires_at": clock() - timedelta(seconds=1)}, synchronize_session=False
+ )
+ db.commit()
+ decisions = await recover_incomplete_tool_effects(journal=ledger, registry=registry)
+ assert {(item.run_id, item.action) for item in decisions} == {
+ ("run-broken", "retry"),
+ ("run-healthy", "replayed"),
+ }
+ assert ledger.terminal_decision(effects[0]) is None
+ assert ledger.terminal_decision(effects[1]).result == {"ok": True}
+ run_recovery = RunJournal(sessions, clock=clock).recover()
+ assert "run-broken" not in {item.run_id for item in run_recovery}
+
+
+@pytest.mark.asyncio
+async def test_recovery_heartbeat_prevents_second_sweep_from_replaying_long_adapter(effect_env):
+ sessions, make_run = effect_env
+ make_run("run-long-recovery")
+ ledger = ToolEffectJournal(sessions)
+ intent = ledger.begin_intent(
+ run_id="run-long-recovery",
+ owner="worker",
+ claim_owner="crashed",
+ tool_call_id="long-recovery-call",
+ tool_name="long_read",
+ args={"query": "slow"},
+ recovery_policy="replay_safe",
+ lease_seconds=1,
+ )
+ expired = datetime.now(timezone.utc) - timedelta(seconds=1)
+ with sessions() as db:
+ db.query(ChatRun).filter(ChatRun.run_id == "run-long-recovery").update(
+ {"lease_expires_at": expired}
+ )
+ db.query(ToolEffectLease).filter(ToolEffectLease.effect_id == intent.effect_id).update(
+ {"lease_expires_at": expired}
+ )
+ db.commit()
+
+ started = asyncio.Event()
+ release = asyncio.Event()
+ replays = 0
+
+ async def replay(_intent):
+ nonlocal replays
+ replays += 1
+ started.set()
+ await release.wait()
+ return {"ok": True}
+
+ registry = ToolRecoveryRegistry()
+ registry.register("long_read", "replay_safe", replayer=replay)
+ first = asyncio.create_task(
+ recover_incomplete_tool_effects(
+ journal=ledger,
+ registry=registry,
+ lease_seconds=1,
+ heartbeat_interval=0.1,
+ )
+ )
+ await started.wait()
+ await asyncio.sleep(1.1)
+ second = await recover_incomplete_tool_effects(
+ journal=ledger,
+ registry=registry,
+ lease_seconds=1,
+ heartbeat_interval=0.1,
+ )
+ assert second == []
+ assert replays == 1
+ release.set()
+ decisions = await first
+ assert [(item.effect_id, item.action) for item in decisions] == [(intent.effect_id, "replayed")]
+
+
+def test_tool_call_log_is_a_projection_linked_to_authoritative_effect(effect_env, monkeypatch):
+ sessions, make_run = effect_env
+ make_run("run-projection")
+ ledger = ToolEffectJournal(sessions)
+ intent = ledger.begin_intent(
+ run_id="run-projection",
+ owner="worker",
+ claim_owner="projection-invocation",
+ tool_call_id="projection-call",
+ tool_name="Read",
+ args={"path": "/workspace/a.txt"},
+ recovery_policy="replay_safe",
+ )
+ ledger.commit_result(
+ intent.effect_id,
+ run_owner="worker",
+ claim_owner="projection-invocation",
+ result={"ok": True},
+ )
+
+ from core.harness import log_writer as log_service
+
+ monkeypatch.setattr(log_records, "SessionLocal", sessions)
+ projection_secret = "projection-secret-token-123456"
+ log_records.write_tool_call_sync(
+ {
+ "run_id": "run-projection",
+ "chat_id": "chat-1",
+ "user_id": "user-1",
+ "tool_name": "Read",
+ "tool_call_id": "projection-call",
+ "tool_args": {
+ "path": "/workspace/a.txt",
+ "command": f"curl -H 'Authorization: Bearer {projection_secret}' https://x",
+ "url": f"https://example.test/?token={projection_secret}",
+ "callback": f"https://example.test/cb?password={projection_secret}",
+ },
+ "tool_result": "contents",
+ "status": "success",
+ }
+ )
+ with sessions() as db:
+ projection = db.query(ToolCallLog).one()
+ assert projection.effect_id == intent.effect_id
+ assert projection.id == intent.intent.result_id
+ assert projection.tool_result == "contents"
+ assert projection_secret not in json.dumps(projection.tool_args)
+ assert projection.tool_args["command"]["_withheld"] is True
+ assert "[REDACTED]" in projection.tool_args["url"]
+ assert "[REDACTED]" in projection.tool_args["callback"]
+
+ log_records.write_tool_call_sync(
+ {
+ "run_id": "run-projection",
+ "chat_id": "chat-1",
+ "user_id": "user-1",
+ "tool_name": "Read",
+ "tool_call_id": "new-provider-call-after-recovery",
+ "effect_id": intent.effect_id,
+ "result_id": intent.intent.result_id,
+ "tool_args": {"path": "/workspace/a.txt"},
+ "tool_result": "replayed contents",
+ "status": "success",
+ }
+ )
+ with sessions() as db:
+ assert db.query(ToolCallLog).count() == 1
+ projection = db.query(ToolCallLog).one()
+ assert projection.id == intent.intent.result_id
+ assert projection.tool_call_id == "new-provider-call-after-recovery"
+ assert projection.tool_result == "replayed contents"
+
+
+def test_prune_settled_drops_only_old_rows_of_settled_runs(effect_env):
+ sessions, make_run = effect_env
+ clock = MutableClock()
+ ledger = ToolEffectJournal(sessions, clock=clock)
+ make_run("run-old")
+ make_run("run-live", chat_id="chat-2")
+ old = ledger.begin_intent(
+ run_id="run-old",
+ owner="worker",
+ claim_owner="invocation",
+ tool_call_id="call-old",
+ tool_name="write",
+ args={"path": "/tmp/a"},
+ recovery_policy="never_replay",
+ idempotency_key="prune-old",
+ )
+ ledger.commit_result(
+ old.effect_id,
+ run_owner="worker",
+ claim_owner="invocation",
+ result={"ok": True},
+ )
+ live = ledger.begin_intent(
+ run_id="run-live",
+ owner="worker",
+ claim_owner="invocation",
+ tool_call_id="call-live",
+ tool_name="write",
+ args={"path": "/tmp/b"},
+ recovery_policy="never_replay",
+ idempotency_key="prune-live",
+ )
+ from core.db.models import ChatRunOperation, HarnessEventLog, HarnessUsageAttempt
+
+ with sessions() as db:
+ run = db.get(ChatRun, "run-old")
+ run.status = "completed"
+ db.add(
+ HarnessUsageAttempt(
+ attempt_id="hat-old",
+ run_id="run-old",
+ attempt_seq=1,
+ kind="tool",
+ operation_name="write",
+ status="success",
+ )
+ )
+ db.add(
+ HarnessEventLog(
+ event_id="hev-old",
+ run_id="run-old",
+ event_seq=1,
+ event_type="tool_result",
+ phase="post",
+ payload={},
+ )
+ )
+ db.commit()
+ clock.advance(8 * 24 * 3600)
+ assert ledger.prune_settled() >= 4
+ with sessions() as db:
+ for model in (
+ ToolEffectLedger,
+ HarnessUsageAttempt,
+ HarnessEventLog,
+ ChatRunOperation,
+ ):
+ assert db.query(model).filter(model.run_id == "run-old").count() == 0
+ assert (
+ db.query(ToolEffectLedger)
+ .filter(ToolEffectLedger.run_id == "run-live")
+ .count()
+ == 1
+ )
+ assert (
+ db.query(ChatRunOperation)
+ .filter(ChatRunOperation.run_id == "run-live")
+ .count()
+ > 0
+ )
+ assert ledger.pending_effect_ids() == [live.effect_id]
diff --git a/src/backend/tests/orchestration/test_tool_effect_ledger_5.py b/src/backend/tests/orchestration/test_tool_effect_ledger_5.py
new file mode 100644
index 00000000..db62959a
--- /dev/null
+++ b/src/backend/tests/orchestration/test_tool_effect_ledger_5.py
@@ -0,0 +1,133 @@
+from tests.orchestration.tool_effect_support import (
+ log_records,
+ asyncio,
+ json,
+ ThreadPoolExecutor,
+ datetime,
+ timedelta,
+ timezone,
+ Barrier,
+ SimpleNamespace,
+ pytest,
+ Agent,
+ ReActConfig,
+ TextBlock,
+ ToolCallBlock,
+ ToolResultState,
+ UserMsg,
+ ChatResponse,
+ ChatUsage,
+ PermissionContext,
+ PermissionMode,
+ FunctionTool,
+ Toolkit,
+ ToolChunk,
+ ToolResponse,
+ Base,
+ ChatRun,
+ ChatSession,
+ ToolCallLog,
+ ToolEffectLease,
+ ToolEffectLedger,
+ ToolEffectReceipt,
+ ScheduledTask,
+ AgentRuntimeState,
+ make_adapter,
+ durable_adapter,
+ _plain_replay_args,
+ reconcile_scheduled_task_intent,
+ RunJournal,
+ DEFAULT_TOOL_RECOVERY_REGISTRY,
+ ToolRecoveryRegistry,
+ ToolEffectError,
+ ToolIdempotencyConflict,
+ ToolIntentCommitError,
+ ToolOutcomeUnknown,
+ ToolEffectGateway,
+ ToolEffectJournal,
+ ToolIntent,
+ recover_incomplete_tool_effects,
+ create_engine,
+ sessionmaker,
+ QueuePool,
+ MutableClock,
+ effect_env,
+)
+
+def test_prune_settled_eventually_reclaims_needs_attention_runs(effect_env):
+ """needs_attention 保留得更久,但不是永远。
+
+ 原先它被整个排除在清理之外,等于永不回收:生产库里每一条超出保留期的
+ journal 记录——四万条——都属于 needs_attention 的 run,被永久钉住,还要被
+ 每五分钟一轮的清理反复全表扫。保留期该是"更长",不是"无限"。
+ """
+ from core.db.models import HarnessEventLog
+ from core.runs.tool_effects.journal_constants import ATTENTION_RETENTION_FACTOR as _ATTENTION_RETENTION_FACTOR
+
+ sessions, make_run = effect_env
+ clock = MutableClock()
+ ledger = ToolEffectJournal(sessions, clock=clock)
+ make_run("run-attention")
+ intent = ledger.begin_intent(
+ run_id="run-attention",
+ owner="worker",
+ claim_owner="invocation",
+ tool_call_id="call-attention",
+ tool_name="write",
+ args={"path": "/tmp/a"},
+ recovery_policy="never_replay",
+ idempotency_key="prune-attention",
+ )
+ ledger.commit_result(
+ intent.effect_id,
+ run_owner="worker",
+ claim_owner="invocation",
+ result={"ok": True},
+ )
+ with sessions() as db:
+ run = db.get(ChatRun, "run-attention")
+ run.status = "needs_attention"
+ # recovery_snapshot 是整轮对话的快照,库里最大的单个字段;它跟 journal
+ # 走同一套分档,不能因为状态是 needs_attention 就永久留着
+ run.completed_at = clock.now
+ db.add(
+ HarnessEventLog(
+ event_id="hev-attention",
+ run_id="run-attention",
+ event_seq=1,
+ event_type="tool_result",
+ phase="post",
+ payload={},
+ )
+ )
+ db.commit()
+
+ # 保留期显式传入,两次推进都从它算——否则 prune_settled 的默认值一改,
+ # 这里的天数就悄悄测不到边界了
+ retention_days = 7
+
+ # 刚过普通终态的保留期:记录必须还在
+ clock.advance((retention_days + 1) * 24 * 3600)
+ ledger.prune_settled(retention_days=retention_days)
+ with sessions() as db:
+ assert (
+ db.query(ToolEffectLedger).filter(ToolEffectLedger.run_id == "run-attention").count()
+ > 0
+ )
+ assert (
+ db.query(HarnessEventLog).filter(HarnessEventLog.run_id == "run-attention").count() == 1
+ )
+ assert db.get(ChatRun, "run-attention").recovery_snapshot is not None
+
+ # 过了加长保留期:回收,不能无限期占着库
+ clock.advance(retention_days * _ATTENTION_RETENTION_FACTOR * 24 * 3600)
+ ledger.prune_settled(retention_days=retention_days)
+ with sessions() as db:
+ assert (
+ db.query(ToolEffectLedger).filter(ToolEffectLedger.run_id == "run-attention").count()
+ == 0
+ )
+ assert (
+ db.query(HarnessEventLog).filter(HarnessEventLog.run_id == "run-attention").count() == 0
+ )
+ assert db.get(ChatRun, "run-attention").recovery_snapshot is None
diff --git a/src/backend/tests/orchestration/test_user_question_passthrough.py b/src/backend/tests/orchestration/test_user_question_passthrough.py
index 0ae40c4c..e579dc01 100644
--- a/src/backend/tests/orchestration/test_user_question_passthrough.py
+++ b/src/backend/tests/orchestration/test_user_question_passthrough.py
@@ -14,7 +14,6 @@
from types import SimpleNamespace
import core.agent_runtime.integrations.memory as memory
-import core.agent_runtime.resources as resources
import core.agent_runtime.runner as runner
import core.agent_runtime.targets as targets
import core.capabilities.catalog.resolver as catalog_resolver
@@ -101,7 +100,6 @@ async def no_compaction(_chat_id, messages, **_kwargs):
monkeypatch.setattr(catalog_resolver, "enabled_kb_ids_from_context", lambda _ctx: [])
monkeypatch.setattr(targets, "_resolve_mode_spec", lambda _ctx: None)
monkeypatch.setattr(streaming_module, 'StreamingAgent', FakeStreamingAgent)
- monkeypatch.setattr(resources.process_state(), 'persistent_clients', [])
chunks = [
chunk
diff --git a/src/backend/tests/orchestration/tool_effect_support.py b/src/backend/tests/orchestration/tool_effect_support.py
new file mode 100644
index 00000000..e3c69680
--- /dev/null
+++ b/src/backend/tests/orchestration/tool_effect_support.py
@@ -0,0 +1,81 @@
+"""Durable, idempotent tool side-effect execution and recovery."""
+
+from __future__ import annotations
+from core.harness import log_records
+
+import asyncio
+import json
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, timedelta, timezone
+from threading import Barrier
+from types import SimpleNamespace
+
+import pytest
+from agentscope.agent import Agent, ReActConfig
+from agentscope.message import TextBlock, ToolCallBlock, ToolResultState, UserMsg
+from agentscope.model import ChatResponse, ChatUsage
+from agentscope.permission import PermissionContext, PermissionMode
+from agentscope.tool import FunctionTool, Toolkit
+from agentscope.tool._response import ToolChunk, ToolResponse
+from core.db.engine import Base
+from core.db.models import ChatRun, ChatSession, ToolCallLog, ToolEffectLease, ToolEffectLedger, ToolEffectReceipt
+from features.automation.models import ScheduledTask
+from core.llm.runtime.state import AgentRuntimeState
+from tests.orchestration.hook_support import make_adapter, durable_adapter
+from core.runs.tool_effect_recovery import _plain_replay_args, reconcile_scheduled_task_intent
+from core.runs.journal import RunJournal
+from core.runs.tool_effects.registry import DEFAULT_TOOL_RECOVERY_REGISTRY, ToolRecoveryRegistry
+from core.runs.tool_effects.errors import ToolEffectError, ToolIdempotencyConflict, ToolIntentCommitError, ToolOutcomeUnknown
+from core.runs.tool_effects.gateway import ToolEffectGateway
+from core.runs.tool_effects.journal import ToolEffectJournal
+from core.runs.tool_effects.types import ToolIntent
+from core.runs.tool_effects.recovery import recover_incomplete_tool_effects
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import QueuePool
+
+
+class MutableClock:
+ def __init__(self) -> None:
+ self.now = datetime.now(timezone.utc)
+
+ def __call__(self) -> datetime:
+ return self.now
+
+ def advance(self, seconds: float) -> None:
+ self.now += timedelta(seconds=seconds)
+
+
+@pytest.fixture()
+def effect_env(tmp_path):
+ engine = create_engine(
+ f"sqlite:///{tmp_path / 'tool-effects.sqlite'}",
+ connect_args={"check_same_thread": False},
+ )
+ Base.metadata.create_all(engine)
+ sessions = sessionmaker(bind=engine, expire_on_commit=False)
+
+ def make_run(
+ run_id: str,
+ *,
+ owner: str = "worker",
+ user_id: str = "user-1",
+ chat_id: str = "chat-1",
+ ) -> None:
+ with sessions() as db:
+ if db.get(ChatSession, chat_id) is None:
+ db.add(ChatSession(chat_id=chat_id, user_id=user_id, title="test"))
+ db.commit()
+ journal = RunJournal(sessions)
+ journal.accept(
+ run_id=run_id,
+ message_id=f"msg-{run_id}",
+ chat_id=chat_id,
+ user_id=user_id,
+ request_payload={"kind": "chat"},
+ recovery_snapshot={"kind": "chat", "worker_args": {}},
+ )
+ assert journal.claim(run_id, owner=owner, lease_seconds=300)
+
+ yield sessions, make_run
+ engine.dispose()
diff --git a/src/backend/tests/prompts/test_desktop_prompt_versions.py b/src/backend/tests/prompts/test_desktop_prompt_versions.py
index 02943cb4..8bfc84bb 100644
--- a/src/backend/tests/prompts/test_desktop_prompt_versions.py
+++ b/src/backend/tests/prompts/test_desktop_prompt_versions.py
@@ -3,6 +3,7 @@
from core.llm import prompt_versions as prompt_versions_module
import pytest
from sqlalchemy import create_engine
+from sqlalchemy.pool import StaticPool
from sqlalchemy.orm import Session
from core.db.models import ContentBlock
from core.llm import prompt_storage as pvs
@@ -10,11 +11,13 @@
@pytest.fixture
def db(monkeypatch):
- engine = create_engine("sqlite://")
+ # One connection for every thread: preview steps run in worker threads.
+ engine = create_engine("sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False})
ContentBlock.__table__.create(engine)
prompt_storage_module.invalidate_cache()
with Session(engine) as session:
monkeypatch.setattr(pvs, "SessionLocal", lambda: Session(engine))
+ monkeypatch.setattr("core.db.engine.SessionLocal", lambda: Session(engine))
yield session
prompt_storage_module.invalidate_cache()
engine.dispose()
@@ -54,7 +57,7 @@ async def test_full_preview_does_not_activate_version(db, monkeypatch):
monkeypatch.setattr("core.config.local_mode.local_mode_enabled", lambda: False)
prompt_versions_module.seed_from_filesystem(db=db)
prompt_versions_module.upsert_version("desktop", "draft", from_id="default", db=db)
- data = (await desktop_preview(DesktopPreviewRequest(version_id="draft"), db=db))["data"]
+ data = (await desktop_preview(DesktopPreviewRequest(version_id="draft")))["data"]
assert data["environment_source"] == "runtime_placeholders"
assert "{runtime.os}" in data["prompt"]
assert "本机文件与执行" in data["prompt"]
@@ -77,11 +80,11 @@ async def test_local_preview_uses_runtime_assembly_with_selected_version(db, mon
monkeypatch.setattr("core.config.local_mode.local_mode_enabled", lambda: True)
prompt_versions_module.seed_from_filesystem(db=db)
prompt_versions_module.upsert_version("desktop", "draft", parts=[{"part_id": "guidance", "content": "SELECTED_DRAFT"}], db=db)
- async def runtime(_, **kwargs):
+ async def runtime(**kwargs):
assert kwargs["approval_mode"] == "ask"
return {"prompt": "RUNTIME_TOOLS\n" + render_desktop_part("guidance")}
monkeypatch.setattr(admin_prompts, "_runtime_prompt_preview", runtime)
- data = (await admin_prompts.desktop_preview(admin_prompts.DesktopPreviewRequest(version_id="draft"), db=db))["data"]
+ data = (await admin_prompts.desktop_preview(admin_prompts.DesktopPreviewRequest(version_id="draft")))["data"]
assert data["preview_mode"] == "runtime"
assert data["prompt"] == "RUNTIME_TOOLS\nSELECTED_DRAFT"
assert "SELECTED_DRAFT" not in render_desktop_part("guidance")
@@ -94,7 +97,7 @@ async def test_preview_unsaved_parts_and_snapshot_roundtrip(db, monkeypatch):
prompt_versions_module.seed_from_filesystem(db=db)
before = api.export_snapshot(db=db)["data"]
data = (await api.desktop_preview(api.DesktopPreviewRequest(version_id="default", parts=[
- api.PromptPartPayload(part_id="guidance", content="UNSAVED_PREVIEW")]), db=db))["data"]
+ api.PromptPartPayload(part_id="guidance", content="UNSAVED_PREVIEW")])))["data"]
assert "UNSAVED_PREVIEW" in data["prompt"]
assert "UNSAVED_PREVIEW" not in render_desktop_part("guidance")
api.import_snapshot(before, db=db)
@@ -123,10 +126,10 @@ async def test_management_permission_gate(db, monkeypatch, user_id, allowed, sta
request = Request({"type": "http", "method": "GET", "path": "/v1/admin/prompts/kinds", "headers": []})
user = SimpleNamespace(user_id=user_id) if user_id else None
if status == 200:
- assert await deps.require_system_settings(request, db=db, user=user) == user_id
+ assert await deps.require_system_settings(request, user=user) == user_id
else:
with pytest.raises(HTTPException) as error:
- await deps.require_system_settings(request, db=db, user=user)
+ await deps.require_system_settings(request, user=user)
assert error.value.status_code == status
diff --git a/src/backend/tests/prompts/test_local_project_section.py b/src/backend/tests/prompts/test_local_project_section.py
index 495a0a44..c2457d80 100644
--- a/src/backend/tests/prompts/test_local_project_section.py
+++ b/src/backend/tests/prompts/test_local_project_section.py
@@ -82,10 +82,12 @@ def test_desktop_prompt_and_bash_describe_effective_session(monkeypatch):
functions = {}
class Toolkit:
- def register_tool_function(self, fn, **kwargs):
+ def append(self, tool):
+ fn = tool._func
+ kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
functions[fn.__name__] = fn
- register_bash(Toolkit(), loader=None, loaded_skill_ids=set(), **ctx)
+ register_bash(Toolkit(), loader=None, **ctx)
description = functions["Bash"].__doc__
expected = session_root(root, "effective-session")
assert expected in prompt and expected in description
@@ -104,10 +106,12 @@ def test_cloud_bash_description_keeps_container_paths(monkeypatch):
functions = {}
class Toolkit:
- def register_tool_function(self, fn, **kwargs):
+ def append(self, tool):
+ fn = tool._func
+ kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
functions[fn.__name__] = fn
- register_bash(Toolkit(), loader=None, loaded_skill_ids=set(), chat_id="cloud-chat")
+ register_bash(Toolkit(), loader=None, chat_id="cloud-chat")
doc = functions["Bash"].__doc__
assert "工作目录默认 /workspace" in doc
assert "/workspace/skills" in doc
diff --git a/src/backend/tests/prompts/test_prompt_version_freshness.py b/src/backend/tests/prompts/test_prompt_version_freshness.py
index 8dbf9cad..bfb61ff8 100644
--- a/src/backend/tests/prompts/test_prompt_version_freshness.py
+++ b/src/backend/tests/prompts/test_prompt_version_freshness.py
@@ -111,10 +111,9 @@ async def test_preview_and_runtime_use_saved_prompts_without_local_invalidation(
config = PromptConfig()
context = {"chat_id": "freshness-test"}
assert "SYSTEM_BEFORE" in build_system_prompt(config, context)
- with sessions() as db:
- before = (await desktop_preview(DesktopPreviewRequest(version_id="default"), db=db))[
- "data"
- ]["prompt"]
+ before = (await desktop_preview(DesktopPreviewRequest(version_id="default")))["data"][
+ "prompt"
+ ]
assert "SYSTEM_BEFORE" in before
assert "DESKTOP_BEFORE" in before
@@ -124,10 +123,9 @@ async def test_preview_and_runtime_use_saved_prompts_without_local_invalidation(
runtime = build_system_prompt(config, context)
assert "SYSTEM_AFTER" in runtime
assert "SYSTEM_BEFORE" not in runtime
- with sessions() as db:
- after = (await desktop_preview(DesktopPreviewRequest(version_id="default"), db=db))["data"][
- "prompt"
- ]
+ after = (await desktop_preview(DesktopPreviewRequest(version_id="default")))["data"][
+ "prompt"
+ ]
assert "SYSTEM_AFTER" in after
assert "DESKTOP_AFTER" in after
assert "SYSTEM_BEFORE" not in after
diff --git a/src/backend/tests/run_command_opensandbox_e2e.py b/src/backend/tests/run_command_opensandbox_e2e.py
index ffd9f121..a72101b0 100644
--- a/src/backend/tests/run_command_opensandbox_e2e.py
+++ b/src/backend/tests/run_command_opensandbox_e2e.py
@@ -70,11 +70,11 @@ def main():
domain = os.environ["COMMAND_E2E_OPENSANDBOX_DOMAIN"]
with tempfile.TemporaryDirectory(prefix="command-cloud-e2e-") as folder:
env = {"DATABASE_URL":"sqlite:///" + str(Path(folder)/"test.db"),
- "HUGAGENT_HOME":folder,"STORAGE_PATH":str(Path(folder)/"storage"),
+ "HUGAGENT_HOME":folder,"STORAGE_PATH":os.environ["COMMAND_E2E_STORAGE_PATH"],
"DEPLOY_PROFILE":"team","AUTH_MODE":"mock","SANDBOX_PROVIDER":"opensandbox",
"OPENSANDBOX_DOMAIN":domain,"OPENSANDBOX_IMAGE":image,
"OPENSANDBOX_DIRECT_EXECD":"false","OPENSANDBOX_MYSPACE_BIND_MOUNT_ENABLED":"false",
- "OPENSANDBOX_SNAPSHOT_ENABLED":"false","HOST_STORAGE_PATH":"",
+ "OPENSANDBOX_SNAPSHOT_ENABLED":"false","HOST_STORAGE_PATH":os.environ["COMMAND_E2E_HOST_STORAGE_PATH"],
"SCRIPT_RUNNER_WORKSPACE":"/workspace","REDIS_URL":"",
"NO_PROXY":"127.0.0.1,localhost","no_proxy":"127.0.0.1,localhost"}
for name in ("DWS","LARK","EMAIL","YIDA"):
diff --git a/src/backend/tests/run_personal_agent_cli_grant_e2e.py b/src/backend/tests/run_personal_agent_cli_grant_e2e.py
index e2cc271a..a34d21fb 100644
--- a/src/backend/tests/run_personal_agent_cli_grant_e2e.py
+++ b/src/backend/tests/run_personal_agent_cli_grant_e2e.py
@@ -18,7 +18,8 @@ class Tools:
def __init__(self):
self.functions = {}
- def register_tool_function(self, fn):
+ def append(self, tool):
+ fn = tool._func
self.functions[fn.__name__] = fn
diff --git a/src/backend/tests/run_personal_agent_e2e.py b/src/backend/tests/run_personal_agent_e2e.py
index 34ac88f9..c9200c73 100644
--- a/src/backend/tests/run_personal_agent_e2e.py
+++ b/src/backend/tests/run_personal_agent_e2e.py
@@ -15,7 +15,9 @@
class CaptureTools:
def __init__(self): self.tools = {}
- def register_tool_function(self, fn): self.tools[fn.__name__] = fn
+ def append(self, tool):
+ fn = tool._func
+ self.tools[fn.__name__] = fn
def data(chunk): return json.loads(chunk.content[0].text)
diff --git a/src/backend/tests/run_personal_agent_project_sessions_e2e.py b/src/backend/tests/run_personal_agent_project_sessions_e2e.py
index fc5c08e1..c51c260f 100644
--- a/src/backend/tests/run_personal_agent_project_sessions_e2e.py
+++ b/src/backend/tests/run_personal_agent_project_sessions_e2e.py
@@ -31,7 +31,8 @@ class Tools:
def __init__(self):
self.functions = {}
- def register_tool_function(self, fn):
+ def append(self, tool):
+ fn = tool._func
self.functions[fn.__name__] = fn
diff --git a/src/backend/tests/sandbox/e2e_myspace_bulk_live.py b/src/backend/tests/sandbox/e2e_myspace_bulk_live.py
index 45dac682..0e64384a 100644
--- a/src/backend/tests/sandbox/e2e_myspace_bulk_live.py
+++ b/src/backend/tests/sandbox/e2e_myspace_bulk_live.py
@@ -23,7 +23,7 @@ async def main():
db.commit()
_, raw = ApiKeyService(db).create_key(uid, "temporary bulk E2E", expires_in_days=1)
tk = Toolkit()
- register_bash(tk, loader=None, loaded_skill_ids=set(), chat_id=chat,
+ register_bash(tk, loader=None, chat_id=chat,
sandbox_session_id=chat, user_id=uid)
metrics = {"user": uid}
diff --git a/src/backend/tests/sandbox/test_adopt_respects_session_binding.py b/src/backend/tests/sandbox/test_adopt_respects_session_binding.py
index 7179f9ed..5198434c 100644
--- a/src/backend/tests/sandbox/test_adopt_respects_session_binding.py
+++ b/src/backend/tests/sandbox/test_adopt_respects_session_binding.py
@@ -144,9 +144,6 @@ def _server_info(sandbox_id: str, user_id: str = "u1"):
def _provider(monkeypatch):
"""一个只装了认领这条路所需部件的 provider。"""
- monkeypatch.setattr(
- "core.sandbox.providers.opensandbox.provider._user_bound_sandbox_required", lambda: True
- )
provider = object.__new__(OpenSandboxProvider)
provider._make_config = lambda: None
provider._repoint_execd_direct = AsyncMock(return_value=None)
diff --git a/src/backend/tests/sandbox/test_artifact_streaming.py b/src/backend/tests/sandbox/test_artifact_streaming.py
index 6a952bc2..5d0bb7f4 100644
--- a/src/backend/tests/sandbox/test_artifact_streaming.py
+++ b/src/backend/tests/sandbox/test_artifact_streaming.py
@@ -19,8 +19,8 @@
class _CaptureToolkit:
- def register_tool_function(self, function, *, namesake_strategy: str):
- assert namesake_strategy == "override"
+ def append(self, tool):
+ function = tool._func
self.function = function
diff --git a/src/backend/tests/sandbox/test_controller_capacity.py b/src/backend/tests/sandbox/test_controller_capacity.py
new file mode 100644
index 00000000..e1847ff8
--- /dev/null
+++ b/src/backend/tests/sandbox/test_controller_capacity.py
@@ -0,0 +1,37 @@
+"""Capacity includes all controller inventory and competing creation processes."""
+import importlib.util
+from pathlib import Path
+from types import SimpleNamespace
+from concurrent.futures import ThreadPoolExecutor
+from fastapi import HTTPException
+import pytest
+
+ROOT = Path(__file__).resolve().parents[4]
+spec = importlib.util.spec_from_file_location("sandbox_capacity", ROOT / "docker/opensandbox-server/opensandbox_capacity.py")
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+
+
+def test_inventory_and_atomic_creation_share_one_budget(tmp_path, monkeypatch):
+ monkeypatch.setattr(module, "Path", lambda _: tmp_path)
+ monkeypatch.setenv("OPENSANDBOX_POOL_MAX_TOTAL", "1")
+ inventory = []
+ first = SimpleNamespace(network_mode="compute", _owned_sandboxes=lambda: inventory)
+ second = SimpleNamespace(network_mode="compute", _owned_sandboxes=lambda: inventory)
+ def compete():
+ with pytest.raises(HTTPException) as caught:
+ with module.sandbox_capacity(second):
+ pytest.fail("second creator admitted while first is pending")
+ return caught.value.status_code
+ with module.sandbox_capacity(first):
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ assert pool.submit(compete).result(timeout=1) == 429
+ inventory.append("sandbox")
+ # A restarted provider/controller sees inventory, without local reservations.
+ with pytest.raises(HTTPException) as caught:
+ with module.sandbox_capacity(second):
+ pytest.fail("capacity exceeded after first creation")
+ assert caught.value.status_code == 429
+ inventory.clear()
+ with module.sandbox_capacity(second):
+ pass
diff --git a/src/backend/tests/sandbox/test_creation_adoption_race.py b/src/backend/tests/sandbox/test_creation_adoption_race.py
index 7ee7bbc7..af7683dd 100644
--- a/src/backend/tests/sandbox/test_creation_adoption_race.py
+++ b/src/backend/tests/sandbox/test_creation_adoption_race.py
@@ -23,7 +23,7 @@ def creator():
def stats():
- return dict.fromkeys(("adopted_jupyter", "adopted_light", "skipped_bound", "killed_legacy", "killed_dead", "killed_overflow"), 0)
+ return dict.fromkeys(("adopted_jupyter", "adopted_light", "skipped_bound", "killed_invalid", "killed_dead", "killed_overflow"), 0)
def test_adopter_skips_container_while_creator_is_still_delivering_it(monkeypatch):
@@ -67,7 +67,7 @@ def test_adoption_connect_timeout_preserves_live_container(monkeypatch):
def test_startup_cleanup_cannot_kill_a_foreign_tracker_or_bound_instance(state, bound):
b = creator()
manager = SimpleNamespace(kill_sandbox=AsyncMock())
- # Missing pool-kind enters the legacy cleanup branch in the Running case.
+ # Missing pool-kind enters the invalid-metadata cleanup branch in the Running case.
info = SimpleNamespace(id="foreign", status=SimpleNamespace(state=state), metadata={"hugagent-owner": "backend"}, entrypoint=[])
async def scenario():
if bound:
@@ -79,9 +79,10 @@ async def scenario():
asyncio.run(scenario())
-def test_stopped_untagged_unknown_client_is_never_deleted():
+@pytest.mark.parametrize("entrypoint", [["foreign-agent"], ["sleep", "infinity"], ["/opt/opensandbox/code-interpreter.sh"]])
+def test_stopped_untagged_client_is_never_deleted(entrypoint):
b = creator()
manager = SimpleNamespace(kill_sandbox=AsyncMock())
- info = SimpleNamespace(id="another-client", status=SimpleNamespace(state="Stopped"), metadata={}, entrypoint=["foreign-agent"])
+ info = SimpleNamespace(id="another-client", status=SimpleNamespace(state="Stopped"), metadata={}, entrypoint=entrypoint)
asyncio.run(b._classify_and_adopt(manager, info, stats()))
manager.kill_sandbox.assert_not_awaited()
diff --git a/src/backend/tests/sandbox/test_credential_purge_preserves_mounts.py b/src/backend/tests/sandbox/test_credential_purge_preserves_mounts.py
index 37939310..8ccad2f7 100644
--- a/src/backend/tests/sandbox/test_credential_purge_preserves_mounts.py
+++ b/src/backend/tests/sandbox/test_credential_purge_preserves_mounts.py
@@ -23,6 +23,7 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
import core.db.models # noqa: F401 registers all models (FKs depend on users_shadow)
import core.sandbox.workspace.credentials as common_credentials
@@ -42,9 +43,12 @@
@pytest.fixture
-def db():
- eng = create_engine("sqlite:///:memory:")
+def db(monkeypatch):
+ eng = create_engine(
+ "sqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False}
+ )
Base.metadata.create_all(eng)
+ monkeypatch.setattr("core.db.engine.SessionLocal", sessionmaker(bind=eng))
s = sessionmaker(bind=eng)()
s.add(UserShadow(user_id="u1", username="alice"))
s.commit()
@@ -116,7 +120,7 @@ def test_email_purge_config_preserves_the_himalaya_mount(db, monkeypatch, tmp_pa
inodes = _seed_tree(root)
monkeypatch.setattr(integrations_email_storage, 'email_cache_dir', lambda uid: tmp_path / "email_cache" / uid)
- EmailService(db)._purge_config("u1")
+ EmailService()._purge_config("u1")
_assert_emptied_but_mounted(root, inodes)
@@ -133,8 +137,8 @@ async def _fake_run(*_a, **_kw):
monkeypatch.setattr(lark_process, 'run_lark', _fake_run)
- svc = lark_service.LarkService(db)
- svc.repo.ensure("u1")
+ svc = lark_service.LarkService()
+ asyncio.run(svc.repo.ensure("u1"))
result = asyncio.run(svc.disconnect("u1"))
assert result["status"] == "disconnected"
@@ -153,8 +157,8 @@ async def _fake_run(*_a, **_kw):
monkeypatch.setattr(ds, "_run_dws", _fake_run)
- svc = ds.DingTalkService(db)
- svc.repo.ensure("u1")
+ svc = ds.DingTalkService()
+ asyncio.run(svc.repo.ensure("u1"))
result = asyncio.run(svc.disconnect("u1"))
assert result["status"] == "disconnected"
diff --git a/src/backend/tests/sandbox/test_idle_reclaim_snapshot.py b/src/backend/tests/sandbox/test_idle_reclaim_snapshot.py
index e9232bdc..07925d6b 100644
--- a/src/backend/tests/sandbox/test_idle_reclaim_snapshot.py
+++ b/src/backend/tests/sandbox/test_idle_reclaim_snapshot.py
@@ -37,7 +37,6 @@ def _settings(**overrides):
def _restore_provider(monkeypatch, *, snapshot_id, idle_hit):
monkeypatch.setattr('core.capabilities.skills.publication.prepare_skill_view', lambda uid: None)
patch_open_settings(monkeypatch, _settings())
- monkeypatch.setattr(session_mod_runtime, "_user_bound_sandbox_required", lambda: True)
provider = OpenSandboxProvider.__new__(OpenSandboxProvider)
provider._lookup_snapshot = AsyncMock(return_value=snapshot_id)
provider._jupyter_user_pool = SimpleNamespace(has_idle=lambda uid: idle_hit)
diff --git a/src/backend/tests/sandbox/test_opensandbox_controller_patch.py b/src/backend/tests/sandbox/test_opensandbox_controller_patch.py
index 220f3198..52f8bb72 100644
--- a/src/backend/tests/sandbox/test_opensandbox_controller_patch.py
+++ b/src/backend/tests/sandbox/test_opensandbox_controller_patch.py
@@ -19,7 +19,7 @@
PATCH_PATH = (
Path(os.environ["OPENSANDBOX_PATCH_SCRIPT"])
if "OPENSANDBOX_PATCH_SCRIPT" in os.environ
- else Path(__file__).resolve().parents[4] / "docker/patch_opensandbox_server.py"
+ else Path(__file__).resolve().parents[4] / "docker/opensandbox-server/patch_opensandbox_server.py"
)
ROOT = PATCH_PATH.parent.parent
spec = importlib.util.spec_from_file_location("controller_patch", PATCH_PATH)
diff --git a/src/backend/tests/sandbox/test_opensandbox_expirations.py b/src/backend/tests/sandbox/test_opensandbox_expirations.py
index 950d1034..9e1bcf9d 100644
--- a/src/backend/tests/sandbox/test_opensandbox_expirations.py
+++ b/src/backend/tests/sandbox/test_opensandbox_expirations.py
@@ -9,7 +9,7 @@
import pytest
from tests.sandbox.test_opensandbox_controller_patch import SOURCE, extract, patch
-spec = importlib.util.spec_from_file_location("expirations", Path(__file__).resolve().parents[4] / "docker/opensandbox_expirations.py")
+spec = importlib.util.spec_from_file_location("expirations", Path(__file__).resolve().parents[4] / "docker/opensandbox-server/opensandbox_expirations.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
@@ -112,6 +112,8 @@ def test_failed_initial_lease_rolls_back_container_and_mounts(failed_step):
namespace[name] = name
if failed_step == "mount":
namespace["initialize_mounts"].side_effect = OSError("disk full")
+ from contextlib import nullcontext
+ namespace["sandbox_capacity"] = lambda service: nullcontext()
provision = extract(source, "_provision_sandbox", namespace)
with pytest.raises(OSError, match="disk full"):
provision(service, "sandbox", request, datetime.now(timezone.utc), datetime(2099,1,1,tzinfo=timezone.utc))
diff --git a/src/backend/tests/sandbox/test_opensandbox_provider.py b/src/backend/tests/sandbox/test_opensandbox_provider.py
index 5882b8e8..0060e89f 100644
--- a/src/backend/tests/sandbox/test_opensandbox_provider.py
+++ b/src/backend/tests/sandbox/test_opensandbox_provider.py
@@ -45,3 +45,33 @@ async def fake_renew(s):
p._renew = fake_renew
asyncio.run(p._renew_in_background("chat-x", sess))
assert sess.stale_marked is False
+
+@pytest.mark.parametrize("domain,expected", [
+ ("127.0.0.1:46074", "http://127.0.0.1:46074"),
+ ("http://controller:8080/", "http://controller:8080"),
+ ("https://controller.example/prefix/", "https://controller.example/prefix"),
+])
+def test_controller_url_shared_by_sdk_health_and_renew(monkeypatch, domain, expected):
+ from types import SimpleNamespace
+ from unittest.mock import AsyncMock
+ osp = _import_opensandbox_module()
+ original = osp.settings.sandbox
+ class Config:
+ opensandbox_domain = domain
+ def __getattr__(self, key):
+ return getattr(original, key)
+ monkeypatch.setattr(osp, "settings", SimpleNamespace(sandbox=Config()))
+ p = osp.OpenSandboxProvider()
+ client = SimpleNamespace(
+ post=AsyncMock(return_value=SimpleNamespace(status_code=200)),
+ get=AsyncMock(return_value=SimpleNamespace(status_code=200)),
+ )
+ p._get_http_client = lambda: client
+ p._session_expiry_s = lambda: 600
+ async def check():
+ await p._renew(osp._Session(sandbox=SimpleNamespace(id="recovered")))
+ assert await p.health()
+ asyncio.run(check())
+ assert p._make_config().get_base_url() == expected + "/v1"
+ assert client.post.call_args.args[0] == expected + "/v1/sandboxes/recovered/renew-expiration"
+ assert client.get.call_args.args[0] == expected + "/health"
diff --git a/src/backend/tests/sandbox/test_pool_retirement_guards.py b/src/backend/tests/sandbox/test_pool_retirement_guards.py
index e53c5433..55207a1a 100644
--- a/src/backend/tests/sandbox/test_pool_retirement_guards.py
+++ b/src/backend/tests/sandbox/test_pool_retirement_guards.py
@@ -120,21 +120,21 @@ async def kill():
def test_destruction_guard_renews_long_controller_calls(monkeypatch, sandbox_session_store):
monkeypatch.setattr(session_registry, "_LOCK_RENEW_INTERVAL_S", 0.001)
- original_hold = sandbox_session_store.hold
+ original_hold = sandbox_session_store.compare_exchange
async def scenario():
renewed = asyncio.Event()
renewals = 0
- async def hold(key, token, *, ttl):
+ async def hold(key, expected, token, *, ttl):
nonlocal renewals
if key == session_registry._TRACK_KEY.format(sandbox_id="long-kill"):
renewals += 1
if renewals >= 2:
renewed.set()
- return await original_hold(key, token, ttl=ttl)
+ return await original_hold(key, expected, token, ttl=ttl)
- monkeypatch.setattr(sandbox_session_store, "hold", hold)
+ monkeypatch.setattr(sandbox_session_store, "compare_exchange", hold)
async with session_registry.destruction_guard("long-kill") as allowed:
assert allowed
await asyncio.wait_for(renewed.wait(), timeout=1)
@@ -145,23 +145,23 @@ async def hold(key, token, *, ttl):
def test_lost_destruction_guard_interrupts_kill_and_preserves_adopter(monkeypatch, sandbox_session_store):
monkeypatch.setattr(session_registry, "_LOCK_RENEW_INTERVAL_S", 0.001)
- original_hold = sandbox_session_store.hold
+ original_hold = sandbox_session_store.compare_exchange
async def scenario():
calls = 0
key = session_registry._TRACK_KEY.format(sandbox_id="lost-kill")
- async def hold(candidate, token, *, ttl):
+ async def hold(candidate, expected, token, *, ttl):
nonlocal calls
if candidate == key:
calls += 1
- if calls > 1:
+ if calls >= 1:
await sandbox_session_store.drop(key)
await sandbox_session_store.put(key, "adopter", ttl=ttl)
return False
- return await original_hold(candidate, token, ttl=ttl)
+ return await original_hold(candidate, expected, token, ttl=ttl)
- monkeypatch.setattr(sandbox_session_store, "hold", hold)
+ monkeypatch.setattr(sandbox_session_store, "compare_exchange", hold)
with pytest.raises(RuntimeError, match="destruction lease lost"):
async with session_registry.destruction_guard("lost-kill") as allowed:
assert allowed
diff --git a/src/backend/tests/sandbox/test_runner_package.py b/src/backend/tests/sandbox/test_runner_package.py
index 8b089803..0e99c6a8 100644
--- a/src/backend/tests/sandbox/test_runner_package.py
+++ b/src/backend/tests/sandbox/test_runner_package.py
@@ -17,7 +17,7 @@
def _image_sources(destination):
- dockerfile = (ROOT / "docker/Dockerfile.script-runner").read_text()
+ dockerfile = (ROOT / "docker/sandbox/Dockerfile.script-runner").read_text()
for line in dockerfile.splitlines():
if not line.startswith("COPY src/backend/"):
continue
diff --git a/src/backend/tests/sandbox/test_runtime_ownership.py b/src/backend/tests/sandbox/test_runtime_ownership.py
new file mode 100644
index 00000000..efe14771
--- /dev/null
+++ b/src/backend/tests/sandbox/test_runtime_ownership.py
@@ -0,0 +1,61 @@
+"""Shared maintenance discovers other workers and snapshot deletion is conditional."""
+import asyncio
+import time
+from datetime import timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from core.db.models import Base, ChatSandboxSnapshot
+from core.infra.time import utc_now
+from core.sandbox.providers.opensandbox.provider import OpenSandboxProvider
+from core.sandbox.providers.opensandbox import snapshots, tracking
+from core.sandbox.sessions import registry
+
+
+async def test_missing_binding_does_not_block_other_worker_session(monkeypatch):
+ monkeypatch.setattr(registry, "bindings", AsyncMock(return_value=[
+ ("missing", "gone", 0), ("other-worker", "live", 0)]))
+ monkeypatch.setattr(registry, "lookup", AsyncMock(side_effect=["gone", "live"]))
+ forget = AsyncMock()
+ monkeypatch.setattr(registry, "forget", forget)
+ response = SimpleNamespace(status_code=200, json=lambda: {"metadata": {}},
+ raise_for_status=lambda: None)
+ missing = SimpleNamespace(status_code=404, json=lambda: {"code": "DOCKER::SANDBOX_NOT_FOUND"})
+ client = SimpleNamespace(get=AsyncMock(side_effect=[missing, response]))
+ provider = object.__new__(OpenSandboxProvider)
+ provider._sessions = {}
+ provider._domain = "http://controller"
+ provider._get_http_client = lambda: client
+ provider._get_session_lock = AsyncMock(side_effect=lambda _: asyncio.Lock())
+ session = SimpleNamespace(last_active_ts=time.monotonic())
+ provider._attach_session = AsyncMock(return_value=session)
+ await provider._discover_idle_sessions()
+ forget.assert_awaited_once_with("missing", "gone")
+ assert provider._sessions["other-worker"] is session
+ assert session.last_active_ts < time.monotonic() - 100
+
+
+async def test_snapshot_gc_cannot_remove_concurrent_replacement(tmp_path, monkeypatch):
+ engine = create_engine(f"sqlite:///{tmp_path / 'snap.sqlite'}")
+ Base.metadata.create_all(engine, tables=[Base.metadata.tables["chat_sandbox_snapshots"]])
+ factory = sessionmaker(bind=engine)
+ monkeypatch.setattr(snapshots, "_get_db_session_local", lambda: factory)
+ with factory() as db:
+ db.add(ChatSandboxSnapshot(chat_id="chat", snapshot_id="old", sandbox_id="sandbox",
+ expires_at=utc_now()-timedelta(hours=1)))
+ db.commit()
+ provider = object.__new__(OpenSandboxProvider)
+ async def delete_remote(snapshot_id):
+ with factory() as db:
+ row = db.get(ChatSandboxSnapshot, "chat")
+ row.snapshot_id = "new"
+ row.expires_at = utc_now() + timedelta(days=1)
+ db.commit()
+ provider._delete_snapshot = delete_remote
+ await provider._snapshot_gc_once()
+ with factory() as db:
+ assert db.get(ChatSandboxSnapshot, "chat").snapshot_id == "new"
+ engine.dispose()
diff --git a/src/backend/tests/sandbox/test_sandbox_accounting.py b/src/backend/tests/sandbox/test_sandbox_accounting.py
index 276bb364..c5dddabf 100644
--- a/src/backend/tests/sandbox/test_sandbox_accounting.py
+++ b/src/backend/tests/sandbox/test_sandbox_accounting.py
@@ -254,17 +254,17 @@ async def blocked(*args,**kwargs):
def test_lost_shared_claim_interrupts_owner_without_deleting_another_token(monkeypatch, sandbox_session_store):
monkeypatch.setattr(session_registry,"_LOCK_RENEW_INTERVAL_S",0.001)
- original_hold=sandbox_session_store.hold
+ original_hold=sandbox_session_store.compare_exchange
calls=0
- async def hold(key,token,*,ttl):
+ async def hold(key,expected,token,*,ttl):
nonlocal calls
calls+=1
- if calls > 1:
+ if calls >= 1:
await sandbox_session_store.drop(key)
await sandbox_session_store.put(key,"new-owner",ttl=ttl)
return False
- return await original_hold(key,token,ttl=ttl)
- monkeypatch.setattr(sandbox_session_store,"hold",hold)
+ return await original_hold(key,expected,token,ttl=ttl)
+ monkeypatch.setattr(sandbox_session_store,"compare_exchange",hold)
async def scenario():
with pytest.raises(RuntimeError,match="claim lost"):
async with session_registry.claim("chat"):
diff --git a/src/backend/tests/sandbox/test_sandbox_packaging.py b/src/backend/tests/sandbox/test_sandbox_packaging.py
index 661196e7..3b779ae5 100644
--- a/src/backend/tests/sandbox/test_sandbox_packaging.py
+++ b/src/backend/tests/sandbox/test_sandbox_packaging.py
@@ -8,7 +8,7 @@
def test_opensandbox_controller_copy_sources_survive_restricted_context():
- dockerfile = ROOT / "docker/Dockerfile.opensandbox-server"
+ dockerfile = ROOT / "docker/opensandbox-server/Dockerfile.opensandbox-server"
rules = Path(f"{dockerfile}.dockerignore").read_text().splitlines()
assert rules[0] == "**"
included = {line[1:].rstrip("/") for line in rules if line.startswith("!")}
@@ -23,11 +23,11 @@ def test_opensandbox_controller_copy_sources_survive_restricted_context():
assert item.as_posix() in included, f"build context excludes {item}"
-def test_builtin_skill_mount_fallback_uses_central_extension_root(tmp_path, monkeypatch):
- backend = tmp_path / "src/backend"
- skills = backend / "extensions/skills"
- skills.mkdir(parents=True)
- monkeypatch.delenv("HOST_REPO_PATH", raising=False)
- monkeypatch.setattr(volumes, "SKILLS_DIR", skills)
- monkeypatch.setattr(volumes, "_BUILTIN_SKILLS_HOST_PATH", None)
- assert volumes._resolve_builtin_skills_host_path() == str(skills)
+def test_skill_mount_requires_host_storage(monkeypatch):
+ from types import SimpleNamespace
+ import pytest
+ from core.sandbox.errors import SandboxError
+ monkeypatch.setattr(volumes, "settings", SimpleNamespace(
+ sandbox=SimpleNamespace(opensandbox_host_storage_path="")))
+ with pytest.raises(SandboxError, match="HOST_STORAGE_PATH"):
+ volumes._make_skills_volumes("user")
diff --git a/src/backend/tests/sandbox/test_user_bound_sandbox.py b/src/backend/tests/sandbox/test_user_bound_sandbox.py
index 02f043d1..a397b14c 100644
--- a/src/backend/tests/sandbox/test_user_bound_sandbox.py
+++ b/src/backend/tests/sandbox/test_user_bound_sandbox.py
@@ -9,7 +9,6 @@
from unittest.mock import AsyncMock
import pytest
-import core.sandbox.providers.opensandbox.runtime as internals_runtime
from core.sandbox.providers.opensandbox.provider import OpenSandboxProvider
_FLAGS = (
@@ -29,26 +28,6 @@ def _settings(**overrides):
return SimpleNamespace(sandbox=SimpleNamespace(**values))
-@pytest.mark.parametrize("enabled_flag", _FLAGS)
-def test_each_private_mount_requires_user_bound_sandbox(monkeypatch, enabled_flag):
- patch_open_settings(monkeypatch, _settings(**{enabled_flag: True}))
-
- assert internals_runtime._user_bound_sandbox_required() is True
-
-
-def test_per_user_skill_view_requires_user_bound_sandbox(monkeypatch):
- """Host storage alone is enough: /workspace/skills is that user's own view."""
- patch_open_settings(monkeypatch, _settings(opensandbox_host_storage_path="/srv/storage"))
-
- assert internals_runtime._user_bound_sandbox_required() is True
-
-
-def test_space_mounts_require_user_bound_sandbox_without_credential_mounts(monkeypatch):
- patch_open_settings(monkeypatch, _settings())
-
- assert internals_runtime._user_bound_sandbox_required() is True
-
-
def test_dingtalk_mount_uses_user_pool_when_myspace_mount_is_disabled(monkeypatch):
"""HugAgentOS regression: dws=true + myspace=false must still mount user credentials."""
patch_open_settings(monkeypatch, _settings(dws_creds_bind_mount_enabled=True))
diff --git a/src/backend/tests/services/test_audio_models.py b/src/backend/tests/services/test_audio_models.py
index f314777e..d1e55c1c 100644
--- a/src/backend/tests/services/test_audio_models.py
+++ b/src/backend/tests/services/test_audio_models.py
@@ -6,6 +6,7 @@
from alembic.operations import Operations
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
from core.db.engine import Base
from core.db.audio_model_upgrade import reconcile_audio_model_type
@@ -17,7 +18,7 @@
@pytest.fixture
def model_db():
- engine = create_engine("sqlite://")
+ engine = create_engine("sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
with sessionmaker(bind=engine)() as db:
yield db
@@ -86,8 +87,9 @@ def test_audio_management_can_save_blank_endpoint_and_enforces_role_type(model_d
from fastapi import HTTPException
from api.routes.v1 import models
monkeypatch.setattr(models, "_invalidate_model_caches", lambda: None)
+ monkeypatch.setattr("core.db.engine.SessionLocal", sessionmaker(bind=model_db.get_bind()))
created = asyncio.run(models.create_provider_endpoint(
- models.ProviderCreateRequest(display_name="待配置音频", provider_type="audio", provider="audio_compatible", base_url="", api_key="", model_name="pending-asr"), None, model_db))
+ models.ProviderCreateRequest(display_name="待配置音频", provider_type="audio", provider="audio_compatible", base_url="", api_key="", model_name="pending-asr"), None))
data = created["data"]
row = get_provider(model_db, data["provider_id"])
assert row.base_url == "" and row.api_key == ""
@@ -97,4 +99,4 @@ def test_audio_management_can_save_blank_endpoint_and_enforces_role_type(model_d
result = models.assign_role_endpoint("audio_transcription", models.RoleAssignRequest(provider_id=row.provider_id), None, model_db)
assert result["data"]["provider_id"] == row.provider_id
with pytest.raises(HTTPException):
- asyncio.run(models.update_provider_endpoint(row.provider_id, models.ProviderUpdateRequest(provider_type="chat", provider="openai_compatible"), None, model_db))
+ asyncio.run(models.update_provider_endpoint(row.provider_id, models.ProviderUpdateRequest(provider_type="chat", provider="openai_compatible"), None))
diff --git a/src/backend/tests/services/test_audio_routes.py b/src/backend/tests/services/test_audio_routes.py
index 0fd71983..caf90e2d 100644
--- a/src/backend/tests/services/test_audio_routes.py
+++ b/src/backend/tests/services/test_audio_routes.py
@@ -15,6 +15,7 @@
from core.models.repository import create_provider, assign_role, update_provider
from core.chat.audio_transcription import resolve_audio_model
from tests.services.test_audio_transcription import wav
+from tests.db_support import share_session
@pytest.fixture
@@ -27,6 +28,7 @@ def setup(monkeypatch):
app = FastAPI()
app.include_router(audio.router)
app.dependency_overrides[get_db] = lambda: db
+ share_session(monkeypatch, db)
auth = audio.audio_transcription.__defaults__[0].dependency
app.dependency_overrides[auth] = lambda: SimpleNamespace(user_id="owner", public_chat_visitor=None)
# require_auth creates separate closures for each endpoint.
diff --git a/src/backend/tests/services/test_chat_titles.py b/src/backend/tests/services/test_chat_titles.py
index a0e5edbb..7ff0d119 100644
--- a/src/backend/tests/services/test_chat_titles.py
+++ b/src/backend/tests/services/test_chat_titles.py
@@ -188,7 +188,7 @@ async def generate(messages):
model(monkeypatch, generate)
with factory() as db:
response = await admission.start_response_run(
- ChatRequest(chat_id="chat-1", message="首条问题", stream=True), _user(), db)
+ ChatRequest(chat_id="chat-1", message="首条问题", stream=True), _user())
assert response.run_id
await asyncio.wait_for(entered.wait(), 2)
with factory() as db:
diff --git a/src/backend/tests/services/test_model_config_consistency.py b/src/backend/tests/services/test_model_config_consistency.py
index 2f143c5c..96d17723 100644
--- a/src/backend/tests/services/test_model_config_consistency.py
+++ b/src/backend/tests/services/test_model_config_consistency.py
@@ -91,3 +91,45 @@ def test_separate_process_observes_commit_without_restart(tmp_path):
child.kill()
child.wait()
engine.dispose()
+
+
+def test_current_snapshot_is_served_while_another_thread_reloads(db_session, monkeypatch):
+ import threading
+
+ monkeypatch.setattr(model_config, "SessionLocal", sessionmaker(bind=db_session.get_bind()))
+ provider = create_provider(
+ db_session,
+ display_name="main",
+ provider_type="chat",
+ base_url="http://example.invalid",
+ api_key="test",
+ model_name="main",
+ )
+ assign_role(db_session, "main_agent", provider.provider_id)
+ service = ModelConfigService()
+ assert service.resolve("main_agent").model_name == "main"
+
+ # A reload in progress elsewhere must not hold up lookups whose revision matches.
+ with service._refresh_lock:
+ result = []
+ reader = threading.Thread(target=lambda: result.append(service.resolve("main_agent")))
+ reader.start()
+ reader.join(timeout=5)
+ assert not reader.is_alive()
+ assert result[0].model_name == "main"
+
+
+def test_context_length_falls_back_to_unassigned_providers(db_session, monkeypatch):
+ monkeypatch.setattr(model_config, "SessionLocal", sessionmaker(bind=db_session.get_bind()))
+ create_provider(
+ db_session,
+ display_name="spare",
+ provider_type="chat",
+ base_url="http://example.invalid",
+ api_key="test",
+ model_name="spare",
+ extra_config={"context_length": 32768},
+ )
+ service = ModelConfigService()
+ assert service.get_context_length_by_model_name("spare") == 32768
+ assert service.get_context_length_by_model_name("missing") is None
diff --git a/src/backend/tests/services/test_project_instructions.py b/src/backend/tests/services/test_project_instructions.py
index 46b51b50..f2ba1fb3 100644
--- a/src/backend/tests/services/test_project_instructions.py
+++ b/src/backend/tests/services/test_project_instructions.py
@@ -16,6 +16,7 @@
from core.projects.edition import ProjectService
from fastapi import HTTPException
from tests.services.project_instructions_support import env, local, update
+from tests.db_support import share_session
def test_local_file_and_editor_and_next_turn_share_one_source(env):
@@ -211,21 +212,20 @@ def test_init_requires_unadorned_project_chat(env, mode):
@pytest.mark.asyncio
async def test_registered_tools_save_real_cloud_content_with_revision_and_permissions(env):
from agentscope.tool import Toolkit
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools.project_instructions_tool import register_project_instruction_tools
db, _ = env
p = ProjectService(db).create_personal("alice", "Tools")
- collector = ToolCollector()
+ collector = ToolAssembly()
register_project_instruction_tools(collector, project_id=p.project_id, user_id="alice")
- schemas = await Toolkit(tools=collector.function_tools).get_tool_schemas()
+ schemas = await Toolkit(tools=list(collector)).get_tool_schemas()
schema = next(
s["function"] for s in schemas if s["function"]["name"] == "save_project_instructions"
)
assert set(schema["parameters"]["required"]) == {"content", "expected_revision"}
- # ToolCollector's documented get_tool exposes the registered function for direct execution.
- read = collector.get_tool("read_project_instructions")._func
- save = collector.get_tool("save_project_instructions")._func
+ read = next(t for t in collector if t.name == "read_project_instructions")._func
+ save = next(t for t in collector if t.name == "save_project_instructions")._func
before = json.loads((await read()).content[0].text)
result = json.loads(
(await save("# Rules\nTest first.", before["instructions_revision"])).content[0].text
@@ -262,7 +262,7 @@ def test_saved_chat_binding_controls_initialization(env):
assert err.value.status_code == 409
-def test_instructions_http_api_reads_writes_and_rejects_stale_or_unauthorized(env):
+def test_instructions_http_api_reads_writes_and_rejects_stale_or_unauthorized(env, monkeypatch):
from api.routes.v1.projects import router
from core.auth.backend import UserContext, get_current_user
from core.db.engine import get_db
@@ -274,6 +274,7 @@ def test_instructions_http_api_reads_writes_and_rejects_stale_or_unauthorized(en
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_db] = lambda: db
+ share_session(monkeypatch, db)
actor = ["alice"]
app.dependency_overrides[get_current_user] = lambda: UserContext(
user_id=actor[0], user_center_id="test", username=actor[0]
@@ -314,7 +315,7 @@ async def test_team_initialization_tool_and_viewer_denial(env, monkeypatch):
if not hasattr(models, "Team"):
pytest.skip("EE organization project")
from core.db.models import Team, TeamMember
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.sandbox.permissions.dispatch_models import PermissionRuntime
from core.llm.tools.project_instructions_tool import (
project_instruction_path,
@@ -340,7 +341,7 @@ async def test_team_initialization_tool_and_viewer_denial(env, monkeypatch):
target = project_instruction_path(ctx)
assert target.startswith("/teamspace/team/")
monkeypatch.setattr("core.config.local_mode.local_mode_enabled", lambda: False)
- collector = ToolCollector()
+ collector = ToolAssembly()
register_project_instruction_tools(
collector,
project_id=p.project_id,
@@ -358,8 +359,8 @@ async def test_team_initialization_tool_and_viewer_denial(env, monkeypatch):
intents = collector.permission_specs[tool_name].resolver({}, runtime)
assert intents[0].target == "/workspace" + target
assert all(intent.domain != "myspace" for intent in intents)
- read = collector.get_tool("read_project_instructions")._func
- save = collector.get_tool("save_project_instructions")._func
+ read = next(t for t in collector if t.name == "read_project_instructions")._func
+ save = next(t for t in collector if t.name == "save_project_instructions")._func
snapshot = json.loads((await read()).content[0].text)
assert snapshot["path"] == target
result = json.loads(
diff --git a/src/backend/tests/services/test_project_instructions_dispatch.py b/src/backend/tests/services/test_project_instructions_dispatch.py
index 36b8886f..85eb8905 100644
--- a/src/backend/tests/services/test_project_instructions_dispatch.py
+++ b/src/backend/tests/services/test_project_instructions_dispatch.py
@@ -65,21 +65,21 @@ def test_combined_project_patch_preserves_metadata_with_production_session_setti
@pytest.mark.asyncio
async def test_normal_chat_can_read_canonical_rules_but_has_no_init_write_tool(env):
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools.project_instructions_tool import register_project_instruction_tools
db, _ = env
p = ProjectService(db).create_personal("alice", "Normal")
update(db, p, "Current rules")
- collector = ToolCollector()
+ collector = ToolAssembly()
register_project_instruction_tools(
collector,
project_id=p.project_id,
user_id="alice",
allow_write=False,
)
- assert collector.get_tool("save_project_instructions") is None
- read = collector.get_tool("read_project_instructions")._func
+ assert not any(t.name == "save_project_instructions" for t in collector)
+ read = next(t for t in collector if t.name == "read_project_instructions")._func
assert json.loads((await read()).content[0].text)["instructions"] == "Current rules"
# Permission is checked at execution time, not only at registration.
p.owner_user_id = "bob"
@@ -89,7 +89,7 @@ async def test_normal_chat_can_read_canonical_rules_but_has_no_init_write_tool(e
@pytest.mark.asyncio
async def test_read_tool_ignores_stale_root_cache_without_changing_nested_rules(env, monkeypatch):
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools._state import ReadStateTracker
from core.llm.tools.read_tool import register_read
from core.projects.edition import project_scope_from_context
@@ -109,7 +109,7 @@ async def put_file(self, session, path, data, **kwargs):
cache[path] = data
monkeypatch.setattr("core.sandbox.get_sandbox_provider", lambda: Provider())
- collector = ToolCollector()
+ collector = ToolAssembly()
register_read(
collector,
chat_id="read-init-test",
@@ -118,7 +118,7 @@ async def put_file(self, session, path, data, **kwargs):
project_folder_name=scope.folder_name,
scope=scope,
)
- read = collector.get_tool("Read")._func
+ read = next(t for t in collector if t.name == "Read")._func
root = f"/myspace/{scope.folder_name}/AGENTS.md"
result = json.loads((await read(root)).content[0].text)
assert "FRESH-628" in result["content"] and "STALE" not in result["content"]
diff --git a/src/backend/tests/skills/test_read_artifact_pptx_xlsx.py b/src/backend/tests/skills/test_read_artifact_pptx_xlsx.py
index ada170e4..0530a420 100644
--- a/src/backend/tests/skills/test_read_artifact_pptx_xlsx.py
+++ b/src/backend/tests/skills/test_read_artifact_pptx_xlsx.py
@@ -88,14 +88,12 @@ def test_is_pptx_meta_detects_by_mime_and_extension():
@pytest.fixture
def registered_tool():
"""Register read_artifact onto a fresh Toolkit and return the callable."""
- from core.llm.runtime.tool_collector import ToolCollector
+ from core.llm.factory.tools.definitions import ToolAssembly
from core.llm.tools.read_artifact_tool import register_read_artifact
- # AgentScope 2.0: register_* writes into the ToolCollector (duck-compatible with register_tool_function);
- # the tool's original callable lives at AllowedFunctionTool._func.
- c = ToolCollector()
+ c = ToolAssembly()
register_read_artifact(c, user_id="u1")
- return c.get_tool("read_artifact")._func
+ return next(t for t in c if t.name == "read_artifact")._func
async def _call(func, **kwargs) -> Dict[str, Any]:
diff --git a/src/backend/tests/skills/test_skill_deps_aggregator.py b/src/backend/tests/skills/test_skill_deps_aggregator.py
index 87ecbccb..26eededb 100644
--- a/src/backend/tests/skills/test_skill_deps_aggregator.py
+++ b/src/backend/tests/skills/test_skill_deps_aggregator.py
@@ -132,12 +132,12 @@ def test_write_manifests_creates_and_tracks_changes(db, tmp_path):
db.add(_mk("a", {"pip": [{"name": "pandas", "source": "manual"}]}))
db.commit()
info = write_manifests(db, repo_root=str(tmp_path))
- # Manifests belong to the Docker build context (``docker/``), which the
+ # Manifests belong to the Docker build context (``docker/sandbox/``), which the
# writer creates when a fresh checkout does not have it yet.
- assert (tmp_path / "docker" / "requirements-skills.txt").exists()
- assert (tmp_path / "docker" / "package-skills.json").exists()
- assert (tmp_path / "docker" / "apt-skills.txt").exists()
- assert "docker/requirements-skills.txt" in info["changed"]
+ assert (tmp_path / "docker" / "sandbox" / "requirements-skills.txt").exists()
+ assert (tmp_path / "docker" / "sandbox" / "package-skills.json").exists()
+ assert (tmp_path / "docker" / "sandbox" / "apt-skills.txt").exists()
+ assert "docker/sandbox/requirements-skills.txt" in info["changed"]
# Second write with no DB change → no files changed
info2 = write_manifests(db, repo_root=str(tmp_path))
diff --git a/src/backend/tests/skills/test_skill_publication.py b/src/backend/tests/skills/test_skill_publication.py
index 11df11af..4905140e 100644
--- a/src/backend/tests/skills/test_skill_publication.py
+++ b/src/backend/tests/skills/test_skill_publication.py
@@ -408,7 +408,6 @@ async def test_prewarmed_session_delivery_reconciles_before_pool_hit(cloud, monk
return_value=SimpleNamespace(sandbox=SimpleNamespace(id="pooled"))
),
)
- monkeypatch.setattr(sessions, "_user_bound_sandbox_required", lambda: True)
await module_class._create_session_for(fake, "new-chat", user_id="alice")
assert fake._create_session.await_count == 1
assert not (view / "sample/old.txt").exists()
diff --git a/src/backend/tests/test_agent_manager_plugin.py b/src/backend/tests/test_agent_manager_plugin.py
index 869915cc..9a68034e 100644
--- a/src/backend/tests/test_agent_manager_plugin.py
+++ b/src/backend/tests/test_agent_manager_plugin.py
@@ -74,7 +74,7 @@ def test_bundle_manifest_is_wellformed():
assert manifest["name"] == "agent-manager"
ext_mcp = manifest["extensions"]["org.hugagent"]["mcp"]
assert set(ext_mcp) == set(mcp_json["mcpServers"]), "扩展段的服务名必须与 mcp.json 一一对应"
- assert mcp_json["mcpServers"]["agent_manager"]["url"] == "http://mcp:9115/mcp/"
+ assert mcp_json["mcpServers"]["agent_manager"]["url"] == "http://mcp:9115/mcp"
# The declared tool list must match what the server actually exposes — a manifest that
# advertises a tool the server doesn't have is a silently broken promise to the model.
@@ -141,7 +141,7 @@ def test_install_plugin_creates_rows_and_is_agent_visible(am_env):
mcp = mcps[0]
assert mcp.owner_user_id == OWNER
assert mcp.transport == "streamable_http"
- assert mcp.url == "http://mcp:9115/mcp/"
+ assert mcp.url == "http://mcp:9115/mcp"
assert mcp.is_enabled is True # http MCP is not needs_runtime → enabled upon install
assert len(mcp.tools_json) == 8
assert plugin is not None
diff --git a/src/backend/tests/test_browser_prewarm.py b/src/backend/tests/test_browser_prewarm.py
index 7c623051..ae67c390 100644
--- a/src/backend/tests/test_browser_prewarm.py
+++ b/src/backend/tests/test_browser_prewarm.py
@@ -4,6 +4,7 @@
from types import SimpleNamespace
import pytest
import httpx
+import core.db.engine as db_engine
from core.db.models import ChatSession
from core.capabilities.plugins.resources import installation, service
import core.sandbox.processes.interactive as interactive
@@ -17,7 +18,12 @@ def test_database_url(tmp_path):
@pytest.fixture
def runtime(db_session, monkeypatch):
+ from sqlalchemy.orm import sessionmaker
+ from core.db import engine as db_engine
+
monkeypatch.setenv("PLUGIN_RESOURCE_SECRET_KEY", "isolated-prewarm-fixture")
+ # Reconcile and close run on sessions of their own (core.db.threads).
+ monkeypatch.setattr(db_engine, "SessionLocal", sessionmaker(bind=db_session.bind))
db_session.add(ChatSession(chat_id="warm-chat", user_id="warm-user", title="warm"))
db_session.commit()
module = {"id": "browser", "resource": {"entry": "runtime", "callable": "worker:main", "prewarm": True}}
@@ -42,12 +48,12 @@ async def request(*a, **k):
async def test_first_open_adopts_warm_worker_once(runtime):
db, launches, _, _ = runtime
- warm = await service.create(db, "browser", "browser", "warm-user", "warm-chat", _prewarm=True)
+ warm = await service.create("browser", "browser", "warm-user", "warm-chat", _prewarm=True)
assert warm["status"] == "warm"
- opened = await service.create(db, "browser", "browser", "warm-user", "warm-chat")
+ opened = await service.create("browser", "browser", "warm-user", "warm-chat")
assert opened["resource_id"] == warm["resource_id"]
assert opened["status"] == "active"
- again = await service.create(db, "browser", "browser", "warm-user", "warm-chat")
+ again = await service.create("browser", "browser", "warm-user", "warm-chat")
assert again["resource_id"] != warm["resource_id"]
assert len(launches) == 2
@@ -60,21 +66,18 @@ async def slow_launch(*args):
await release.wait()
return await original(*args)
monkeypatch.setattr(interactive, "launch", slow_launch)
- warming = asyncio.create_task(service.create(db, "browser", "browser", "warm-user", "warm-chat", _prewarm=True))
+ warming = asyncio.create_task(service.create("browser", "browser", "warm-user", "warm-chat", _prewarm=True))
await entered.wait()
- # A separate request session observes the committed start reservation.
- from sqlalchemy.orm import Session
- with Session(db.bind) as other:
- opened = asyncio.create_task(service.create(other, "browser", "browser", "warm-user", "warm-chat"))
- await asyncio.sleep(0.1)
- assert not opened.done()
- release.set()
- warm, active = await asyncio.gather(warming, opened)
+ # A second request, on sessions of its own, observes the committed start reservation.
+ opened = asyncio.create_task(service.create("browser", "browser", "warm-user", "warm-chat"))
+ await asyncio.sleep(0.1)
+ assert not opened.done()
+ release.set()
+ warm, active = await asyncio.gather(warming, opened)
assert warm["resource_id"] == active["resource_id"]
assert len(launches) == 1
async def test_router_starts_warm_worker_in_background_and_close_reclaims_it(runtime, monkeypatch):
- from core.capabilities.plugins.resources import prewarm
from core.db.models import InstalledPlugin
from features.desktop.capabilities import device_catalog
from core.sandbox.sessions.router import SessionSandboxRouter
@@ -83,7 +86,7 @@ async def test_router_starts_warm_worker_in_background_and_close_reclaims_it(run
db.add(InstalledPlugin(install_id=selected.install_id, slug=selected.slug, name="Browser", owner_user_id="warm-user"))
db.commit()
monkeypatch.setattr(device_catalog, "active", lambda: False)
- monkeypatch.setattr(prewarm.engine, "SessionLocal", sessionmaker(bind=db.bind))
+ monkeypatch.setattr(db_engine, "SessionLocal", sessionmaker(bind=db.bind))
ensured, entered, release = [], asyncio.Event(), asyncio.Event()
original = interactive.launch
async def launch(*args):
@@ -104,7 +107,7 @@ async def close(*args):
release.set()
for _ in range(100):
await asyncio.sleep(0.01)
- with prewarm.engine.SessionLocal() as check:
+ with db_engine.SessionLocal() as check:
from core.capabilities.plugins.resources import store
rows = check.query(store.PluginResource).filter_by(status="warm").all()
if rows:
@@ -122,28 +125,28 @@ async def test_warm_is_private_and_counts_against_quota(runtime, monkeypatch):
from fastapi import HTTPException
db, launches, _, _ = runtime
monkeypatch.setenv("PLUGIN_RESOURCE_MAX_PER_USER", "1")
- warm = await service.create(db, "browser", "browser", "warm-user", "warm-chat", _prewarm=True)
+ warm = await service.create("browser", "browser", "warm-user", "warm-chat", _prewarm=True)
with pytest.raises(HTTPException) as unavailable:
service.authorized(db, warm["resource_id"], "warm-user")
assert unavailable.value.status_code == 410
db.add(ChatSession(chat_id="second-chat", user_id="warm-user", title="Second"))
db.commit()
with pytest.raises(HTTPException) as limited:
- await service.create(db, "browser", "browser", "warm-user", "second-chat")
+ await service.create("browser", "browser", "warm-user", "second-chat")
assert limited.value.status_code == 429
assert len(launches) == 1
- active = await service.create(db, "browser", "browser", "warm-user", "warm-chat")
+ active = await service.create("browser", "browser", "warm-user", "warm-chat")
assert active["resource_id"] == warm["resource_id"]
async def test_checkpoint_opens_its_own_worker_and_never_consumes_blank_warm(runtime):
from core.capabilities.plugins.resources import store
from core.capabilities.plugins.resources.crypto import encrypt_secret
db, launches, selected, _ = runtime
- warm = await service.create(db, "browser", "browser", "warm-user", "warm-chat", _prewarm=True)
+ warm = await service.create("browser", "browser", "warm-user", "warm-chat", _prewarm=True)
db.add(store.PluginResourceCheckpoint(checkpoint_id="saved", user_id="warm-user",
install_id=selected.install_id, name="Saved", state_enc=encrypt_secret('{"cookies":[]}')))
db.commit()
- restored = await service.create(db, "browser", "browser", "warm-user", "warm-chat", checkpoint_id="saved")
+ restored = await service.create("browser", "browser", "warm-user", "warm-chat", checkpoint_id="saved")
assert restored["resource_id"] != warm["resource_id"]
assert launches[-1]["checkpoint"] == {"cookies": []}
@@ -151,7 +154,7 @@ async def test_checkpoint_opens_its_own_worker_and_never_consumes_blank_warm(run
async def test_invalid_warm_is_never_reused(runtime, monkeypatch, change):
from core.capabilities.plugins.resources import store
db, launches, selected, provider = runtime
- warm = await service.create(db, "browser", "browser", "warm-user", "warm-chat", _prewarm=True)
+ warm = await service.create("browser", "browser", "warm-user", "warm-chat", _prewarm=True)
if change == "expired":
db.get(store.PluginResource, warm["resource_id"]).expires_at = 0
db.commit()
@@ -162,7 +165,7 @@ async def identity(chat):
else:
newer = installation.Installation(selected.install_id, "rev-2", selected.slug, selected.ui, selected.package)
monkeypatch.setattr(installation, "resolve", lambda *a, **kw: newer)
- active = await service.create(db, "browser", "browser", "warm-user", "warm-chat")
+ active = await service.create("browser", "browser", "warm-user", "warm-chat")
assert active["resource_id"] != warm["resource_id"]
assert len(launches) == 2
@@ -181,7 +184,7 @@ async def write(*args, **kwargs):
provider.write_stdin = write
monkeypatch.setattr(interactive, "launch", launch)
with pytest.raises(HTTPException) as denied:
- await service.create(db, "browser", "browser", "warm-user", "warm-chat", _prewarm=True)
+ await service.create("browser", "browser", "warm-user", "warm-chat", _prewarm=True)
assert denied.value.status_code == 403
assert cancelled == ["\x03"]
@@ -222,7 +225,7 @@ async def test_close_fence_blocks_readmission_until_provider_close_finishes(runt
db.add(InstalledPlugin(install_id=selected.install_id, slug=selected.slug, name="Browser", owner_user_id="warm-user"))
db.commit()
monkeypatch.setattr(device_catalog, "active", lambda: False)
- monkeypatch.setattr(prewarm.engine, "SessionLocal", sessionmaker(bind=db.bind))
+ monkeypatch.setattr(db_engine, "SessionLocal", sessionmaker(bind=db.bind))
entered, release = asyncio.Event(), asyncio.Event()
async def close(chat):
entered.set()
@@ -281,8 +284,8 @@ async def test_failed_warm_shutdown_reports_sandbox_dirty(runtime, monkeypatch):
from core.capabilities.plugins.resources import prewarm
from sqlalchemy.orm import sessionmaker
db, _, _, _ = runtime
- monkeypatch.setattr(prewarm.engine, "SessionLocal", sessionmaker(bind=db.bind))
- await service.create(db, "browser", "browser", "warm-user", "warm-chat", _prewarm=True)
+ monkeypatch.setattr(db_engine, "SessionLocal", sessionmaker(bind=db.bind))
+ await service.create("browser", "browser", "warm-user", "warm-chat", _prewarm=True)
db.commit()
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: HTTPClient(
**kwargs, transport=httpx.MockTransport(lambda request: httpx.Response(503))))
diff --git a/src/backend/tests/test_browser_remediation.py b/src/backend/tests/test_browser_remediation.py
index 959903b0..49bc610f 100644
--- a/src/backend/tests/test_browser_remediation.py
+++ b/src/backend/tests/test_browser_remediation.py
@@ -73,7 +73,7 @@ async def test_chat_recovers_only_live_owned_resources(runtime, monkeypatch):
from types import SimpleNamespace
from core.capabilities.plugins.resources import service
db, _, _, _ = runtime
- await service.create(db, "browser", "browser", "warm-user", "warm-chat")
+ await service.create("browser", "browser", "warm-user", "warm-chat")
result = await chat_resources("warm-chat", SimpleNamespace(user_id="warm-user"), db)
assert len(result["data"]["items"]) == 1
async def closed(*args):
diff --git a/src/backend/tests/test_browser_resource_assets.py b/src/backend/tests/test_browser_resource_assets.py
index 8e130dc2..9ec48d7b 100644
--- a/src/backend/tests/test_browser_resource_assets.py
+++ b/src/backend/tests/test_browser_resource_assets.py
@@ -8,6 +8,7 @@
from core.db.engine import get_db
from core.db.models.plugin_resource import PluginResource, PluginResourceAssetTicket
from core.capabilities.plugins.resources import store
+from tests.db_support import share_session
async def test_opaque_iframe_assets_use_revocable_ticket_without_session_cookie(monkeypatch, tmp_path):
from api.routes.v1 import plugin_resources
@@ -34,6 +35,7 @@ async def test_opaque_iframe_assets_use_revocable_ticket_without_session_cookie(
app = FastAPI()
app.include_router(plugin_resources.router)
app.dependency_overrides[get_db] = lambda: db
+ share_session(monkeypatch, db)
prefix = "/v1/plugin-resource-assets/cloud/" + ticket
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
diff --git a/src/backend/tests/test_browser_resource_security.py b/src/backend/tests/test_browser_resource_security.py
index 1526c784..a457fb55 100644
--- a/src/backend/tests/test_browser_resource_security.py
+++ b/src/backend/tests/test_browser_resource_security.py
@@ -72,25 +72,30 @@ async def test_viewer_authentication_is_revoked_with_real_session_store(monkeypa
monkeypatch.setattr(session, "_use_memory_store", lambda: True)
token = await session.create_session({"user_id": "owner", "user_center_id": "owner", "username": "test"})
context = {"headers": {"cookie": settings.session.cookie_name + "=" + token}, "path": "/api/v1/plugin-resources/resource/attach", "method": "POST"}
- await authentication.validate(context, "owner", None)
+ await authentication.validate(context, "owner")
await session.revoke_session(token)
from fastapi import HTTPException
with pytest.raises(HTTPException) as error:
- await authentication.validate(context, "owner", None)
+ await authentication.validate(context, "owner")
assert error.value.status_code == 401
async def test_expired_starting_reservation_is_cleaned_without_endpoint(monkeypatch):
+ from sqlalchemy.orm import sessionmaker
+ from sqlalchemy.pool import StaticPool
+ from core.db import engine as db_engine
from core.capabilities.plugins.resources import lifecycle
- engine = create_engine("sqlite://")
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
for model in (PluginResource, PluginResourceTicket, PluginResourceAssetTicket):
model.__table__.create(engine)
monkeypatch.setattr(store, "descriptor", lambda row: {})
+ monkeypatch.setattr(db_engine, "SessionLocal", sessionmaker(bind=engine))
with Session(engine) as db:
row = PluginResource(resource_id="starting", user_id="owner", chat_id="chat",
install_id="install", revision="revision", slug="fixture", module_id="module",
scope="local", descriptor="", status="starting", created_at=0, expires_at=0)
db.add(row); db.commit()
- await lifecycle.reconcile(db, "owner")
+ await lifecycle.reconcile("owner")
+ db.expire_all()
assert db.get(PluginResource, "starting").status == "closed"
@@ -118,16 +123,20 @@ async def test_opensandbox_endpoint_auth_is_confined_to_control_plane_origin():
assert headers == {"X-Endpoint-Route": "public"}
-def test_opensandbox_template_allows_the_actual_builtin_skill_mount(monkeypatch, tmp_path):
+def test_opensandbox_template_allows_the_actual_skill_mount(monkeypatch, tmp_path):
import tomllib
from pathlib import Path
- import importlib
import core.sandbox.providers.opensandbox.volumes as volumes
+ from core.capabilities.skills import config, publication
root = Path(__file__).resolve().parents[3]
monkeypatch.setenv("HOST_REPO_PATH", str(root))
- monkeypatch.setattr(volumes, "_BUILTIN_SKILLS_HOST_PATH", None)
- source = Path(volumes._resolve_builtin_skills_host_path())
- rendered = (root / "docker/opensandbox-config.toml.tpl").read_text().replace(
+ from types import SimpleNamespace
+ monkeypatch.setattr(volumes, "settings", SimpleNamespace(sandbox=SimpleNamespace(
+ opensandbox_host_storage_path=str(tmp_path))))
+ monkeypatch.setattr(config, "get_sandbox_skills_dir", lambda: tmp_path / "sandbox_skills")
+ monkeypatch.setattr(publication, "prepare_skill_view", lambda user_id: None)
+ source = Path(volumes._make_skills_volumes()[0].host.path)
+ rendered = (root / "docker/opensandbox-server/opensandbox-config.toml.tpl").read_text().replace(
"@@HOST_REPO_PATH@@", str(root)).replace("@@HOST_STORAGE_PATH@@", str(tmp_path))
allowed = tomllib.loads(rendered)["storage"]["allowed_host_paths"]
assert any(source == Path(path) or source.is_relative_to(path) for path in allowed)
@@ -159,9 +168,9 @@ async def test_resource_callback_rejects_changed_signed_installation(monkeypatch
body = plugin_resources.ToolRequest(chat_id="chat", slug="fixture", module_id="browser",
install_id="plugin:local:installation-b" if changed_body else source,
resource_id="resource-b", action="state")
- monkeypatch.setattr(plugin_resources.service, "authorized", lambda *args: SimpleNamespace(
+ monkeypatch.setattr(plugin_resources.service, "authorized_row", lambda *args: SimpleNamespace(
chat_id="chat", slug="fixture", module_id="browser", install_id="plugin:local:installation-b"))
with pytest.raises(HTTPException) as error:
- await plugin_resources.tool_resource(body, request, db=object())
+ await plugin_resources.tool_resource(body, request)
assert error.value.status_code == 403
assert error.value.detail in {"plugin_binding_mismatch", "resource_binding_mismatch"}
diff --git a/src/backend/tests/test_browser_resource_transport.py b/src/backend/tests/test_browser_resource_transport.py
index 2f6fa3c9..a0beb360 100644
--- a/src/backend/tests/test_browser_resource_transport.py
+++ b/src/backend/tests/test_browser_resource_transport.py
@@ -61,7 +61,7 @@ async def serve(reader, writer):
async def test_websocket_revocation_stops_commands_and_closes_transport(monkeypatch):
from starlette.websockets import WebSocketDisconnect
- from core.capabilities.plugins.resources import stream, authentication
+ from core.capabilities.plugins.resources import stream
from contextlib import nullcontext
bodies, writers, handlers, transports = [], [], set(), []
@@ -134,12 +134,14 @@ async def validate(*args):
if len(validations) == 4:
raise HTTPException(403, "fixture_revoked")
- monkeypatch.setattr(stream, "SessionLocal", lambda: nullcontext(object()))
+ from core.db import engine as db_engine
+
+ monkeypatch.setattr(db_engine, "SessionLocal", lambda: nullcontext(SimpleNamespace()))
monkeypatch.setattr(stream, "RuntimeTransport", TrackedTransport)
monkeypatch.setattr(stream.store, "consume", lambda *args, **kwargs: ("fixture-user", {}))
monkeypatch.setattr(stream.store, "descriptor", lambda row: target)
- monkeypatch.setattr(service, "authorized", lambda *args: SimpleNamespace())
- monkeypatch.setattr(authentication, "validate", validate)
+ monkeypatch.setattr(service, "authorized_row", lambda *args: SimpleNamespace())
+ monkeypatch.setattr(stream, "validate_authentication", validate)
socket = Socket()
try:
await asyncio.wait_for(stream.connect(socket, "fixture-resource"), 5)
diff --git a/src/backend/tests/test_ce_runtime_contracts.py b/src/backend/tests/test_ce_runtime_contracts.py
index 45c1a507..11bce425 100644
--- a/src/backend/tests/test_ce_runtime_contracts.py
+++ b/src/backend/tests/test_ce_runtime_contracts.py
@@ -30,7 +30,13 @@ def test_ce_has_no_enterprise_modules_or_model_exports():
"core.services.team_service",
"core.db.repository.team",
):
- assert importlib.util.find_spec(module_name) is None
+ try:
+ spec = importlib.util.find_spec(module_name)
+ except ModuleNotFoundError as exc:
+ # A removed parent package also makes this enterprise path unavailable.
+ assert exc.name and (module_name == exc.name or module_name.startswith(exc.name + "."))
+ spec = None
+ assert spec is None
for model_name in (
"Team",
"TeamMember",
@@ -116,8 +122,8 @@ def test_ce_user_router_exposes_self_service_password_change():
def test_dingtalk_status_env_does_not_import_ee_sandbox_module(monkeypatch, tmp_path):
- import core.sandbox.providers.account_env as _common_account_env
- import core.sandbox.workspace.credentials as _common_credentials
+ import integrations.dingtalk.environment as _common_account_env
+ import integrations.dingtalk.storage as _common_credentials
from integrations.dingtalk.service import _dws_env
monkeypatch.setattr(_common_credentials, "dws_home_dir", lambda _user_id: tmp_path / "dws-home")
@@ -184,7 +190,7 @@ def test_ce_local_launcher_covers_default_plugin_mcp_servers():
from extensions.mcp_servers._launcher import PORTS as LAUNCHER_PORTS
from extensions.mcp_servers._ports import PORTS, package_name
- assert _default_plugins() == ["automation", "skill-manager", "sites"]
+ assert _default_plugins() == ["automation", "skill-manager", "sites", "visualize"]
assert {
"automation_task": 9108,
"skill_manager": 9112,
@@ -196,7 +202,7 @@ def test_ce_local_launcher_covers_default_plugin_mcp_servers():
def test_ce_fresh_database_bootstraps_default_plugins(db_session):
from core.db.models import AdminMcpServer, ContentBlock, InstalledPlugin
- from core.capabilities.plugins import service as plugin_service
+ from core.capabilities.plugins import management as plugin_service
assert plugin_service.ensure_default_plugins_bootstrapped(db_session) is True
installs = db_session.query(InstalledPlugin).filter(InstalledPlugin.owner_user_id.is_(None))
@@ -204,6 +210,7 @@ def test_ce_fresh_database_bootstraps_default_plugins(db_session):
"automation",
"skill-manager",
"sites",
+ "visualize",
}
assert {
row.source_plugin
@@ -212,7 +219,7 @@ def test_ce_fresh_database_bootstraps_default_plugins(db_session):
.all()
} == {"automation", "skill-manager", "sites"}
marker = db_session.get(ContentBlock, plugin_service.DEFAULT_BOOTSTRAP_MARKER_ID)
- assert marker.payload["plugins"] == ["automation", "skill-manager", "sites"]
+ assert marker.payload["plugins"] == ["automation", "skill-manager", "sites", "visualize"]
assert plugin_service.ensure_default_plugins_bootstrapped(db_session) is False
@@ -271,7 +278,6 @@ def test_serve_cli_accepts_explicit_public_bind():
def test_configure_chat_model_persists_context_window(monkeypatch):
from core.db import engine
- from core.models.config import ModelConfigService
class FakeDb:
closed = False
@@ -281,7 +287,6 @@ def close(self):
db = FakeDb()
captured = {}
- invalidated = []
def fake_create_provider(_db, **kwargs):
captured.update(kwargs)
@@ -290,11 +295,6 @@ def fake_create_provider(_db, **kwargs):
monkeypatch.setattr(engine, "SessionLocal", lambda: db)
monkeypatch.setattr(model_repository, "create_provider", fake_create_provider)
monkeypatch.setattr(model_repository, "assign_role", lambda *args, **kwargs: None)
- monkeypatch.setattr(
- ModelConfigService,
- "get_instance",
- classmethod(lambda cls: SimpleNamespace(invalidate_cache=lambda: invalidated.append(True))),
- )
configure_model(
"http://model.example/v1",
@@ -306,7 +306,6 @@ def fake_create_provider(_db, **kwargs):
assert captured["extra_config"] == {"context_length": 65536}
assert db.closed is True
- assert invalidated == [True]
def test_ce_schema_keeps_ontology_control_plane_tables():
diff --git a/src/backend/tests/test_db_import_order.py b/src/backend/tests/test_db_import_order.py
new file mode 100644
index 00000000..c0565a3f
--- /dev/null
+++ b/src/backend/tests/test_db_import_order.py
@@ -0,0 +1,103 @@
+"""Database models load no matter which module a fresh process imports first."""
+from pathlib import Path
+import os
+import subprocess
+import sys
+
+import pytest
+
+BACKEND = Path(__file__).resolve().parents[1]
+
+CHECK = """
+import core.db as db
+import core.db.models as models
+from core.db import ChatSession, Team, get_db
+from core.db.models import * # noqa: F403
+from core.db.models import ProjectMember, Team as ModelTeam
+assert ModelTeam is Team and ProjectMember is models.ProjectMember
+assert {"Team", "ChatSessionUserState"} <= set(models.__all__)
+registry = db.Base.registry._class_registry
+assert {"Team", "ProjectMember", "ChatSession"} <= set(registry)
+print("DB_IMPORT_ORDER_OK")
+"""
+
+
+@pytest.mark.parametrize(
+ "entry",
+ [
+ "edition_ee.db.model_extensions",
+ "edition_ee.db.models",
+ "edition_ee.db.models.feedback",
+ "core.db.utc_datetime",
+ "core.auth.local_users",
+ "core.db.models",
+ ],
+)
+def test_models_import_from_any_entry_point(entry, tmp_path):
+ env = {
+ key: value for key, value in os.environ.items()
+ if key.upper() in {"PATH", "SYSTEMROOT", "WINDIR", "TMP", "TEMP", "TMPDIR"}
+ }
+ env.update(PYTHONPATH=str(BACKEND), HOME=str(tmp_path), PYTHONUTF8="1", REDIS_URL="")
+ result = subprocess.run(
+ [sys.executable, "-c", f"import {entry}\n{CHECK}"],
+ cwd=tmp_path,
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ encoding="utf-8",
+ timeout=90,
+ )
+ assert result.returncode == 0, result.stdout
+ assert "DB_IMPORT_ORDER_OK" in result.stdout
+
+
+def test_db_leaf_modules_do_not_load_models(tmp_path):
+ code = "import sys, core.db.engine, core.db.utc_datetime\nassert 'core.db.models' not in sys.modules\n"
+ result = subprocess.run(
+ [sys.executable, "-c", code],
+ cwd=tmp_path,
+ env={**os.environ, "PYTHONPATH": str(BACKEND)},
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ encoding="utf-8",
+ timeout=90,
+ )
+ assert result.returncode == 0, result.stdout
+
+
+@pytest.mark.parametrize(
+ "entry",
+ [
+ "features.automation.models",
+ "features.automation.service",
+ "extensions.mcp_servers.automation_task_mcp.server",
+ ],
+)
+def test_automation_relationships_resolve_in_a_cold_process(entry, tmp_path):
+ code = f"""
+import {entry}
+from features.automation.models import ScheduledTask
+from sqlalchemy.orm import configure_mappers
+configure_mappers()
+assert ScheduledTask.user.property.mapper.class_.__name__ == "UserShadow"
+assert ScheduledTask.plan.property.mapper.class_.__name__ == "Plan"
+print("AUTOMATION_MODELS_OK")
+"""
+ result = subprocess.run(
+ [sys.executable, "-c", code],
+ cwd=tmp_path,
+ env={
+ **os.environ,
+ "PYTHONPATH": str(BACKEND),
+ "DATABASE_URL": f"sqlite:///{tmp_path / 'automation.db'}",
+ "SQLITE_FALLBACK_URL": f"sqlite:///{tmp_path / 'fallback.db'}",
+ "REDIS_URL": "",
+ },
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ encoding="utf-8",
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stdout
+ assert "AUTOMATION_MODELS_OK" in result.stdout
diff --git a/src/backend/tests/test_iter_budget_middleware.py b/src/backend/tests/test_iter_budget_middleware.py
index 8de63c58..f70d78c9 100644
--- a/src/backend/tests/test_iter_budget_middleware.py
+++ b/src/backend/tests/test_iter_budget_middleware.py
@@ -5,7 +5,7 @@
which can be faked with SimpleNamespace.
"""
from core.evolution.profiles import agent_profile as agent_profile_module
-from core.evolution.profiles import agent_profile_state as agent_profile_state_module
+from core.evolution.profiles import agent_profile_constants as agent_profile_state_module
from types import SimpleNamespace
diff --git a/src/backend/tests/test_leader_election.py b/src/backend/tests/test_leader_election.py
index 12ad6162..f2283272 100644
--- a/src/backend/tests/test_leader_election.py
+++ b/src/backend/tests/test_leader_election.py
@@ -127,3 +127,51 @@ async def step() -> None:
sink.append("x")
return step
+
+async def test_shutdown_cannot_delete_successor_lease(shared_store):
+ holder = leader.Leadership("shutdown")
+ holder.start(on_elected=_noop, on_deposed=_noop)
+ await asyncio.sleep(0.01)
+ await shared_store.drop(holder._key)
+ successor = leader.Leadership("shutdown")
+ assert await successor._hold()
+ await holder.stop()
+ assert await shared_store.get(successor._key) == successor._token
+
+
+async def test_shutdown_stops_work_before_releasing_role(shared_store):
+ observed = []
+ holder = leader.Leadership("ordered")
+ async def stopped():
+ observed.append(await shared_store.get(holder._key))
+ holder.start(on_elected=_noop, on_deposed=stopped)
+ await asyncio.sleep(0.01)
+ await holder.stop()
+ assert observed == [holder._token]
+
+
+async def test_failed_gate_cannot_admit_other_workers(shared_store):
+ async def fail():
+ raise ValueError("required initialization failed")
+ with pytest.raises(Exception):
+ await leader.run_once("failure", fail)
+ with pytest.raises(Exception):
+ await leader.run_once("failure", _noop)
+
+
+async def test_renewal_uncertainty_deposes_work(shared_store, monkeypatch):
+ stopped = asyncio.Event()
+ holder = leader.Leadership("failure", lease_seconds=1)
+ holder.start(on_elected=_noop, on_deposed=_recorder_event(stopped))
+ await asyncio.sleep(0.02)
+ async def failed(*args, **kwargs):
+ raise ConnectionError("Redis lost")
+ monkeypatch.setattr(shared_store, "compare_exchange", failed)
+ await asyncio.wait_for(stopped.wait(), 1)
+ await holder.stop()
+
+
+def _recorder_event(event):
+ async def record():
+ event.set()
+ return record
diff --git a/src/backend/tests/test_local_mcp_http_readiness.py b/src/backend/tests/test_local_mcp_http_readiness.py
new file mode 100644
index 00000000..2ec421f9
--- /dev/null
+++ b/src/backend/tests/test_local_mcp_http_readiness.py
@@ -0,0 +1,33 @@
+"""MCP readiness waits for the listener to accept and answer the connection."""
+import asyncio
+
+import pytest
+
+from core.infra.local_runtime.startup import _http_port_ready
+
+
+@pytest.mark.asyncio
+async def test_http_probe_waits_for_response_even_when_root_route_is_missing():
+ requests = []
+ async def serve(reader, writer):
+ requests.append(await reader.readuntil(b"\r\n\r\n"))
+ await asyncio.sleep(0.05)
+ writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
+ await writer.drain()
+ writer.close()
+ await writer.wait_closed()
+ server = await asyncio.start_server(serve, "127.0.0.1", 0)
+ async with server:
+ assert await _http_port_ready("127.0.0.1", server.sockets[0].getsockname()[1])
+ assert requests[0].startswith(b"HEAD / HTTP/1.1\r\n")
+
+
+@pytest.mark.asyncio
+async def test_tcp_accept_without_an_http_response_is_not_ready():
+ async def serve(reader, writer):
+ await reader.readuntil(b"\r\n\r\n")
+ writer.close()
+ await writer.wait_closed()
+ server = await asyncio.start_server(serve, "127.0.0.1", 0)
+ async with server:
+ assert not await _http_port_ready("127.0.0.1", server.sockets[0].getsockname()[1])
diff --git a/src/backend/tests/test_local_memory_defaults.py b/src/backend/tests/test_local_memory_defaults.py
index e4290ce5..88aeae3a 100644
--- a/src/backend/tests/test_local_memory_defaults.py
+++ b/src/backend/tests/test_local_memory_defaults.py
@@ -146,6 +146,7 @@ def test_cli_reconfigures_redirected_gbk_stream_to_utf8(monkeypatch):
[
"desktop/requirements-desktop-windows-py311.lock",
"desktop/requirements-desktop-linux-x86_64-py311.lock",
+ "desktop/requirements-desktop-linux-aarch64-py311.lock",
"desktop/requirements-desktop-macos-aarch64-py311.lock",
"desktop/requirements-desktop-macos-x86_64-py311.lock",
],
@@ -157,7 +158,7 @@ def test_desktop_offline_runtime_includes_persistent_memory_stack(relative_path)
assert "mem0ai==" in lock
assert "protobuf==" in lock
assert "pymilvus==2.5.18" in lock
- assert "milvus-lite==3.1.0" in lock
+ assert "milvus-lite==3.2.2" in lock
def test_hybrid_does_not_install_local_default_plugins(monkeypatch):
diff --git a/src/backend/tests/test_local_profile.py b/src/backend/tests/test_local_profile.py
index fcc51bad..e180121d 100644
--- a/src/backend/tests/test_local_profile.py
+++ b/src/backend/tests/test_local_profile.py
@@ -126,7 +126,7 @@ def test_seed_builtin_mcp_is_idempotent_and_templates_url(db_session):
sample_id = next(iter(expected_ids))
sample = db_session.query(AdminMcpServer).filter_by(server_id=sample_id).one()
assert sample.transport == "streamable_http"
- assert sample.url.endswith(f":{PORTS[sample_id]}/mcp/")
+ assert sample.url.endswith(f":{PORTS[sample_id]}/mcp")
def test_seed_builtin_mcp_skips_when_catalog_present(db_session):
diff --git a/src/backend/tests/test_management_failures.py b/src/backend/tests/test_management_failures.py
index 948fba1d..1d1992bf 100644
--- a/src/backend/tests/test_management_failures.py
+++ b/src/backend/tests/test_management_failures.py
@@ -54,9 +54,7 @@ def test_local_management_uses_same_safe_error(monkeypatch):
from agentscope.message import ToolResultState
from core.llm.tools.management import ManagementTool
- tool = ManagementTool("manager", SimpleNamespace(
- name="install_skill", description="Install", inputSchema={}
- ), "skill-manager", {}, cloud_backed=False)
+ tool = ManagementTool("manager", dict(name="install_skill", description="Install", inputSchema={}), "skill-manager", {}, cloud_backed=False)
original = RuntimeError("private payload")
original.pgcode = "22001"
def fail(arguments):
diff --git a/src/backend/tests/test_mcp_canonical_url.py b/src/backend/tests/test_mcp_canonical_url.py
new file mode 100644
index 00000000..d9c5a09b
--- /dev/null
+++ b/src/backend/tests/test_mcp_canonical_url.py
@@ -0,0 +1,30 @@
+"""Built-in MCP requests must not incur an HTTP redirect."""
+from core.capabilities.mcp.service import rewrite_builtin_mcp_host
+from core.config.mcp_config import mcp_http_url
+
+
+def test_builtin_canonical_endpoint():
+ assert mcp_http_url("web_fetch").endswith("/mcp")
+ assert rewrite_builtin_mcp_host("http://mcp:9106/mcp/").endswith("/mcp")
+
+
+def test_external_paths_and_queries_are_preserved():
+ external = "https://example.com/mcp/?tenant=a"
+ assert rewrite_builtin_mcp_host(external) == external
+ assert rewrite_builtin_mcp_host("http://mcp:9106/custom/?tenant=a").endswith("/custom/?tenant=a")
+
+
+def test_local_seed_uses_canonical_endpoint(db_session, monkeypatch):
+ from core.config.settings import settings
+ from core.capabilities.mcp.service import seed_builtin_mcp_servers_if_empty, McpServerConfigService
+ from core.db.models import AdminMcpServer
+
+ from dataclasses import replace
+
+ monkeypatch.setattr("core.config.settings.settings",
+ replace(settings, server=replace(settings.server, mcp_host="127.0.0.1")))
+ assert seed_builtin_mcp_servers_if_empty(db_session)
+ for row in db_session.query(AdminMcpServer).all():
+ assert row.url.startswith("http://127.0.0.1:")
+ assert row.url.endswith("/mcp")
+ assert McpServerConfigService()._row_to_config(row)["url"] == row.url
diff --git a/src/backend/tests/test_personal_agent.py b/src/backend/tests/test_personal_agent.py
index d8009c9b..71eb5486 100644
--- a/src/backend/tests/test_personal_agent.py
+++ b/src/backend/tests/test_personal_agent.py
@@ -3,7 +3,6 @@
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.db.models import UserShadow
from personal_agent.context import AgentContext
@@ -19,10 +18,13 @@ def personal_agent_on(monkeypatch):
@pytest.fixture
-def sessions():
- engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
+def sessions(monkeypatch, tmp_path):
+ # A file database: background steps run in worker threads, each on its own connection.
+ engine = create_engine(f"sqlite:///{tmp_path / 'pa.db'}", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
+ # Async code reaches the database through core.db.threads, which opens core.db.engine sessions.
+ monkeypatch.setattr("core.db.engine.SessionLocal", factory)
with factory() as db:
for uid in ("owner", "other"):
db.add(UserShadow(user_id=uid, user_center_id=uid, username=uid))
diff --git a/src/backend/tests/test_personal_agent_channel_sessions.py b/src/backend/tests/test_personal_agent_channel_sessions.py
index bf87eda1..87dd5d80 100644
--- a/src/backend/tests/test_personal_agent_channel_sessions.py
+++ b/src/backend/tests/test_personal_agent_channel_sessions.py
@@ -11,6 +11,7 @@
from personal_agent.conversation.message_routing import admit
from personal_agent.conversation.conversations import channel_session, listing
from tests.test_personal_agent_routing import sessions, personal_agent_on, turn, scripted # noqa: F401
+from tests.db_support import share_factory
def conversation(db, peer="alice", bot="bot", kind="p2p"):
@@ -80,7 +81,7 @@ async def inspect(plan):
assert db.get(ChatMessage, admitted["message_id"]).chat_id == alice
-def test_http_conversation_scope_and_owner_checks(sessions):
+def test_http_conversation_scope_and_owner_checks(sessions, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.auth.backend import UserContext, get_current_user
@@ -94,6 +95,7 @@ def test_http_conversation_scope_and_owner_checks(sessions):
def database():
with sessions() as db: yield db
app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, sessions)
app.dependency_overrides[get_current_user] = lambda: UserContext(user_id="owner", user_center_id="owner", username="owner")
with TestClient(app) as client:
response = client.post("/v1/personal-agent/messages", params={"chat_id": alice},
diff --git a/src/backend/tests/test_personal_agent_cold_start.py b/src/backend/tests/test_personal_agent_cold_start.py
index 19766b1e..e22507b7 100644
--- a/src/backend/tests/test_personal_agent_cold_start.py
+++ b/src/backend/tests/test_personal_agent_cold_start.py
@@ -1,6 +1,6 @@
"""Cold SQLite initialization must allow catalog configuration to seed independently."""
from core.config.store import system_config
-from core.config.store import system_config_state
+from core.config.store import system_config_constants
from types import SimpleNamespace
from sqlalchemy import create_engine, select
@@ -28,7 +28,7 @@ def test_first_identity_allows_catalog_seed_on_another_connection(tmp_path, monk
Base.metadata.create_all(engine)
config = system_config.SystemConfigService()
monkeypatch.setattr("core.db.system_configuration.SessionLocal", factory)
- monkeypatch.setattr(system_config_state, 'SEED_CONFIGS', [
+ monkeypatch.setattr(system_config_constants, 'SEED_CONFIGS', [
("pa.cold_start", "seeded", "Cold start", "", "test", False),
])
monkeypatch.setattr(capabilities, "plugin_inventory", lambda db, user_id: [])
diff --git a/src/backend/tests/test_personal_agent_computer_wait.py b/src/backend/tests/test_personal_agent_computer_wait.py
index f9c23453..afdf70b8 100644
--- a/src/backend/tests/test_personal_agent_computer_wait.py
+++ b/src/backend/tests/test_personal_agent_computer_wait.py
@@ -77,7 +77,9 @@ async def driver(*args):
monkeypatch.setattr(computer, "cloud_driver", driver)
class Toolkit:
def __init__(self): self.functions = {}
- def register_tool_function(self, fn): self.functions[fn.__name__] = fn
+ def append(self, tool):
+ fn = tool._func
+ self.functions[fn.__name__] = fn
with sessions() as db:
svc = AgentContext(db, "owner")
svc.identity.configure({"approval_mode": "full"})
diff --git a/src/backend/tests/test_personal_agent_delivery.py b/src/backend/tests/test_personal_agent_delivery.py
index deca0098..d16b93eb 100644
--- a/src/backend/tests/test_personal_agent_delivery.py
+++ b/src/backend/tests/test_personal_agent_delivery.py
@@ -53,10 +53,9 @@ async def get_file(box, path, user_id=None): return files[path]
monkeypatch.setattr(tools, "get_personal_sandbox_provider", lambda: SimpleNamespace(get_file=get_file))
cache = LocalEphemeralState()
monkeypatch.setattr("core.infra.ephemeral.get_ephemeral_state", lambda: cache)
- kit = SimpleNamespace(functions={})
- kit.register_tool_function = lambda fn: kit.functions.__setitem__(fn.__name__, fn)
+ kit = []
tools.register_personal_tools(kit, SimpleNamespace(profile_ref=task, current_user_id="owner"))
- yield SimpleNamespace(task=task, functions=kit.functions, files=files, storage=storage, cache=cache)
+ yield SimpleNamespace(task=task, functions={tool.name: tool._func for tool in kit}, files=files, storage=storage, cache=cache)
current_execution.set(None)
diff --git a/src/backend/tests/test_personal_agent_dreaming.py b/src/backend/tests/test_personal_agent_dreaming.py
index 67c5e6c1..0acf7cc2 100644
--- a/src/backend/tests/test_personal_agent_dreaming.py
+++ b/src/backend/tests/test_personal_agent_dreaming.py
@@ -11,6 +11,7 @@
from personal_agent.conversation.message_routing import admit
from tests.test_personal_agent_routing import sessions, personal_agent_on, turn, scripted
from tests.test_personal_agent_file_memory import workspace
+from tests.db_support import share_factory
NOW = datetime(2026, 10, 18, 4, tzinfo=timezone.utc).timestamp()
@@ -237,7 +238,7 @@ async def hold(payload, provider_id=None):
assert calls == [1]
-def test_dreaming_api_settings_run_and_owner_isolation(workspace, sessions):
+def test_dreaming_api_settings_run_and_owner_isolation(workspace, sessions, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.db.engine import get_db
@@ -247,6 +248,7 @@ def test_dreaming_api_settings_run_and_owner_isolation(workspace, sessions):
def database():
with sessions() as db: yield db
app.dependency_overrides[get_db] = database
+ share_factory(monkeypatch, sessions)
app.dependency_overrides[get_current_user] = lambda: UserContext(user_id="owner", user_center_id="owner", username="owner")
with TestClient(app) as client:
assert client.patch("/v1/personal-agent/settings", json={"dreaming": {"enabled": True, "hour": 4}}).status_code == 200
diff --git a/src/backend/tests/test_personal_agent_fixes.py b/src/backend/tests/test_personal_agent_fixes.py
index cd6bfa3f..92cc391f 100644
--- a/src/backend/tests/test_personal_agent_fixes.py
+++ b/src/backend/tests/test_personal_agent_fixes.py
@@ -321,7 +321,9 @@ def test_schedule_is_not_duplicated_and_scheduled_run_cannot_schedule(sessions,
class Toolkit:
def __init__(self): self.functions = {}
- def register_tool_function(self, fn): self.functions[fn.__name__] = fn
+ def append(self, tool):
+ fn = tool._func
+ self.functions[fn.__name__] = fn
toolkit = Toolkit()
monkeypatch.setattr("personal_agent.tools.email.register_email_tools", lambda *a: None)
monkeypatch.setattr("personal_agent.computer.computer.register_computer_tools", lambda *a: None)
diff --git a/src/backend/tests/test_personal_agent_monitor.py b/src/backend/tests/test_personal_agent_monitor.py
index cf5a9df8..4eb1fcc9 100644
--- a/src/backend/tests/test_personal_agent_monitor.py
+++ b/src/backend/tests/test_personal_agent_monitor.py
@@ -16,9 +16,10 @@
from personal_agent.delivery.outputs import listing
@pytest.fixture
-def sessions():
+def sessions(monkeypatch):
engine=create_engine("sqlite://",connect_args={"check_same_thread":False},poolclass=StaticPool)
Base.metadata.create_all(engine);factory=sessionmaker(bind=engine,expire_on_commit=False)
+ monkeypatch.setattr("core.db.engine.SessionLocal",factory)
with factory() as db:
db.add_all([UserShadow(user_id=u,user_center_id=u,username=u) for u in ["owner","other"]]);db.commit()
yield factory;engine.dispose()
diff --git a/src/backend/tests/test_personal_agent_prompt_cache.py b/src/backend/tests/test_personal_agent_prompt_cache.py
new file mode 100644
index 00000000..350a04ed
--- /dev/null
+++ b/src/backend/tests/test_personal_agent_prompt_cache.py
@@ -0,0 +1,22 @@
+from tests.test_personal_agent_routing import sessions, AgentContext, admit
+from tests.test_personal_agent import personal_agent_on
+
+
+def test_the_turn_prefix_stays_cacheable(sessions, monkeypatch):
+ """The system prompt is identical across turns; the per-turn context follows the current message."""
+ from core.db import engine as db_engine
+ from personal_agent.conversation import front
+ monkeypatch.setattr(db_engine, "SessionLocal", sessions)
+ monkeypatch.setattr("personal_agent.tasks.guards.SessionLocal", sessions)
+ with sessions() as db:
+ svc = AgentContext(db, "owner")
+ first = admit(svc, "你好", "hi")
+ svc.conversation.append(svc.identity.agent(), "assistant", "你好!", None, reply_to=first["message_id"]); db.commit()
+ second = admit(svc, "再见", "bye")
+ assert front.system_prompt(first["message_id"]) == front.system_prompt(second["message_id"])
+ assert "Current time" not in front.system_prompt(second["message_id"])
+ assert front.turn_context(second["message_id"]).startswith(front.CONTEXT_OPEN)
+ rows = front.with_context(front.history(db, second["message_id"]), "")
+ assert rows[-1]["content"] == "再见\n\n"
+ # Earlier messages are a prefix of what the previous turn sent.
+ assert front.history(db, second["message_id"])[0] == front.history(db, first["message_id"])[0]
diff --git a/src/backend/tests/test_personal_agent_routing.py b/src/backend/tests/test_personal_agent_routing.py
index 017e37c7..69d0ff01 100644
--- a/src/backend/tests/test_personal_agent_routing.py
+++ b/src/backend/tests/test_personal_agent_routing.py
@@ -31,6 +31,7 @@ def sessions(monkeypatch):
from personal_agent.conversation import turn_worker
for module in (front, front_tools, turn_worker): monkeypatch.setattr(module, "SessionLocal", factory)
monkeypatch.setattr("personal_agent.tasks.guards.SessionLocal", factory)
+ monkeypatch.setattr("core.db.engine.SessionLocal", factory)
from core.runs import journal as run_journal
@asynccontextmanager
@@ -44,10 +45,9 @@ async def binding(**options):
def tools_for(ident, uid="owner"):
from personal_agent.tools.front_tools import register_front_tools
- kit = SimpleNamespace(functions={})
- kit.register_tool_function = lambda fn: kit.functions.__setitem__(fn.__name__, fn)
+ kit = []
register_front_tools(kit, SimpleNamespace(profile_ref=ident, current_user_id=uid))
- return kit.functions
+ return {tool.name: tool._func for tool in kit}
def result(response):
@@ -484,23 +484,3 @@ def test_an_agent_with_a_conversation_never_hatches(sessions, fresh):
svc = AgentContext(db, "owner")
admit(svc, "你好", "hi")
assert not front.hatch(svc) and len(pending(svc)) == 1
-
-
-def test_the_turn_prefix_stays_cacheable(sessions, monkeypatch):
- """The system prompt is identical across turns; the per-turn context follows the current message."""
- from core.db import engine as db_engine
- from personal_agent.conversation import front
- monkeypatch.setattr(db_engine, "SessionLocal", sessions)
- monkeypatch.setattr("personal_agent.tasks.guards.SessionLocal", sessions)
- with sessions() as db:
- svc = AgentContext(db, "owner")
- first = admit(svc, "你好", "hi")
- svc.conversation.append(svc.identity.agent(), "assistant", "你好!", None, reply_to=first["message_id"]); db.commit()
- second = admit(svc, "再见", "bye")
- assert front.system_prompt(first["message_id"]) == front.system_prompt(second["message_id"])
- assert "Current time" not in front.system_prompt(second["message_id"])
- assert front.turn_context(second["message_id"]).startswith(front.CONTEXT_OPEN)
- rows = front.with_context(front.history(db, second["message_id"]), "")
- assert rows[-1]["content"] == "再见\n\n"
- # Earlier messages are a prefix of what the previous turn sent.
- assert front.history(db, second["message_id"])[0] == front.history(db, first["message_id"])[0]
diff --git a/src/backend/tests/test_personal_agent_session_tool.py b/src/backend/tests/test_personal_agent_session_tool.py
index 36bda56c..46eac3ea 100644
--- a/src/backend/tests/test_personal_agent_session_tool.py
+++ b/src/backend/tests/test_personal_agent_session_tool.py
@@ -29,6 +29,7 @@ def db(monkeypatch):
for module in (approvals, session_tool, session_watch, tools):
monkeypatch.setattr(module, "SessionLocal", factory)
monkeypatch.setattr("personal_agent.tasks.guards.SessionLocal", factory)
+ monkeypatch.setattr("core.db.engine.SessionLocal", factory)
with factory() as session:
session.add(UserShadow(user_id="owner", user_center_id="owner", username="owner"))
session.commit()
@@ -40,7 +41,7 @@ def db(monkeypatch):
def cloud(monkeypatch):
calls = []
- async def execute(db, user, operation, args, origin=None):
+ async def execute(user, operation, args, origin=None):
calls.append((operation, args))
if operation == "chat.send":
return {"run_id": "run_x", "chat_id": args["chat_id"], "status": "accepted"}
@@ -142,7 +143,7 @@ async def test_a_run_the_task_followed_is_not_reported_again(db, cloud, monkeypa
])
db.commit()
- async def execute(db, user, operation, args, origin=None):
+ async def execute(user, operation, args, origin=None):
return {"run_id": args["run_id"], "status": "completed"}
monkeypatch.setattr(session_cloud, "execute", execute)
diff --git a/src/backend/tests/test_personal_agent_sessions.py b/src/backend/tests/test_personal_agent_sessions.py
index 25d9c06b..189623aa 100644
--- a/src/backend/tests/test_personal_agent_sessions.py
+++ b/src/backend/tests/test_personal_agent_sessions.py
@@ -137,27 +137,27 @@ async def test_task_grants_are_bound_to_run_owner_and_running_lease(db, monkeypa
request = Request(
{"type": "http", "headers": [(b"authorization", ("Bearer " + token).encode())]}
)
- user, grant = await session_auth.resolve(request, db)
+ user, grant = await session_auth.resolve(request)
assert user.user_id == "owner" and grant["task"] == task
row.run_id = "replacement-run"
db.commit()
with pytest.raises(HTTPException) as exc:
- await session_auth.resolve(request, db)
+ await session_auth.resolve(request)
assert exc.value.status_code == 401
row.run_id = "run"
db.commit()
row.owner = "replacement"
db.commit()
with pytest.raises(HTTPException):
- await session_auth.resolve(request, db)
+ await session_auth.resolve(request)
row.owner = "worker"
row.lease_until = time.time() - 1
db.commit()
with pytest.raises(HTTPException):
- await session_auth.resolve(request, db)
+ await session_auth.resolve(request)
await store.drop(session_auth.key(token))
with pytest.raises(HTTPException):
- await session_auth.resolve(request, db)
+ await session_auth.resolve(request)
def test_trusted_cloud_installer_script_is_valid_python():
diff --git a/src/backend/tests/test_personal_agent_steer.py b/src/backend/tests/test_personal_agent_steer.py
index b08bc4ac..69f57b08 100644
--- a/src/backend/tests/test_personal_agent_steer.py
+++ b/src/backend/tests/test_personal_agent_steer.py
@@ -171,9 +171,9 @@ async def model(**kwargs):
@pytest.mark.asyncio
async def test_steered_attachments_are_staged_and_cumulative_limit_is_atomic(sessions, monkeypatch):
from core.db.models import Artifact
- from personal_agent.tasks import steering, attachments
+ from personal_agent.tasks import steering
monkeypatch.setattr(steering, "SessionLocal", sessions)
- monkeypatch.setattr(attachments, "SessionLocal", sessions)
+ monkeypatch.setattr("core.db.engine.SessionLocal", sessions)
staged = []
class Sandbox:
async def put_file(self, box, path, content, user_id):
diff --git a/src/backend/tests/test_personal_agent_tool_regressions.py b/src/backend/tests/test_personal_agent_tool_regressions.py
index 7feabda3..82cb861e 100644
--- a/src/backend/tests/test_personal_agent_tool_regressions.py
+++ b/src/backend/tests/test_personal_agent_tool_regressions.py
@@ -37,7 +37,9 @@ async def get_file(self, *args, **kwargs): return self.content
monkeypatch.setattr(tools, "get_personal_sandbox_provider", lambda: provider)
class Toolkit:
def __init__(self): self.functions = {}
- def register_tool_function(self, fn): self.functions[fn.__name__] = fn
+ def append(self, tool):
+ fn = tool._func
+ self.functions[fn.__name__] = fn
with sessions() as db:
task = AgentContext(db,"owner").tasks.submit("Export file","one")
row = db.get(PersonalWorkItem, task["id"])
@@ -116,7 +118,9 @@ async def put_file(self, box, path, content, user_id=None): self.files[path] = c
monkeypatch.setattr(tools, "get_personal_sandbox_provider", lambda: provider)
class Toolkit:
def __init__(self): self.functions = {}
- def register_tool_function(self, fn): self.functions[fn.__name__] = fn
+ def append(self, tool):
+ fn = tool._func
+ self.functions[fn.__name__] = fn
with sessions() as db:
task = AgentContext(db, "owner").tasks.submit("Remember preference", "edit-one")
row = db.get(PersonalWorkItem, task["id"])
diff --git a/src/backend/tests/test_personal_task_transcript_order.py b/src/backend/tests/test_personal_task_transcript_order.py
index 4230ee7f..d092ebca 100644
--- a/src/backend/tests/test_personal_task_transcript_order.py
+++ b/src/backend/tests/test_personal_task_transcript_order.py
@@ -192,8 +192,7 @@ async def input_loop_pulse():
monkeypatch.setattr("personal_agent.tools.tools.SessionLocal", sessions)
monkeypatch.setattr("personal_agent.tools.space.SessionLocal", sessions)
monkeypatch.setattr("personal_agent.tools.email.SessionLocal", sessions)
- kit = SimpleNamespace(functions={})
- kit.register_tool_function = lambda fn: kit.functions.__setitem__(fn.__name__, fn)
+ kit = []
register_personal_tools(kit, SimpleNamespace(profile_ref=ident, current_user_id="owner"))
# Substitute only the model/stream boundary; admission, task tools, transcript,
@@ -216,7 +215,7 @@ async def stream(self, **options):
async def continue_model(**kwargs):
assert "改用脚本" in agent.state.context[-1].content[0].text
yield "text_delta", "已按脚本要求完成"
- await kit.functions["pa_finish"]("已按脚本要求完成")
+ await {tool.name: tool._func for tool in kit}["pa_finish"]("已按脚本要求完成")
async for event in middleware.on_reasoning(agent, {}, continue_model):
yield event
diff --git a/src/backend/tests/test_plugin_components.py b/src/backend/tests/test_plugin_components.py
index c1c6b906..c6effe2f 100644
--- a/src/backend/tests/test_plugin_components.py
+++ b/src/backend/tests/test_plugin_components.py
@@ -24,6 +24,7 @@
from core.capabilities.plugins.management import packages as plugin_packages
from core.capabilities.plugins.packaging import sources as plugin_sources
+from tests.db_support import share_session
OWNER = "test_user_123"
@@ -175,9 +176,6 @@ async def test_feishu_plugin_app_routes_delegate_to_lark_service(monkeypatch):
calls = []
class FakeLarkService:
- def __init__(self, db):
- assert db is None
-
def app_status(self):
calls.append("status")
return {"configured": True, "status": "configured"}
@@ -202,7 +200,7 @@ async def reset_app(self):
assert calls == ["status", "init", "reset"]
-def test_route_import_and_uninstall_e2e(tmp_path, db_session):
+def test_route_import_and_uninstall_e2e(tmp_path, db_session, monkeypatch):
"""Import a plugin zip through the real FastAPI route stack, then list and uninstall."""
from api.app import app
from core.auth.backend import UserContext, get_current_user
@@ -230,6 +228,7 @@ def _override_db():
email="t@e.com",
)
app.dependency_overrides[get_db] = _override_db
+ share_session(monkeypatch, db_session)
client = TestClient(app)
try:
diff --git a/src/backend/tests/test_plugin_manager_plugin.py b/src/backend/tests/test_plugin_manager_plugin.py
index 8b8331b9..013f0a99 100644
--- a/src/backend/tests/test_plugin_manager_plugin.py
+++ b/src/backend/tests/test_plugin_manager_plugin.py
@@ -88,7 +88,7 @@ def test_bundle_manifest_is_wellformed():
assert manifest["name"] == "plugin-manager"
ext_mcp = manifest["extensions"]["org.hugagent"]["mcp"]
assert set(ext_mcp) == set(mcp_json["mcpServers"]), "扩展段的服务名必须与 mcp.json 一一对应"
- assert mcp_json["mcpServers"]["plugin_manager"]["url"] == "http://mcp:9116/mcp/"
+ assert mcp_json["mcpServers"]["plugin_manager"]["url"] == "http://mcp:9116/mcp"
from extensions.mcp_servers.plugin_manager_mcp import server
@@ -138,7 +138,7 @@ def test_install_plugin_creates_rows_and_is_agent_visible(pm_env):
assert len(skills) == 1 and skills[0].owner_user_id == OWNER
assert len(mcps) == 1
mcp = mcps[0]
- assert mcp.url == "http://mcp:9116/mcp/"
+ assert mcp.url == "http://mcp:9116/mcp"
assert mcp.is_enabled is True
assert len(mcp.tools_json) == 8
assert plugin is not None
diff --git a/src/backend/tests/test_plugin_progressive_loading.py b/src/backend/tests/test_plugin_progressive_loading.py
index 4e43eb2a..3927fe23 100644
--- a/src/backend/tests/test_plugin_progressive_loading.py
+++ b/src/backend/tests/test_plugin_progressive_loading.py
@@ -14,7 +14,7 @@
import core.db.engine as dbe
import pytest
from core.db.models import ChatSession, InstalledPlugin, UserShadow
-from core.llm.runtime.tool_collector import ToolCollector
+from core.llm.factory.tools.definitions import ToolAssembly
from core.capabilities.plugins import runtime as plugin_loader
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@@ -203,24 +203,20 @@ def test_directory_section_stable_and_empty_safe(ppl_env):
assert plugin_loader.build_plugin_directory_section(res2.directory) == section
-class _FakeMCPClient:
- def __init__(self, name, cfg):
- self.name = name
-
- async def list_tools(self):
- return [
- SimpleNamespace(
- name=name,
- description=f"{name} tool",
- input_schema={"type": "object", "properties": {}},
- )
- for name in ("crawl_url", "crawl_site")
- ]
+async def _discover(key, cfg, *, interactions, scope):
+ assert interactions.agent is not None
+ assert interactions.request.chat_id == CHAT
+ assert scope == (USER, CHAT, "")
+ from agentscope.tool import FunctionTool
+ from core.llm.mcp.lifetime import MCPResource
+ async def crawl():
+ return "ok"
+ return MCPResource(key, tools=[FunctionTool(crawl, name=name) for name in ("crawl_url", "crawl_site")])
@pytest.mark.asyncio
async def test_load_plugin_tool_activates_in_place(ppl_env, tmp_path, monkeypatch):
- import core.llm.mcp.pool as mcp_pool
+ import core.llm.mcp.lifetime as mcp_pool
from core.agent_runtime.budget import _compaction_budget_inputs
from core.llm.factory.runtime.evidence import cache_compaction_execution_surface
from core.llm.tools.ontology_toolkit import OntologyFilteredToolkit
@@ -229,8 +225,8 @@ async def test_load_plugin_tool_activates_in_place(ppl_env, tmp_path, monkeypatc
monkeypatch.setattr(
mcp_pool,
- "make_client",
- lambda key, cfg, is_stateful=True, **kw: _FakeMCPClient(key, cfg),
+ "discover",
+ _discover,
)
# 技能目录物化桩:含 SKILL.md 的临时目录。
@@ -243,7 +239,7 @@ async def test_load_plugin_tool_activates_in_place(ppl_env, tmp_path, monkeypatc
ppl_env.loader.get_skill_dir = lambda sid: str(skill_dir)
res = _resolve(ppl_env)
- collector = ToolCollector()
+ collector = ToolAssembly()
tk = OntologyFilteredToolkit(tools=[], mcps=[])
budget_agent = SimpleNamespace()
tk.set_execution_surface_listener(
@@ -258,6 +254,8 @@ async def test_load_plugin_tool_activates_in_place(ppl_env, tmp_path, monkeypatc
permission_registry = ToolPermissionRegistry()
runtime = {
+ "request": SimpleNamespace(current_user_id=USER, chat_id=CHAT, capability_scope="", isolated=False),
+ "agent": SimpleNamespace(),
"activated_slugs": set(),
"connected_keys": {"internet_search"},
"toolkit": tk,
@@ -274,7 +272,7 @@ async def test_load_plugin_tool_activates_in_place(ppl_env, tmp_path, monkeypatc
"ontology_runtime": {},
}
plugin_activation.register_load_plugin(collector, res.deferred_by_slug(), runtime)
- tool = collector.get_tool("load_plugin")
+ tool = next(t for t in collector if t.name == "load_plugin")
assert tool is not None
resp = await tool._func(plugin="crawler")
@@ -282,7 +280,7 @@ async def test_load_plugin_tool_activates_in_place(ppl_env, tmp_path, monkeypatc
assert "已加载" in text and "crawl_url" in text and "crawler-scrape-a1" in text
basic = tk.tool_groups[0]
- assert len(basic.mcps) == 1 and len(close_list) == 1
+ assert len(basic.tools) == 2 and len(close_list) == 1
assert {r for r in permission_context.allow_rules} == {"crawl_url", "crawl_site"}
assert permission_registry.names == frozenset()
assert len(basic.skills_or_loaders) == 1
@@ -320,7 +318,7 @@ async def fake_summary(history, *, timeout):
resp2 = await tool._func(plugin="crawler")
text2 = "".join(getattr(b, "text", "") for b in resp2.content)
assert "无需重复调用" in text2
- assert len(basic.mcps) == 1
+ assert len(basic.tools) == 2
resp3 = await tool._func(plugin="nope")
text3 = "".join(getattr(b, "text", "") for b in resp3.content)
@@ -341,19 +339,21 @@ def test_resolve_bound_defers_http_plugins_and_applies_skill_filter(ppl_env):
def test_load_plugin_no_persist_for_subagent_runtime(ppl_env, tmp_path, monkeypatch):
- import core.llm.mcp.pool as mcp_pool
+ import core.llm.mcp.lifetime as mcp_pool
from agentscope.tool import Toolkit
monkeypatch.setattr(
mcp_pool,
- "make_client",
- lambda key, cfg, is_stateful=True, **kw: _FakeMCPClient(key, cfg),
+ "discover",
+ _discover,
)
res = plugin_loader.resolve_bound_progressive_plugins(["crawler@global"])
- collector = ToolCollector()
+ collector = ToolAssembly()
from core.sandbox.permissions.tool_specs import ToolPermissionRegistry
runtime = {
+ "request": SimpleNamespace(current_user_id=USER, chat_id=CHAT, capability_scope="", isolated=False),
+ "agent": SimpleNamespace(),
"activated_slugs": set(),
"connected_keys": set(),
"toolkit": Toolkit(tools=[], mcps=[]),
@@ -371,7 +371,7 @@ def test_load_plugin_no_persist_for_subagent_runtime(ppl_env, tmp_path, monkeypa
"ontology_runtime": {},
}
plugin_activation.register_load_plugin(collector, res.deferred_by_slug(), runtime)
- tool = collector.get_tool("load_plugin")
+ tool = next(t for t in collector if t.name == "load_plugin")
resp = asyncio.run(tool._func(plugin="crawler"))
text = "".join(getattr(b, "text", "") for b in resp.content)
assert "已加载" in text
diff --git a/src/backend/tests/test_route_import_order.py b/src/backend/tests/test_route_import_order.py
new file mode 100644
index 00000000..6f114049
--- /dev/null
+++ b/src/backend/tests/test_route_import_order.py
@@ -0,0 +1,74 @@
+"""Route modules import on their own, without composing the application first."""
+from pathlib import Path
+import importlib.util
+import os
+import subprocess
+import sys
+
+import pytest
+
+BACKEND = Path(__file__).resolve().parents[1]
+
+
+def _run(code, tmp_path):
+ env = {
+ key: value for key, value in os.environ.items()
+ if key.upper() in {"PATH", "SYSTEMROOT", "WINDIR", "TMP", "TEMP", "TMPDIR",
+ "DATABASE_URL", "SQLITE_FALLBACK_URL"}
+ }
+ env.update(PYTHONPATH=str(BACKEND), HOME=str(tmp_path), PYTHONUTF8="1", REDIS_URL="")
+ return subprocess.run(
+ [sys.executable, "-c", code],
+ cwd=tmp_path,
+ env=env,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ encoding="utf-8",
+ timeout=120,
+ )
+
+
+@pytest.mark.parametrize(
+ "entry",
+ [
+ # Each of these once imported ``api`` (and with it the whole app),
+ # which re-imported the route module while it was half initialised.
+ "edition_ee.api.deps",
+ "edition_ee.api.openapi_data_schemas",
+ "edition_ee.routes.config_teams",
+ "edition_ee.routes.team_file_assets",
+ "features.marketplace.api.admin_marketplace",
+ "features.workbench.api.workbench_auth",
+ "features.datasources.api.data_sources",
+ # api.routes eagerly imported files -> local_file_actions -> api.routes.
+ "features.desktop.api.local_file_actions",
+ # device_catalog re-exports from device_plugin_catalog at import time.
+ "features.desktop.capabilities.device_plugin_catalog",
+ ],
+)
+def test_route_module_imports_first(entry, tmp_path):
+ if importlib.util.find_spec(entry.split(".")[0]) is None:
+ pytest.skip(f"{entry} is not part of this edition")
+ result = _run(f"import {entry}\nprint('ROUTE_IMPORT_OK')", tmp_path)
+ assert result.returncode == 0, result.stdout
+ assert "ROUTE_IMPORT_OK" in result.stdout
+
+
+def test_api_submodules_do_not_compose_the_app_but_app_still_resolves(tmp_path):
+ code = """
+import sys
+import api.deps
+from api.routes import file_location
+assert "api.app" not in sys.modules
+from api import app
+from api.routes import files_router
+from fastapi import FastAPI
+assert isinstance(app, FastAPI)
+from features.desktop.capabilities import device_catalog, device_plugin_catalog
+assert device_catalog.plugin_entries is device_plugin_catalog.plugin_entries
+print("API_LAZY_OK")
+"""
+ result = _run(code, tmp_path)
+ assert result.returncode == 0, result.stdout
+ assert "API_LAZY_OK" in result.stdout
diff --git a/src/backend/tests/test_script_runner_process_limits.py b/src/backend/tests/test_script_runner_process_limits.py
index 226b60f2..45cc333d 100644
--- a/src/backend/tests/test_script_runner_process_limits.py
+++ b/src/backend/tests/test_script_runner_process_limits.py
@@ -155,9 +155,9 @@ def test_ce_installer_and_script_runner_ship_office_skill_runtime():
# In the generated public CE tree the overlay becomes the root installer.
installer_path = repo_root / "install.sh"
installer = installer_path.read_text(encoding="utf-8")
- dockerfile = (repo_root / "docker" / "Dockerfile.script-runner").read_text(encoding="utf-8")
+ dockerfile = (repo_root / "docker" / "sandbox" / "Dockerfile.script-runner").read_text(encoding="utf-8")
- assert "pip_install -r docker/requirements-script-runner.txt" in installer
+ assert "pip_install -r docker/sandbox/requirements-script-runner.txt" in installer
assert '--prefix "${SKILL_NODE_DIR}" playwright' in installer
assert "apt-get download fonts-wqy-zenhei" in installer
assert "install_libreoffice" in installer
diff --git a/src/backend/tests/test_session_database_isolation.py b/src/backend/tests/test_session_database_isolation.py
new file mode 100644
index 00000000..fc309071
--- /dev/null
+++ b/src/backend/tests/test_session_database_isolation.py
@@ -0,0 +1,23 @@
+"""Concurrent pytest sessions never share a default SQLite database."""
+import os
+
+import pytest
+
+from tests import conftest
+
+
+def test_default_databases_are_private_to_this_session(session_db_dir, test_database_url):
+ assert session_db_dir == conftest.SESSION_DB_DIR
+ assert f"hugagent-tests-{os.getpid()}-" in os.path.basename(session_db_dir)
+ if os.getenv("TEST_DATABASE_URL"):
+ pytest.skip("TEST_DATABASE_URL overrides the session database")
+ assert test_database_url == f"sqlite:///{session_db_dir}/hugagent_test.db"
+
+
+def test_application_database_defaults_into_the_session_dir(session_db_dir):
+ if conftest.DATABASE_URL_PRESET:
+ pytest.skip("DATABASE_URL was set explicitly")
+ from core.config.settings import settings
+
+ assert settings.db.url == f"sqlite:///{session_db_dir}/hugagent.db"
+ assert settings.db.sqlite_fallback_url.startswith(f"sqlite:///{session_db_dir}/")
diff --git a/src/backend/tests/test_skill_manager_plugin.py b/src/backend/tests/test_skill_manager_plugin.py
index 41536eba..821726be 100644
--- a/src/backend/tests/test_skill_manager_plugin.py
+++ b/src/backend/tests/test_skill_manager_plugin.py
@@ -80,7 +80,7 @@ def test_install_plugin_creates_rows_and_is_agent_visible(sm_env):
mcp = mcps[0]
assert mcp.owner_user_id == OWNER
assert mcp.transport == "streamable_http"
- assert mcp.url == "http://mcp:9112/mcp/"
+ assert mcp.url == "http://mcp:9112/mcp"
assert mcp.is_enabled is True # http MCP is not needs_runtime → enabled upon install
assert len(mcp.tools_json) == 8
assert plugin is not None
diff --git a/src/backend/tests/test_tool_contributions.py b/src/backend/tests/test_tool_contributions.py
index 8f08203c..1ad3801c 100644
--- a/src/backend/tests/test_tool_contributions.py
+++ b/src/backend/tests/test_tool_contributions.py
@@ -85,6 +85,6 @@ async def install(stage, toolkit, context):
def test_headless_host_composes_tools_without_asgi():
import subprocess
import sys
- script = 'import asyncio\nimport logging\nimport sys\nfrom unittest.mock import patch\nfrom features import register_product_tools\nfrom core.harness.registry import NativeToolContext, install_tools\nfrom core.llm.factory.request import AgentRequest\nfrom core.llm.runtime.tool_collector import ToolCollector\n\nassert "api.app" not in sys.modules\nregister_product_tools()\nregister_product_tools()\n\nasync def main():\n toolkit = ToolCollector()\n context = NativeToolContext(AgentRequest(chat_id="test", current_user_id="test"), True, True, None, None, ["site-builder"], ["db_query"], logging.getLogger("test"))\n with patch("features.datasources.metadata.eligible_datasource_ids", return_value=["fixture"]):\n for stage in ("after_sandbox", "after_image", "after_workflow"):\n await install_tools(stage, toolkit, context)\n names = [tool.name for tool in toolkit.function_tools]\n assert names == ["choose_design", "channel_read_attachment", "get_data_context"], names\n assert "api.app" not in sys.modules\n print("headless tool composition passed", names)\n\nasyncio.run(main())\n'
+ script = 'import asyncio\nimport logging\nimport sys\nfrom unittest.mock import patch\nfrom features import register_product_tools\nfrom core.harness.registry import NativeToolContext, install_tools\nfrom core.llm.factory.request import AgentRequest\nfrom core.llm.factory.tools.definitions import ToolAssembly\n\nassert "api.app" not in sys.modules\nregister_product_tools()\nregister_product_tools()\n\nasync def main():\n toolkit = ToolAssembly()\n context = NativeToolContext(AgentRequest(chat_id="test", current_user_id="test"), True, True, None, None, ["site-builder"], ["db_query"], logging.getLogger("test"))\n with patch("features.datasources.metadata.eligible_datasource_ids", return_value=["fixture"]):\n for stage in ("after_sandbox", "after_image", "after_workflow"):\n await install_tools(stage, toolkit, context)\n names = [tool.name for tool in list(toolkit)]\n assert names == ["choose_design", "channel_read_attachment", "get_data_context"], names\n assert "api.app" not in sys.modules\n print("headless tool composition passed", names)\n\nasyncio.run(main())\n'
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
assert result.returncode == 0, result.stdout + result.stderr
diff --git a/src/backend/tests/test_yida_local_login.py b/src/backend/tests/test_yida_local_login.py
index 85d1529d..68e25fcd 100644
--- a/src/backend/tests/test_yida_local_login.py
+++ b/src/backend/tests/test_yida_local_login.py
@@ -55,7 +55,9 @@ async def test_local_skill_uses_login_workspace(tmp_path, monkeypatch):
file = tmp_path / "login.md"
file.write_text("cd /home/ubuntu/yida-workspace && openyida login --agent-qr")
class Tools:
- def register_tool_function(self, function, **kwargs):
+ def append(self, tool):
+ function = tool._func
+ kwargs = {"func_description": tool.description, "permission": getattr(tool, "dispatch_spec", None)}
self.read = function
tools = Tools()
register_sandboxed_view_text_file(tools, [str(tmp_path)], SimpleNamespace(), user_id="owner")
diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile
index b233cc0c..e72d4dfb 100644
--- a/src/frontend/Dockerfile
+++ b/src/frontend/Dockerfile
@@ -29,7 +29,6 @@ COPY packages/workbench-protocol /build/packages/workbench-protocol
COPY packages/workbench-sdk /build/packages/workbench-sdk
COPY packages/chat-sdk /build/packages/chat-sdk
RUN cd /build/packages/chat-sdk && npm ci --ignore-scripts && npm run build
-COPY examples/customer-chat /build/examples/customer-chat
# Install dependencies (including dev dependencies for build)
RUN npm ci
diff --git a/src/frontend/scripts/test-reference-motion-browser.mjs b/src/frontend/scripts/test-reference-motion-browser.mjs
index 755d2a17..77bbc966 100644
--- a/src/frontend/scripts/test-reference-motion-browser.mjs
+++ b/src/frontend/scripts/test-reference-motion-browser.mjs
@@ -29,7 +29,6 @@ function Fixture(){
const [done,setDone]=useState(false);
const tool={id:'reference-tool',name:'Bash',input:{command:'pwd'},status:done?'success':'running',startTs:ts,endTs:done?ts+1000:undefined,output:done?'/workspace':undefined};
const steps=[{kind:'tool',key:'tool',tool},{kind:'thinking',key:'thinking',content:'检查输入并整理线索',active:!done}];
- if(!done)steps.push({kind:'pending',key:'pending',startTs:ts});
return
setDone(true)}>完成
{messages.map((m,i)=>{}} exportChatRecord={async()=>{}}/>)}
@@ -72,34 +71,45 @@ try{
if(user)assert.equal(user.frames[0].transform,'scale(0.92) translateY(6px)');
if(assistant)assert.equal(assistant.frames[0].transform,'translateY(10px)');
await page.locator('.jx-trs-head').click();
- const labels=page.locator('.jx-turnStatus-label,.jx-inlineSummaryText--live,.jx-trs-summary--live,.jx-tcr-prefix--shimmer');
+ const labels=page.locator('.jx-rollText-text.is-shimmer,.jx-inlineSummaryText--live,.jx-tcr-prefix--shimmer');
const live=await labels.evaluateAll(els=>els.map(el=>{const s=getComputedStyle(el);return {name:s.animationName,duration:s.animationDuration,size:s.backgroundSize,clip:s.backgroundClip,gradient:s.backgroundImage};}));
- assert.ok(live.length>=5,'startup, inline thinking, execution title, tool, thinking step and pending labels participate');
+ assert.ok(live.length>=5,'startup, inline thinking, execution title, tool and thinking step labels participate');
assert.ok(live.every(s=>s.name==='jxTurnStatusShine'&&s.duration==='2.8s'&&s.size==='250% 100%'&&s.clip==='text'),'all running labels use the same reference scan');
assert.equal(new Set(live.map(s=>s.gradient)).size,1,'tool.css must not override the shared gradient');
- const summary=page.locator('.jx-trs-summary--live');
+ const summary=page.locator('.jx-trs-summary');
assert.equal(await summary.locator('.jx-trs-dur').count(),1);
- assert.equal(await summary.locator('.jx-trs-steps').count(),1);
- const scan=await summary.evaluate(el=>{const animation=el.getAnimations()[0];window.summaryScan=animation;return {start:animation.startTime,text:el.textContent};});
+ assert.equal(await page.locator('.jx-trs-steps').count(),0,'the tool-kind chips replace the step count');
+ assert.equal(await page.locator('.jx-trs-head .jx-kindChip').count(),1,'one chip per kind of action');
+ assert.equal(await page.locator('.jx-trs-head .jx-kindChip.is-running').count(),1,'the running kind is lit');
+ assert.equal(await page.locator('.jx-trs-head .jx-agentMark--work').count(),0,'a run with tools shows its chips, not the agent mark');
+ assert.equal(await page.locator('.jx-trs-head .jx-agentSlot').count(),0,'a run with tools leaves no mark slot');
+ assert.equal(await page.locator('.jx-trs-head .jx-kindStack .jx-kindChip.is-running').count(),1,'the lit chip carries the running state');
+ const title=summary.locator('.jx-rollText-text.is-shimmer');
+ const scan=await title.evaluate(el=>{const animation=el.getAnimations()[0];window.summaryScan=animation;return {start:animation.startTime};});
+ const before=await summary.locator('.jx-trs-dur').textContent();
await page.waitForTimeout(1100);
- const tick=await summary.evaluate(el=>({same:el.getAnimations()[0]===window.summaryScan,start:el.getAnimations()[0].startTime,text:el.textContent,fill:[...el.querySelectorAll('.jx-trs-title,.jx-trs-dur,.jx-trs-steps')].map(n=>getComputedStyle(n).webkitTextFillColor)}));
- assert.equal(tick.same,true,'timer ticks retain the same shared scan');
+ const tick=await title.evaluate(el=>({same:el.getAnimations()[0]===window.summaryScan,start:el.getAnimations()[0].startTime,fill:getComputedStyle(el).webkitTextFillColor}));
+ assert.equal(tick.same,true,'timer ticks do not restart the title scan');
assert.equal(tick.start,scan.start);
- assert.notEqual(tick.text,scan.text,'elapsed seconds continue to update');
- assert.ok(tick.fill.every(fill=>fill==='rgba(0, 0, 0, 0)'),'title, time and tool count inherit the same text clipping');
- const positions=await page.locator('.jx-turnStatus-label').evaluate(el=>{const a=el.getAnimations()[0];a.pause();return [0,280,1400,2500].map(t=>{a.currentTime=t;return getComputedStyle(el).backgroundPosition;});});
+ assert.notEqual(await summary.locator('.jx-trs-dur').textContent(),before,'elapsed seconds continue to update');
+ assert.equal(tick.fill,'rgba(0, 0, 0, 0)','the running title is the scanned surface');
+ assert.notEqual(await summary.locator('.jx-trs-dur').evaluate(el=>getComputedStyle(el).webkitTextFillColor),'rgba(0, 0, 0, 0)','only the title scans; the clock stays plain');
+ const positions=await page.locator('.jx-turnStatus-label .jx-rollText-text').evaluate(el=>{const a=el.getAnimations()[0];a.pause();const d=a.effect.getTiming().delay;return [0,280,1400,2500].map(t=>{a.currentTime=t+d+2800;return getComputedStyle(el).backgroundPosition;});});
+ // Labels phase-lock to a shared clock through a negative delay; sample one full cycle in.
assert.equal(positions[0],positions[1],'scan holds for the first 15%');assert.notEqual(positions[1],positions[2]);
const skeleton=await page.locator('.jx-skeletonBlock').first().evaluate(el=>{const s=getComputedStyle(el,'::after');return {name:s.animationName,duration:s.animationDuration,gradient:s.backgroundImage,pointer:s.pointerEvents};});
assert.equal(skeleton.name,'jx-skeletonSweep');assert.equal(skeleton.duration,'1.6s');assert.ok(skeleton.gradient.includes('110deg'));assert.equal(skeleton.pointer,'none');
await page.screenshot({path:resolve(evidence,theme+'-running.png'),fullPage:true});
await page.getByRole('button',{name:'完成',exact:true}).click();
assert.equal(await labels.count(),0,'completed thinking and tool labels stop scanning');
+ assert.equal(await page.locator('.jx-trs-head .jx-agentMark--rest').count(),0,'a settled run with tools shows no grey mark');
+ assert.equal(await page.locator('.jx-trs-head .jx-kindChip').count(),1,'a settled run keeps its chips');
await page.emulateMedia({reducedMotion:'reduce'});
await page.reload();
await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;},theme);
const reduced=await page.locator('section').evaluate(el=>[...el.querySelectorAll('.jx-msg.assistant,.jx-bubble.user')].map(n=>({opacity:getComputedStyle(n).opacity,transform:getComputedStyle(n).transform})));
assert.ok(reduced.every(s=>s.opacity==='1'&&['none','matrix(1, 0, 0, 1, 0, 0)'].includes(s.transform)),'reduced motion shows both message types immediately');
- assert.equal(await page.locator('.jx-turnStatus-label').evaluate(el=>getComputedStyle(el).animationName),'jxTurnStatusShine');
+ assert.equal(await page.locator('.jx-turnStatus-label .jx-rollText-text').evaluate(el=>getComputedStyle(el).animationName),'jxTurnStatusShine');
await page.setViewportSize({width:390,height:844});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'no narrow-screen overflow');
await page.screenshot({path:resolve(evidence,theme+'-mobile-reduced.png'),fullPage:true});
diff --git a/src/frontend/scripts/test-sidebar-task-status-browser.mjs b/src/frontend/scripts/test-sidebar-task-status-browser.mjs
index cd10bc4f..230f33eb 100644
--- a/src/frontend/scripts/test-sidebar-task-status-browser.mjs
+++ b/src/frontend/scripts/test-sidebar-task-status-browser.mjs
@@ -118,8 +118,11 @@ try{
assert.equal(await spinner.locator('.jx-taskSpinnerMotion').evaluate(el=>getComputedStyle(el).animationDuration),'1.5s');
assert.equal(await page.locator('#ordinary [data-task-status="completed"]').evaluate(el=>getComputedStyle(el).backgroundColor),await page.locator('#ordinary').evaluate(el=>{const probe=document.createElement('span');probe.style.color='var(--color-primary)';el.append(probe);const color=getComputedStyle(probe).color;probe.remove();return color;}),'unread completion uses the primary blue');
await page.locator('#personal .jx-personalUnread').waitFor();assert.equal(await page.locator('#personal .jx-personalUnread').innerText(),'3');
- assert.equal(await page.locator('#personal [data-task-status="running"]').count(),1,'running and unread coexist');
- assert.match(await page.locator('#compact button').getAttribute('aria-label'),/运行中.*3 条未读消息/,'compact navigation announces task state and unread count');
+ assert.equal(await page.locator('#personal .jx-taskStatus').count(),1,'unread messages light the entry');
+ assert.equal(await page.locator('#personal [data-task-status]').count(),0,'task progress does not light the entry');
+ const compactLabel=await page.locator('#compact button').getAttribute('aria-label');
+ assert.match(compactLabel,/3 条未读消息/,'compact navigation announces the unread count');
+ assert.doesNotMatch(compactLabel,/运行中/,'compact navigation omits task progress');
await page.screenshot({path:resolve(evidence,'light-four-states.png'),fullPage:true});
await row('completed').click();await page.waitForTimeout(100);
assert.equal(await page.locator('#ordinary [data-task-status="completed"]').getAttribute('data-task-unread'),'true','opening a scrolled-away result does not mark it read');
@@ -136,9 +139,9 @@ try{
assert.equal(await page.locator('#ordinary [data-task-status="failed"]').getAttribute('data-task-unread'),'true','a later failed run shows a new reminder');
await page.locator('#personal button').click();await page.getByTestId('reader').evaluate(el=>{el.scrollTop=el.scrollHeight;el.dispatchEvent(new Event('scroll'));});
await page.waitForFunction(()=>!document.querySelector('#personal .jx-personalUnread'));
- assert.equal(await page.locator('#personal [data-task-status="running"]').count(),1,'reading messages does not stop the running lamp');
+ assert.equal(await page.locator('#personal .jx-taskStatus').count(),0,'reading every message turns the lamp off while a task still runs');
personalRunning=false;await page.evaluate(()=>window.dispatchEvent(new Event('hugagent:personal-agent-updated')));
- await page.locator('#personal [data-task-status="completed"]').waitFor();
+ await page.locator('#personal .jx-taskStatus').waitFor();
assert.equal(await page.locator('#personal .jx-personalUnread').innerText(),'1','completion after reading creates a new unread reminder');
await page.locator('#open-dialog').click();
await page.getByText('Earlier progress',{exact:true}).waitFor();
@@ -159,12 +162,12 @@ try{
assert.ok(await page.locator('#compact .jx-taskStatus').isVisible());
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,'narrow preview fits');
const other=await page.context().newPage();await other.goto('http://127.0.0.1:'+server.address().port);
- await other.locator('#personal [data-task-status="completed"]').waitFor();
+ await other.locator('#personal .jx-personalUnread').waitFor();
await page.evaluate(()=>{const store=window.reminders.getState();store.observe([{key:'chat:a',id:'new-a',status:'completed',startedAt:5,updatedAt:6,messageId:'new-message'}]);});
await other.waitForFunction(()=>window.reminders.getState().notices['chat:a']?.id==='new-a');
await page.evaluate(()=>window.reminders.getState().markSeen('chat:a','new-a'));
await other.waitForFunction(()=>window.reminders.getState().notices['chat:a']?.id==='new-a'&&window.reminders.getState().notices['chat:a'].seen);
await other.close();
assert.deepEqual(errors,[]);
- console.log('PASS browser: running ring, blue unread dot, left project slots and right default markers; approval priority; actual viewport read; dismissed viewed failure and new failure reminder; Personal Agent simultaneous running/unread and new completion; reduced motion; dark mode; narrow rail.');
+ console.log('PASS browser: running ring, blue unread dot, left project slots and right default markers; approval priority; actual viewport read; dismissed viewed failure and new failure reminder; Personal Agent lamp only for unread messages; reduced motion; dark mode; narrow rail.');
}finally{await browser.close();await new Promise(r=>server.close(r));}
diff --git a/src/frontend/scripts/test-subagent-canvas-browser.mjs b/src/frontend/scripts/test-subagent-canvas-browser.mjs
index 1a4f8f9c..b135ea07 100644
--- a/src/frontend/scripts/test-subagent-canvas-browser.mjs
+++ b/src/frontend/scripts/test-subagent-canvas-browser.mjs
@@ -73,7 +73,8 @@ try {
await page.locator('#capabilities .jx-tcr-capabilityIcon img[src="/' + icon + '.svg"]').first().waitFor();
}
assert.equal(await page.locator('#capabilities img[src="/plugin.svg"]').count(), 3, 'Plugin skills and tools inherit plugin logo');
- assert.equal(await page.locator('#capabilities .anticon-loading').count(), 1, 'Connector spinner is separate from its logo');
+ assert.equal(await page.locator('#capabilities .anticon-loading').count(), 0, 'Capability rows show their logo, no separate spinner');
+ assert.equal(await page.locator('#capabilities .jx-tcr-prefix--shimmer').count(), 1, 'The running connector row scans its label');
const skillRow = page.locator('#capabilities .jx-tcr-header').first();
await skillRow.click();
assert.equal(await page.locator('aside').count(), 0, 'Capability icons retain inline expansion');
diff --git a/src/frontend/scripts/test-turn-status-alignment.mjs b/src/frontend/scripts/test-turn-status-alignment.mjs
index 572d7c30..da6821df 100644
--- a/src/frontend/scripts/test-turn-status-alignment.mjs
+++ b/src/frontend/scripts/test-turn-status-alignment.mjs
@@ -45,7 +45,7 @@ try{
await page.goto('http://127.0.0.1:'+server.address().port);
await page.waitForSelector('#message .jx-turnStatusText');
const measure=selector=>page.locator(selector).evaluate(el=>{
- const row=el.querySelector('.jx-turnStatusRow'),mark=el.querySelector('.jx-turnStatusMark'),text=el.querySelector('.jx-turnStatusText');
+ const row=el.querySelector('.jx-turnStatusRow'),mark=el.querySelector('.jx-agentSlot,.jx-turnStatusMark'),text=el.querySelector('.jx-turnStatusText');
const r=row.getBoundingClientRect(),m=mark.getBoundingClientRect(),t=text.getBoundingClientRect(),s=getComputedStyle(text);
return {rowHeight:r.height,markX:m.x-r.x,markY:m.y-r.y,markWidth:m.width,markHeight:m.height,textX:t.x,textY:t.y,textHeight:t.height,fontSize:s.fontSize,fontWeight:s.fontWeight,lineHeight:s.lineHeight};
});
diff --git a/src/frontend/src/components/automation/AutomationTaskSections.tsx b/src/frontend/src/components/automation/AutomationTaskSections.tsx
index fcb9db9d..7cbd41d1 100644
--- a/src/frontend/src/components/automation/AutomationTaskSections.tsx
+++ b/src/frontend/src/components/automation/AutomationTaskSections.tsx
@@ -14,7 +14,7 @@ import {
RUN_STATUS_CLASS,
RUN_STATUS_LABEL, SCHEDULE_TYPE_LABEL, channelConversationLabel,
cronToHumanReadable,
- formatRunDuration, formatTimezone
+ formatRunDuration, formatTimezone, isRunActive
} from './automationUtils';
import { ScheduleSelector } from './ScheduleSelector';
@@ -220,13 +220,13 @@ export function AutomationTaskSections({ form, task, runs, isEditing, editSchedu
{run.result_summary || RUN_STATUS_LABEL[run.status] || '-'}
- {run.status !== 'running' && run.chat_id && (
+ {run.chat_id && (
navigateToChat(run)}
>
- {t('查看对话')}
+ {isRunActive(run) ? t('查看进度') : t('查看对话')}
)}
{run.status === 'failed' && run.error_message && (
diff --git a/src/frontend/src/components/automation/RunTimelinePanel.tsx b/src/frontend/src/components/automation/RunTimelinePanel.tsx
index 76b263cc..ac633b74 100644
--- a/src/frontend/src/components/automation/RunTimelinePanel.tsx
+++ b/src/frontend/src/components/automation/RunTimelinePanel.tsx
@@ -8,7 +8,8 @@ RightOutlined,
} from '@ant-design/icons';
import { Tooltip } from 'antd';
import { AnimatePresence,motion } from 'motion/react';
-import { useMemo,useState } from 'react';
+import { useEffect,useMemo,useState } from 'react';
+import { getAutomationRuns } from '../../api';
import { useStatusFlash } from '../../hooks/useFlash';
import { t } from '../../i18n';
import { useAutomationChatStore,useCatalogStore } from '../../stores';
@@ -16,7 +17,7 @@ import '../../styles/automation-timeline.css';
import type { AutomationChatGroup,AutomationRun,AutomationRunStatus } from '../../types';
import { formatDateKey,formatFullDateTime,formatMonthDay,formatMonthDayTime } from '../../utils/date';
import { DUR,EASE,staggerStyle } from '../../utils/motionTokens';
-import { RUN_STATUS_LABEL,formatRunDuration } from '../automation/automationUtils';
+import { RUN_STATUS_LABEL,formatRunDuration,isRunActive } from './automationUtils';
/* Stat number y6→0 crossfade (shared config between top stats and mini stats) */
const STAT_NUM_MOTION = {
@@ -26,6 +27,9 @@ const STAT_NUM_MOTION = {
transition: { duration: DUR.fast, ease: EASE.standard },
};
+/* 有执行中的 run 时刷新列表的间隔:状态流转、chat_id 关联都靠它同步到面板 */
+const RUN_POLL_INTERVAL_MS = 5_000;
+
interface DateGroup {
date: string;
fullDate: string;
@@ -39,7 +43,7 @@ function formatRunOrdinal(n: number): string {
}
function summarizeRun(run: AutomationRun): string {
- if (run.status === 'running') return t('任务正在执行,结果生成后会自动更新到对话区。');
+ if (isRunActive(run)) return t('任务正在执行,结果生成后会自动更新到对话区。');
if (run.status === 'failed') {
return String(run.error_message || run.result_summary || t('执行失败,请进入详情查看错误信息。')).replace(/\s+/g, ' ').trim();
}
@@ -47,9 +51,31 @@ function summarizeRun(run: AutomationRun): string {
}
export function RunTimelinePanel() {
- const { activeGroup, selectedRunId, selectRun, exitAutomationChat } = useAutomationChatStore();
+ const { activeGroup, selectedRunId, selectRun, setRuns, exitAutomationChat } = useAutomationChatStore();
const { setPanel } = useCatalogStore();
+ // activeGroup 只是进入时的快照:有执行中的 run 就轮询,否则状态与 chat_id 永远停在进入那一刻
+ const pollTaskId = activeGroup?.runs.some(isRunActive) ? activeGroup.taskId : null;
+ useEffect(() => {
+ if (!pollTaskId) return;
+ let cancelled = false;
+ const refresh = async () => {
+ if (document.hidden) return;
+ try {
+ const runs = await getAutomationRuns(pollTaskId, 50);
+ // 请求期间可能已退出或切到别的任务,旧响应不能写回
+ if (!cancelled && useAutomationChatStore.getState().activeGroup?.taskId === pollTaskId) setRuns(runs);
+ } catch {
+ /* 下一轮再试 */
+ }
+ };
+ const timer = window.setInterval(() => void refresh(), RUN_POLL_INTERVAL_MS);
+ return () => {
+ cancelled = true;
+ window.clearInterval(timer);
+ };
+ }, [pollTaskId, setRuns]);
+
// Exit snapshot: when App.tsx's AnimatePresence plays the exit animation, store.activeGroup is already null,
// so during render we derive state that caches the last non-empty group to render the final frame as a fallback
// (the React-endorsed derived-state pattern), avoiding a blank panel during exit.
@@ -59,14 +85,8 @@ export function RunTimelinePanel() {
const numberedRuns = useMemo<(AutomationRun & { runNo: number })[]>(() => {
if (!group) return [];
-
- // Number runs in ascending order (oldest = #1)
- const allRuns = [...group.runs];
- const ascending = [...allRuns].reverse();
- return allRuns.map((run) => ({
- ...run,
- runNo: ascending.findIndex((r) => r.run_id === run.run_id) + 1,
- }));
+ // Runs are desc by started_at; number them ascending (oldest = #1)
+ return group.runs.map((run, idx) => ({ ...run, runNo: group.runs.length - idx }));
}, [group]);
// run_id → flat index (used for enter stagger, following render order consistent with groupedRuns)
@@ -86,8 +106,6 @@ export function RunTimelinePanel() {
);
const groupedRuns = useMemo(() => {
- if (!group) return [];
-
// Group by date (runs are already desc by started_at)
const map = new Map();
for (const run of numberedRuns) {
@@ -102,7 +120,7 @@ export function RunTimelinePanel() {
map.get(key)!.runs.push(run);
}
return Array.from(map.values());
- }, [group, numberedRuns]);
+ }, [numberedRuns]);
// Accumulate all status counts + the most recent success record in a single pass, avoiding the O(kn) cost of multiple filter/find calls.
const stats = useMemo(() => {
@@ -112,7 +130,7 @@ export function RunTimelinePanel() {
let latestSuccessRun: AutomationRun | undefined;
let latestFinishedRun: AutomationRun | undefined;
for (const run of numberedRuns) {
- if (run.status === 'running') running += 1;
+ if (isRunActive(run)) running += 1;
else if (run.status === 'success') {
success += 1;
if (!latestSuccessRun) latestSuccessRun = run;
@@ -219,7 +237,7 @@ export function RunTimelinePanel() {
{latestIndicatorStatus === 'idle' && }
- {latestIndicatorRun ? latestLabel : t('暂无执行')}:
+ {latestLabel}:
{latestIndicatorRun ? formatFullDateTime(latestIndicatorRun.started_at) : '—'}
@@ -274,14 +292,18 @@ export function RunTimelinePanel() {
{group.runs.map((run) => {
const isActive = run.run_id === selectedRunId;
- const isRunning = run.status === 'running';
- const isClickable = !isRunning && !!run.chat_id;
+ const isRunning = isRunActive(run);
+ // 执行中的 run 一旦关联了对话就能进入看实时进度;还没关联的(排队/刚触发)才不可点
+ const isClickable = !!run.chat_id;
const summary = summarizeRun(run);
+ const tooltip = !isRunning
+ ? RUN_STATUS_LABEL[run.status]
+ : (isClickable ? t('执行中,点击查看实时进度') : t('准备中,暂不可查看'));
return (
- {isClickable ? t('进入对话') : t('生成中')}
+ {!isClickable ? t('生成中') : (isRunning ? t('查看进度') : t('进入对话'))}
diff --git a/src/frontend/src/components/automation/automationUtils.ts b/src/frontend/src/components/automation/automationUtils.ts
index d21de847..75a9a4af 100644
--- a/src/frontend/src/components/automation/automationUtils.ts
+++ b/src/frontend/src/components/automation/automationUtils.ts
@@ -1,6 +1,6 @@
import type { ChannelConversation } from '../../api';
import { t } from '../../i18n';
-import type { AutomationRunStatus } from '../../types';
+import type { AutomationRun,AutomationRunStatus } from '../../types';
/** A distinguishable label for a channel conversation: bot name · group/direct chat · the real Feishu conversation ID.
* Don't use the first message content (title, e.g. "hello") -- it can collide and can't tell conversations apart. */
@@ -12,15 +12,22 @@ export function channelConversationLabel(c: ChannelConversation): string {
/** Labels for a single automation run's status. Kept here so they aren't redeclared across multiple components. */
export const RUN_STATUS_LABEL: Record = {
+ pending: t('待执行'),
running: t('执行中'),
success: t('成功'),
failed: t('失败'),
};
+/** Queued or executing — the run has no final result yet. */
+export function isRunActive(run: Pick): boolean {
+ return run.status === 'running' || run.status === 'pending';
+}
+
/** Status modifier classes for the run dot (`.jx-automation-runDot`), shared by the task
* detail page and the aggregated run list. The running state additionally carries the
* `.jx-anim-ripple` motion primitive; colour/spread live in automation.css. */
export const RUN_STATUS_CLASS: Record = {
+ pending: 'is-running',
running: 'is-running jx-anim-ripple',
success: 'is-success',
failed: 'is-failed',
diff --git a/src/frontend/src/components/chat/AgentCursor.tsx b/src/frontend/src/components/chat/AgentCursor.tsx
new file mode 100644
index 00000000..37e78a1a
--- /dev/null
+++ b/src/frontend/src/components/chat/AgentCursor.tsx
@@ -0,0 +1,72 @@
+import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
+import { AgentMark, type AgentMarkState } from '../common/AgentMark';
+
+interface AgentCursorProps {
+ /** `data-agent-slot` of the row the agent is working on right now. */
+ slotKey: string;
+ state: AgentMarkState;
+ /** Play the brand intro on first appearance — only when nothing else has shown it yet. */
+ intro?: boolean;
+}
+
+/**
+ * The one brand mark a streaming reply carries.
+ *
+ * Rows that can host the agent (the startup row, a tool-less run of thinking or
+ * compaction, the tail row under the text) leave an empty `[data-agent-slot]`;
+ * the mark glides to whichever slot owns the current step, so a reply reads as
+ * one agent moving from task to task rather than a spinner per row. A run with
+ * tools has no slot — its lit tool icons are the signal — so while one owns the
+ * reply the mark is not rendered, and it lands back in place (no intro) on the
+ * next slot. Rendered as an out-of-flow sibling of those rows, so it never takes
+ * part in the message's layout.
+ */
+export function AgentCursor({ slotKey, state, intro = false }: AgentCursorProps) {
+ const anchorRef = useRef(null);
+ const markRef = useRef(null);
+ const placedRef = useRef(false);
+
+ const place = useCallback(() => {
+ const anchor = anchorRef.current;
+ const mark = markRef.current;
+ const scope = anchor?.parentElement;
+ if (!anchor || !mark || !scope) return;
+ const slot = scope.querySelector(`[data-agent-slot="${CSS.escape(slotKey)}"]`);
+ if (!slot) {
+ mark.classList.add('jx-agentCursor-mark--hidden');
+ placedRef.current = false;
+ return;
+ }
+ const from = anchor.getBoundingClientRect();
+ const to = slot.getBoundingClientRect();
+ // The first placement lands in place; every move after that glides.
+ mark.classList.toggle('jx-agentCursor-mark--glide', placedRef.current);
+ mark.style.transform = `translate(${to.left - from.left}px, ${to.top - from.top}px)`;
+ mark.classList.remove('jx-agentCursor-mark--hidden');
+ placedRef.current = true;
+ }, [slotKey]);
+
+ // Measure when the target changes; any later movement of the slot comes with
+ // the reply changing size, which the observer below catches.
+ useLayoutEffect(() => {
+ place();
+ }, [place]);
+
+ // Text streaming in, images loading, the window resizing, a sibling row
+ // expanding — all of them resize the reply.
+ useEffect(() => {
+ const scope = anchorRef.current?.parentElement;
+ if (!scope || typeof ResizeObserver === 'undefined') return;
+ const observer = new ResizeObserver(() => place());
+ observer.observe(scope);
+ return () => observer.disconnect();
+ }, [place]);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/frontend/src/components/chat/MessageBody.tsx b/src/frontend/src/components/chat/MessageBody.tsx
index ca6ffeae..b869a35d 100644
--- a/src/frontend/src/components/chat/MessageBody.tsx
+++ b/src/frontend/src/components/chat/MessageBody.tsx
@@ -9,6 +9,8 @@ import { anyToolRunning } from '../tool/renderers/utils';
import { ThinkingInline } from './ThinkingInline';
import { StreamWaitIndicator } from './StreamWaitIndicator';
import { TurnStatusIndicator } from './TurnStatusIndicator';
+import { AgentCursor } from './AgentCursor';
+import type { AgentMarkState } from '../common/AgentMark';
import { InvocationBadges } from './InvocationBadges';
import { PlanCard } from './PlanCard';
import { MessageInheritedPlan } from './MessageInheritedPlan';
@@ -17,7 +19,8 @@ import { CitationMarkdownBlock } from '../citation';
import { useChatStore, useUIStore } from '../../stores';
import { updatePlanApi } from '../../api';
import { markPlanDecision } from '../../hooks/usePlanMode';
-import { useStallDetector } from '../../hooks/useStallDetector';
+import { useActivityAge } from '../../hooks/useActivityAge';
+import { ACTIVITY_THRESHOLDS, TAIL_WAIT_MS, WAIT_CLOCK_MS, agentStateForPhase, runPhase, waitLabel } from '../../utils/turnActivity';
import { useMessageCitation } from './useMessageCitation';
import type { ChatMessage } from '../../types';
import { t } from '../../i18n';
@@ -77,8 +80,16 @@ export function MessageBody({ m, messageIndex, currentChatId, send, showProcess
0,
);
const stallSignature = `${(m.content || '').length}|${m.toolCalls?.length ?? 0}|${streamedArgsLength}|${streamedThinkingLength}|${m.segments?.length ?? 0}`;
- // Preserve the startup clock across remounts.
- const stall = useStallDetector(stallSignature, 2500, m.lastActivityTs, !!m.isStreaming);
+ // The startup clock anchors to the persisted activity stamp, so it keeps
+ // counting from the real start across remounts.
+ const turnStartTs = m.lastActivityTs ?? m.ts;
+ // A tool finishing is activity too — it is what starts the "analysing results" wait.
+ const settledTools = (m.toolCalls || []).filter((tool) => tool.status && tool.status !== 'running').length;
+ const activity = useActivityAge(
+ `${stallSignature}|${settledTools}`,
+ !!m.isStreaming && dispatchProcessVisible,
+ ACTIVITY_THRESHOLDS,
+ );
/** Render a settled thinking block. Live thinking is rendered by ThinkingInline. */
const renderThinkingBlock = (content: string, thinkKey: string) => {
@@ -130,9 +141,9 @@ export function MessageBody({ m, messageIndex, currentChatId, send, showProcess
/* Segment-based rendering */
<>
{(() => {
- // Group thinking + tool calls + tool-prepare waits into a single
- // "agent run" shell so the chat flow shows one card per phase
- // instead of separate thinking / tool-call / preparing entries.
+ // Group thinking + tool calls into a single "agent run" shell so
+ // the chat flow shows one card per phase instead of separate
+ // thinking / tool-call entries.
//
// A run is a maximal contiguous sequence of {thinking, tool,
// empty-text} segments. Non-empty text breaks the run. Pure
@@ -143,28 +154,47 @@ export function MessageBody({ m, messageIndex, currentChatId, send, showProcess
const segs = m.segments!;
const { runs, suppressedIdx } = groupExecutionRuns(m, dispatchProcessVisible);
- // Only the initial empty response needs a waiting label.
- const hasVisibleTextProgress = segs.some(s => s.type === 'text' && (s.content || '').trim().length > 0);
- // A thinking segment that is not folded into a run renders as its
- // own ThinkingInline below, so the reasoning is already on screen.
- // Counting only `text` here made the turn indicator sit under a
- // visibly streaming reasoning block for the whole pre-tool phase —
- // several seconds on any turn that calls a tool, which is exactly
- // where "深度拥抱中…" was never meant to appear. It stays for the
- // genuine dead window: after the bubble exists and before the
- // model has emitted anything at all.
- const hasVisibleThinkingProgress = segs.some(
- s => s.type === 'thinking' && (s.content || '').trim().length > 0,
+ // ON mode gives every moment of a live reply one owner: the tool run
+ // still in flight, or a tail row under the text. The reply's single
+ // agent mark parks on that owner, so a pause never reads as a hang —
+ // except a run with tools, whose lit tool icons already say what is
+ // happening; the mark steps away and comes back on the next owner.
+ const liveTurn = !!m.isStreaming && dispatchProcessVisible;
+ const lastRun = runs[runs.length - 1];
+ const tailRun = liveTurn && lastRun
+ && !segs.slice(lastRun.endIdx + 1).some(s => !(s.type === 'text' && !(s.content || '').trim()))
+ ? lastRun : null;
+ // A reasoning block outside a run is already on screen (OFF mode only).
+ const hasVisibleProgress = runs.length > 0 || segs.some(
+ s => (s.type === 'text' || s.type === 'thinking') && (s.content || '').trim().length > 0,
);
- const startupPending =
- !!m.isStreaming &&
- dispatchProcessVisible &&
- runs.length === 0 &&
- !hasVisibleTextProgress &&
- !hasVisibleThinkingProgress;
- const virtualPending = startupPending
- ? { startTs: stall.since, key: `${m.uid}-pending-virtual` }
- : null;
+ const tailSlot = `${m.uid}-tail`;
+ let tail: { label: string; startTs: number; clockAfterMs?: number } | null = null;
+ let agent: { slot: string; state: AgentMarkState } | null = null;
+ if (liveTurn && tailRun) {
+ if (tailRun.tools.length === 0) {
+ const phase = runPhase(tailRun.steps, { isStreaming: true, isTail: true, idleMs: activity.idleMs });
+ agent = { slot: `${m.uid}-seg-${tailRun.anchor}`, state: agentStateForPhase(phase) };
+ }
+ } else if (liveTurn && !hasVisibleProgress) {
+ // The genuine dead window: the bubble exists and nothing has streamed yet.
+ tail = { label: turnStatusLabel ?? t('正在处理…'), startTs: turnStartTs };
+ agent = { slot: tailSlot, state: 'work' };
+ } else if (liveTurn && activity.idleMs >= TAIL_WAIT_MS && activity.since !== null) {
+ // While words arrive the text speaks for itself — nothing under it.
+ // Once it pauses, say that the next step is being worked out.
+ tail = {
+ label: waitLabel(activity.idleMs, t('正在思考下一步…')),
+ startTs: activity.since,
+ clockAfterMs: WAIT_CLOCK_MS,
+ };
+ agent = { slot: tailSlot, state: 'think' };
+ }
+ // The first run takes over from the startup row only if nothing showed before it.
+ const firstRun = runs[0];
+ const takeOver = liveTurn && firstRun
+ && !segs.slice(0, firstRun.anchor).some(s => (s.content || '').trim())
+ ? firstRun : null;
const runByAnchor = new Map();
runs.forEach((r) => runByAnchor.set(r.anchor, r));
@@ -181,6 +211,10 @@ export function MessageBody({ m, messageIndex, currentChatId, send, showProcess
key={segKey}
steps={run.steps}
isStreaming={m.isStreaming}
+ isTail={liveTurn ? run === tailRun : undefined}
+ idleMs={run === tailRun ? activity.idleMs : undefined}
+ agentSlotKey={agent?.slot === segKey ? segKey : undefined}
+ takeOverFrom={run === takeOver ? (turnStatusLabel ?? t('正在处理…')) : undefined}
/>
);
}
@@ -291,7 +325,6 @@ export function MessageBody({ m, messageIndex, currentChatId, send, showProcess
{showInlineWait && (
@@ -302,16 +335,22 @@ export function MessageBody({ m, messageIndex, currentChatId, send, showProcess
return null;
});
- if (virtualPending) {
- // Hide the startup label once this response has visible progress.
+ if (tail) {
rendered.push(
,
);
}
+ if (agent) {
+ // The startup row (rendered before any segment exists) already played the
+ // brand intro; the travelling mark only plays it if it is the first mark shown.
+ rendered.push( );
+ }
return rendered;
})()}
@@ -340,7 +379,7 @@ export function MessageBody({ m, messageIndex, currentChatId, send, showProcess
)}
{m.isStreaming && !m.content ? (
dispatchProcessVisible && !m.toolCalls?.length && !m.thinking?.some(block => block.content?.trim()) ? (
-
+
) : dispatchProcessVisible ? null : (
)
diff --git a/src/frontend/src/components/chat/MessageBubble.tsx b/src/frontend/src/components/chat/MessageBubble.tsx
index b1a50053..19a87b7d 100644
--- a/src/frontend/src/components/chat/MessageBubble.tsx
+++ b/src/frontend/src/components/chat/MessageBubble.tsx
@@ -56,7 +56,6 @@ export const MessageBubble = memo(function MessageBubble({ m, messageIndex, curr
diff --git a/src/frontend/src/components/chat/OntologyRevisionPanel.tsx b/src/frontend/src/components/chat/OntologyRevisionPanel.tsx
index 1d8573bf..55382800 100644
--- a/src/frontend/src/components/chat/OntologyRevisionPanel.tsx
+++ b/src/frontend/src/components/chat/OntologyRevisionPanel.tsx
@@ -162,13 +162,6 @@ export function OntologyRevisionPanel({
revisionToolCalls.forEach((tool, index) => {
revisionRunSteps.push({ kind: 'tool', tool, key: `${chatMessage.ts}-ontology-tool-${tool.id || index}` });
});
- if (revision?.toolPending) {
- revisionRunSteps.push({
- kind: 'pending',
- startTs: chatMessage.lastActivityTs || chatMessage.ts,
- key: `${chatMessage.ts}-ontology-pending`,
- });
- }
const handleAccept = useCallback(async () => {
if (!candidate.trim() || accepting || accepted) return;
diff --git a/src/frontend/src/components/chat/StreamWaitIndicator.tsx b/src/frontend/src/components/chat/StreamWaitIndicator.tsx
index 71d3e19b..99ec8cc1 100644
--- a/src/frontend/src/components/chat/StreamWaitIndicator.tsx
+++ b/src/frontend/src/components/chat/StreamWaitIndicator.tsx
@@ -4,7 +4,6 @@ import { EASE } from '../../utils/motionTokens';
interface StreamWaitIndicatorProps {
signature: string | number;
- forceWait?: boolean;
suppressed?: boolean;
stallMs?: number;
anchorTs?: number;
@@ -20,7 +19,6 @@ const STATE_MOTION = {
/** Show streaming dots during output; silent waits add no text or timer. */
export function StreamWaitIndicator({
signature,
- forceWait,
suppressed,
stallMs = 2500,
anchorTs,
@@ -30,7 +28,7 @@ export function StreamWaitIndicator({
return (
- {!forceWait && !stall.waiting ? (
+ {!stall.waiting ? (
diff --git a/src/frontend/src/components/chat/TurnStatusIndicator.tsx b/src/frontend/src/components/chat/TurnStatusIndicator.tsx
index 66514c37..1fc99b38 100644
--- a/src/frontend/src/components/chat/TurnStatusIndicator.tsx
+++ b/src/frontend/src/components/chat/TurnStatusIndicator.tsx
@@ -1,46 +1,58 @@
import { useEffect, useState } from 'react';
-import { BrandLoader, ElapsedTimer } from '../common';
+import { ElapsedTimer } from '../common';
+import { AgentMark } from '../common/AgentMark';
+import { RollingText } from '../common/RollingText';
import { t } from '../../i18n';
interface TurnStatusIndicatorProps {
/** Wall-clock ms the current wait began — anchors the elapsed clock. */
startTs: number;
/**
- * Overrides the label when we actually know what the wait is for. Today that
- * is the vision bridge reading an uploaded image before the model starts —
- * several seconds of pure network wait that would otherwise be indistinguishable
- * from the model thinking.
+ * What the wait is for. Defaults to the generic startup line. The vision
+ * bridge reading an uploaded image passes its own label — several seconds of
+ * network wait that would otherwise be indistinguishable from the model
+ * thinking.
*/
label?: string;
+ /** The clock only appears once the wait has clearly gone on a while. */
+ clockAfterMs?: number;
+ /**
+ * The reply's travelling agent mark is parked on this row: the slot carries
+ * this id and stays empty. Without it the row draws its own mark.
+ */
+ agentSlotKey?: string;
}
/** Short waits keep the bare label; the clock only appears once the turn has clearly been running a while. */
const CLOCK_AFTER_MS = 15_000;
/**
- * Turn-level "model at work" signal shown before anything visible has
- * streamed in (no text, no tool run yet). A single line of brand-gradient
- * shimmer text instead of a full ToolRunShell card — at this point we don't
- * yet know whether the model will call a tool at all, so "执行中 / 正在准备
- * 调用工具" both over-promises and reads heavy. Once a real tool run starts,
- * the shell (and its in-shell pending row) takes over.
+ * The tail row of a streaming reply: the agent's place whenever nothing else
+ * owns the current moment — before anything has streamed, and in the pause
+ * after text before the next step shows up (not while text streams: the words
+ * are the progress signal then). A single line with the brand mark, so a wait
+ * never reads as a frozen screen.
+ * Once a real tool run starts, the run's own row takes over.
*/
-export function TurnStatusIndicator({ startTs, label }: TurnStatusIndicatorProps) {
- const [now, setNow] = useState(() => Date.now());
-
+export function TurnStatusIndicator({
+ startTs,
+ label,
+ clockAfterMs = CLOCK_AFTER_MS,
+ agentSlotKey,
+}: TurnStatusIndicatorProps) {
+ // One timeout reveals the clock; ElapsedTimer does the ticking from there.
+ const [showClock, setShowClock] = useState(false);
useEffect(() => {
- const id = window.setInterval(() => setNow(Date.now()), 1000);
- return () => window.clearInterval(id);
- }, []);
+ const id = window.setTimeout(() => setShowClock(true), Math.max(0, clockAfterMs - (Date.now() - startTs)));
+ return () => window.clearTimeout(id);
+ }, [startTs, clockAfterMs]);
- const showClock = now - startTs >= CLOCK_AFTER_MS;
- const text = label || t('正在处理…');
return (
-
-
+
+ {agentSlotKey ? null : }
- {text}
+
{showClock && }
);
diff --git a/src/frontend/src/components/common/AgentMark.tsx b/src/frontend/src/components/common/AgentMark.tsx
new file mode 100644
index 00000000..35a6b853
--- /dev/null
+++ b/src/frontend/src/components/common/AgentMark.tsx
@@ -0,0 +1,48 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * What the agent is doing, told by the brand glyph itself:
+ * - `work` — a light runs along the outline (a tool or the model is busy)
+ * - `think` — the light slows down and the spark breathes (waiting on the model)
+ * - `rest` — the settled grey glyph left on finished rows
+ */
+export type AgentMarkState = 'work' | 'think' | 'rest';
+
+// assets/design-v2/logo/hugagentos-glyph-blue.svg
+const GLYPH = 'M14.5888 7.77765C16.2401 7.14973 18.1314 7.84717 18.8512 9.33629L18.8813 9.40059L23.3166 19.1746L27.4553 17.3081C28.7126 16.7409 30.2298 17.1971 30.8786 18.3257L30.9103 18.3827C31.204 18.9283 31.256 19.5557 31.0576 20.1341L31.0315 20.2062L28.3231 27.2981C27.8528 28.5294 26.381 29.1787 25.0358 28.7482C24.3677 28.5344 23.826 28.0792 23.5357 27.4906L23.5045 27.4247L21.6269 23.288L8.74766 29.098C7.98987 29.4398 7.11299 29.4778 6.32588 29.2067L6.2419 29.1765L6.20584 29.1625L6.10859 29.1229C4.60563 28.4799 3.94394 26.8582 4.60616 25.4749L4.63717 25.4122L12.8735 9.28094C13.208 8.62579 13.7888 8.10382 14.5083 7.80942L14.5888 7.77765ZM16.1618 10.3778L8.30761 26.1502L20.6214 20.4547L16.1618 10.3778ZM27.6579 20.4883L25.0192 21.7538L26.2052 24.5325L27.6579 20.4883Z';
+// The outer contour only: the running light travels round the silhouette, not the holes.
+const OUTLINE = GLYPH.slice(0, GLYPH.indexOf('Z') + 1);
+
+/** Brand intro: outline 0–600ms → fill 500–700ms → spark from 650ms (design spec §10). */
+const INTRO_MS = 1000;
+
+interface AgentMarkProps {
+ state: AgentMarkState;
+ /** Play the one-time brand intro before settling into `state`. */
+ intro?: boolean;
+ className?: string;
+}
+
+export function AgentMark({ state, intro = false, className }: AgentMarkProps) {
+ const [introPlaying, setIntroPlaying] = useState(intro && state !== 'rest');
+
+ useEffect(() => {
+ if (!introPlaying) return;
+ const id = window.setTimeout(() => setIntroPlaying(false), INTRO_MS);
+ return () => window.clearTimeout(id);
+ }, [introPlaying]);
+
+ const phase = introPlaying ? 'intro' : state;
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/frontend/src/components/common/ElapsedTimer.tsx b/src/frontend/src/components/common/ElapsedTimer.tsx
index 62a642ff..9d1353f8 100644
--- a/src/frontend/src/components/common/ElapsedTimer.tsx
+++ b/src/frontend/src/components/common/ElapsedTimer.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { formatClock } from '../../utils/elapsed';
interface ElapsedTimerProps {
/** Start time in epoch milliseconds. */
@@ -7,11 +8,6 @@ interface ElapsedTimerProps {
className?: string;
}
-function fmt(totalSec: number): string {
- const m = Math.floor(totalSec / 60);
- const s = totalSec % 60;
- return `${m}:${String(s).padStart(2, '0')}`;
-}
/**
* Live "elapsed since startTs" counter, formatted M:SS, ticking every second.
@@ -27,5 +23,5 @@ export function ElapsedTimer({ startTs, className }: ElapsedTimerProps) {
}, []);
const sec = Math.max(0, Math.floor((now - startTs) / 1000));
- return {fmt(sec)} ;
+ return {formatClock(sec)} ;
}
diff --git a/src/frontend/src/components/common/RollingText.tsx b/src/frontend/src/components/common/RollingText.tsx
new file mode 100644
index 00000000..dd1cf7fb
--- /dev/null
+++ b/src/frontend/src/components/common/RollingText.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import type { Ref } from 'react';
+import { DUR } from '../../utils/motionTokens';
+
+/** Matches `jxTurnStatusShine` in styles/chat/turn-status.css. */
+const SHIMMER_MS = 2800;
+/** The outgoing label is dropped once its exit (--motion-duration-fast) has played. */
+const EXIT_MS = DUR.fast * 1000;
+
+interface RollingTextProps {
+ text: string;
+ /** Scan the label while the step it names is running. */
+ shimmer?: boolean;
+ /** Roll between labels; history renders pass false so nothing replays. */
+ animate?: boolean;
+ /** Let the width follow the label so whatever sits after it slides instead of jumping. */
+ smoothWidth?: boolean;
+ /** Label this one takes over from (e.g. the startup line it replaces): rolls in from it on mount. */
+ rollFrom?: string;
+ className?: string;
+}
+
+/**
+ * A one-line label that rolls up when its text changes: the old label leaves
+ * upwards while the new one rises in, so switching from one tool to the next
+ * reads as a hand-over rather than a cut.
+ */
+export function RollingText({ text, shimmer = false, animate = true, smoothWidth = false, rollFrom, className }: RollingTextProps) {
+ const [shown, setShown] = useState(() => (animate && rollFrom ? rollFrom : text));
+ const [generation, setGeneration] = useState(0);
+ const [leaving, setLeaving] = useState<{ text: string; generation: number } | null>(null);
+
+ if (text !== shown) {
+ setLeaving(animate ? { text: shown, generation } : null);
+ setShown(text);
+ setGeneration(generation + 1);
+ }
+
+ useEffect(() => {
+ if (!leaving) return;
+ const id = window.setTimeout(() => setLeaving(null), EXIT_MS);
+ return () => window.clearTimeout(id);
+ }, [leaving]);
+
+ const wrapRef = useRef(null);
+ const currentRef = useRef(null);
+ const lastWidth = useRef(null);
+ useLayoutEffect(() => {
+ const wrap = wrapRef.current;
+ const current = currentRef.current;
+ if (!wrap || !current) return;
+ const to = current.getBoundingClientRect().width;
+ const from = lastWidth.current;
+ lastWidth.current = to;
+ if (!smoothWidth || !animate || from === null || from === to) {
+ wrap.style.width = '';
+ return;
+ }
+ wrap.classList.remove('jx-rollText--sizing');
+ wrap.style.width = `${from}px`;
+ void wrap.offsetWidth;
+ wrap.classList.add('jx-rollText--sizing');
+ wrap.style.width = `${to}px`;
+ }, [shown, smoothWidth, animate]);
+
+ return (
+
+ {leaving && (
+
+ {leaving.text}
+
+ )}
+ 0}
+ />
+
+ );
+}
+
+function RollItem({ text, shimmer, entering, ref }: {
+ text: string;
+ shimmer: boolean;
+ entering: boolean;
+ ref: Ref;
+}) {
+ // Phase-lock every scan to one wall clock, so a label that mounts mid-scan
+ // continues the sweep instead of restarting it on every hand-over.
+ const [delay] = useState(() => `-${Math.round(performance.now() % SHIMMER_MS)}ms`);
+ // The roll moves the outer span and the scan paints the inner one: one
+ // element per animation, so neither overrides the other's `animation`.
+ return (
+
+
+ {text}
+
+
+ );
+}
diff --git a/src/frontend/src/components/personal-agent/shared/PersonalAgentNavStatus.tsx b/src/frontend/src/components/personal-agent/shared/PersonalAgentNavStatus.tsx
index 4dcda83b..a0d296fc 100644
--- a/src/frontend/src/components/personal-agent/shared/PersonalAgentNavStatus.tsx
+++ b/src/frontend/src/components/personal-agent/shared/PersonalAgentNavStatus.tsx
@@ -1,12 +1,13 @@
import { useAuthStore } from '../../../stores/authStore';
import { t } from '../../../i18n';
-import { TaskStatusDot } from '../../sidebar/TaskStatusDot';
+import '../../../styles/task-status.css';
import { usePersonalTaskIndicator } from './notifications';
export function PersonalAgentNavStatus({compact=false}:{compact?:boolean}){
const state=usePersonalTaskIndicator();
const available=useAuthStore(s=>s.authUser?.can_use_personal_agent===true);
- if (!available) return null;
- return
- {state.unread>0&&{state.unread>99?'99+':state.unread} }
+ if (!available||!state.lit) return null;
+ return
+
+ {state.unread>99?'99+':state.unread}
;
}
diff --git a/src/frontend/src/components/personal-agent/shared/notifications.ts b/src/frontend/src/components/personal-agent/shared/notifications.ts
index cacb8223..f787a887 100644
--- a/src/frontend/src/components/personal-agent/shared/notifications.ts
+++ b/src/frontend/src/components/personal-agent/shared/notifications.ts
@@ -2,7 +2,7 @@ import { needsResultCheck } from '../tasks/taskState';
import { t } from '../../../i18n';
import { useEffect } from 'react';
import { useAuthStore } from '../../../stores/authStore';
-import { useTaskNotificationStore, normalizeTaskStatus, taskIndicator } from '../../../stores/taskNotificationStore';
+import { useTaskNotificationStore, normalizeTaskStatus } from '../../../stores/taskNotificationStore';
import { personalRequest, type PersonalTask } from '../api';
interface PersonalSidebarStatus {tasks:Array;replying:boolean;latest_seq:number;unread_count:number}
@@ -39,19 +39,14 @@ export function usePersonalAgentNotifications() {
},[available,scope]);
}
export function usePersonalTaskIndicator(){
- const notices=useTaskNotificationStore(s=>s.notices);
+ // The permanent agent entry lights up only for unread messages; task progress stays in the panel.
const unread=useTaskNotificationStore(s=>s.personalUnread);
- const replying=useTaskNotificationStore(s=>s.personalReplying);
- const indicator=taskIndicator(Object.values(notices).filter(n=>n.key.startsWith('personal:')),replying);
- if((indicator.status==='idle'||(indicator.status==='completed'&&indicator.seen))&&unread>0)return {status:'completed' as const,seen:false,unread};
- // A completed and already-read task should not leave the permanent agent entry lit.
- return {...indicator,status:indicator.status==='completed'&&indicator.seen?'idle' as const:indicator.status,unread};
+ return {lit:unread>0,unread};
}
export function usePersonalAgentStatusText(){
const available=useAuthStore(s=>s.authUser?.can_use_personal_agent===true);
const state=usePersonalTaskIndicator();
- if(!available)return '';
- const labels={running:t('运行中'),waiting:t('等待你的输入或确认'),failed:t('任务失败'),completed:t('任务已完成,结果未读'),idle:''};
- return [labels[state.status],state.unread>0?t('{count} 条未读消息',{count:state.unread}):''].filter(Boolean).join(' · ');
+ if(!available||!state.lit)return '';
+ return t('{count} 条未读消息',{count:state.unread});
}
diff --git a/src/frontend/src/components/tool/CompactionStepRow.tsx b/src/frontend/src/components/tool/CompactionStepRow.tsx
index cff7c739..574f59ea 100644
--- a/src/frontend/src/components/tool/CompactionStepRow.tsx
+++ b/src/frontend/src/components/tool/CompactionStepRow.tsx
@@ -1,17 +1,19 @@
-import { CheckOutlined, CloseOutlined, LoadingOutlined, MinusOutlined } from '@ant-design/icons';
+import { CloseOutlined, MinusOutlined } from '@ant-design/icons';
+import { ToolIcon } from './toolIcons';
import { ElapsedTimer } from '../common';
import { t } from '../../i18n';
import { compactionLabel, type CompactionProgress } from '../../utils/compactionProgress';
export function CompactionStepRow({ progress }: { progress: CompactionProgress }) {
const running = progress.status === 'running';
- const Icon = running ? LoadingOutlined : progress.status === 'completed' ? CheckOutlined
- : progress.status === 'skipped' ? MinusOutlined : CloseOutlined;
+ const settledIcon = progress.status === 'skipped' ?
+ : progress.status === 'failed' || progress.status === 'interrupted' ?
+ : null;
return (
-
+ {settledIcon ?? }
{t(compactionLabel(progress))}
diff --git a/src/frontend/src/components/tool/PendingStepRow.tsx b/src/frontend/src/components/tool/PendingStepRow.tsx
deleted file mode 100644
index 7358a6d7..00000000
--- a/src/frontend/src/components/tool/PendingStepRow.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { LoadingOutlined } from '@ant-design/icons';
-import { ElapsedTimer } from '../common';
-import { t } from '../../i18n';
-
-interface PendingStepRowProps {
- /** Timestamp the stall/pending state began — drives the inline timer. */
- startTs: number;
-}
-
-/**
- * Pending row shown inside the ToolRunShell when the model is buffering a
- * tool-call payload (backend `tool_pending` or frontend stall detection).
- *
- * The configured LLM emits the tool-call `arguments` JSON in one chunk after
- * a multi-second server-side buffer, so this row is the only signal we can
- * give the user during that gap. It replaces the old free-floating
- * "Preparing to call a tool" inline indicator, keeping the shell as the single home
- * for any agent-side work.
- */
-export function PendingStepRow({ startTs }: PendingStepRowProps) {
- return (
-
-
-
-
-
-
- {t('正在准备调用工具…')}
-
-
-
-
- );
-}
diff --git a/src/frontend/src/components/tool/ThinkingStepRow.tsx b/src/frontend/src/components/tool/ThinkingStepRow.tsx
index 41102c19..5520789b 100644
--- a/src/frontend/src/components/tool/ThinkingStepRow.tsx
+++ b/src/frontend/src/components/tool/ThinkingStepRow.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { BulbOutlined, LoadingOutlined } from '@ant-design/icons';
+import { ToolIcon } from './toolIcons';
import { t } from '../../i18n';
import { useCollapseHeight } from '../../hooks/useCollapseHeight';
@@ -40,10 +40,7 @@ export function ThinkingStepRow({ content, active }: ThinkingStepRowProps) {
}}
>
- {active
- ?
- :
- }
+
diff --git a/src/frontend/src/components/tool/ToolCallRow.tsx b/src/frontend/src/components/tool/ToolCallRow.tsx
index eefe4b7b..d619f963 100644
--- a/src/frontend/src/components/tool/ToolCallRow.tsx
+++ b/src/frontend/src/components/tool/ToolCallRow.tsx
@@ -4,21 +4,7 @@ import { useMemo, useState } from 'react';
import { SubagentCallRow } from './SubagentCallRow';
import { ForkedToolCallRow } from './ForkedToolCallRow';
import type { ComponentType } from 'react';
-import {
- LoadingOutlined,
- PauseCircleOutlined,
- SearchOutlined,
- GlobalOutlined,
- FileTextOutlined,
- EditOutlined,
- CodeOutlined,
- DatabaseOutlined,
- ThunderboltOutlined,
- RobotOutlined,
- FolderOutlined,
- FileWordOutlined,
- FileExcelOutlined,
-} from '@ant-design/icons';
+import { PauseCircleOutlined } from '@ant-design/icons';
import type { ToolCall } from '../../types';
import { useChatStore, useUIStore } from '../../stores';
import { ElapsedTimer } from '../common';
@@ -34,6 +20,9 @@ import { useToolMessageIdentity } from './ToolMessageContext';
import { t } from '../../i18n';
import { useCollapseHeight } from '../../hooks/useCollapseHeight';
import { EDITION_STEP_ICONS, getEditionToolRowLabel } from '../../toolEdition';
+import { ToolIcon } from './toolIcons';
+import { formatClock } from '../../utils/elapsed';
+import { toolIconKind } from '../../utils/toolActionLabel';
import {
resolvePluginToolLabel,
resolvePluginToolStepText,
@@ -170,41 +159,17 @@ function getRunningCodeLabel(tool: ToolCall): { prefix: string; value: string }
}
/**
- * Completed-step marker. Mirrors the reference design: instead of a generic
- * success tick, each row carries a quiet outline icon that signals *what kind*
- * of step it was (search / browse / file / code / …). Unknown tools fall back
- * to a small hollow dot.
+ * Step marker: the line icon for this kind of action (see `toolIcons.tsx`),
+ * grey once done and brand blue while running — the row's state reads from
+ * colour plus the label's scan, with no separate spinner.
*/
-const STEP_ICONS: Record> = {
- internet_search: SearchOutlined,
- web_fetch: GlobalOutlined,
- view_text_file: FileTextOutlined,
- Write: EditOutlined,
- Edit: EditOutlined,
- Bash: CodeOutlined,
- bash: CodeOutlined,
- retrieve_dataset_content: DatabaseOutlined,
- retrieve_local_kb: DatabaseOutlined,
- list_datasets: DatabaseOutlined,
- query_database: DatabaseOutlined,
- load_skill: ThunderboltOutlined,
- load_plugin: ThunderboltOutlined,
- get_skills: ThunderboltOutlined,
- get_mcp_tools: ThunderboltOutlined,
- call_subagent: RobotOutlined,
- get_agents: RobotOutlined,
- ...EDITION_STEP_ICONS,
- sandbox_put_artifact: FolderOutlined,
- sandbox_get_artifact: FolderOutlined,
- word_create_from_markdown: FileWordOutlined,
- export_report_to_docx: FileWordOutlined,
- export_table_to_excel: FileExcelOutlined,
-};
+const EDITION_ICONS: Record> = EDITION_STEP_ICONS;
-function StepIcon({ name }: { name: string }) {
- const Ico = STEP_ICONS[name];
- if (!Ico) return ;
- return ;
+function StepIcon({ name, running }: { name: string; running: boolean }) {
+ const className = `jx-tcr-kind${running ? ' jx-tcr-kind--running' : ''}`;
+ const Edition = EDITION_ICONS[name];
+ if (Edition) return ;
+ return ;
}
interface ToolCallRowProps {
@@ -352,13 +317,14 @@ function StandardToolCallRow({ tool, isStreaming }: ToolCallRowProps) {
key={isStreaming ? `st-${effectiveStatus}` : 'st'}
className={`jx-tcr-status${isStreaming ? ' jx-anim-statusIn' : ''}`}
>
- {effectiveStatus === 'running' && }
{/* 结果始终没到的调用(被中止,或结果带着一个对不上任何卡片的 id 回来)挂
暂停标而不是工具图标——否则它和一次正常完成的调用长得一模一样。 */}
- {effectiveStatus === 'interrupted' && }
- {effectiveStatus !== 'running' && effectiveStatus !== 'interrupted' && !capability && }
+ {effectiveStatus === 'interrupted'
+ ?
+ : capability
+ ?
+ : }
- {capability && }
{prefix}
{value && {value} }
@@ -371,6 +337,8 @@ function StandardToolCallRow({ tool, isStreaming }: ToolCallRowProps) {
) : null}
{running && tool.timestamp ? (
+ ) : !running && (tool.durationMs ?? 0) >= 1000 ? (
+ {formatClock(tool.durationMs! / 1000)}
) : null}
{canExpand && (
diff --git a/src/frontend/src/components/tool/ToolCapabilityIcon.tsx b/src/frontend/src/components/tool/ToolCapabilityIcon.tsx
index adc0e319..94af72b4 100644
--- a/src/frontend/src/components/tool/ToolCapabilityIcon.tsx
+++ b/src/frontend/src/components/tool/ToolCapabilityIcon.tsx
@@ -3,12 +3,12 @@ import { SkillAvatar } from '../catalog/skillIcons';
import { PluginAvatar } from '../catalog/PluginIconPicker';
import { McpIcon } from '../catalog/McpIcon';
-export function ToolCapabilityIcon({ capability }: { capability: ToolCapability }) {
+export function ToolCapabilityIcon({ capability, size = 22 }: { capability: ToolCapability; size?: number }) {
return
{capability.kind === 'skill'
- ?
+ ?
: capability.kind === 'plugin'
- ?
- : }
+ ?
+ : }
;
}
diff --git a/src/frontend/src/components/tool/ToolKindStack.tsx b/src/frontend/src/components/tool/ToolKindStack.tsx
new file mode 100644
index 00000000..6dbed7e5
--- /dev/null
+++ b/src/frontend/src/components/tool/ToolKindStack.tsx
@@ -0,0 +1,69 @@
+import { useMemo } from 'react';
+import type { ToolCall } from '../../types';
+import type { ToolCapability } from '../../utils/toolCapability';
+import { useToolCapabilityResolver } from '../../hooks/useToolCapability';
+import { ToolCapabilityIcon } from './ToolCapabilityIcon';
+import { ToolIcon } from './toolIcons';
+import type { ToolIconKind } from '../../utils/toolIconKind';
+import { toolIconKind } from '../../utils/toolActionLabel';
+import { t } from '../../i18n';
+
+/** More kinds than this collapse into a leading "+N" chip. */
+const MAX_VISIBLE = 3;
+
+interface ToolKindStackProps {
+ tools: ToolCall[];
+ /** Effective status per tool, aligned with `tools`. */
+ statuses: string[];
+ /** Live batch: new chips slide in; history renders stay still. */
+ live: boolean;
+}
+
+interface Kind {
+ key: string;
+ icon: ToolIconKind;
+ capability: ToolCapability | null;
+ running: boolean;
+}
+
+/**
+ * The kinds of action a batch used, one chip each, in first-use order.
+ *
+ * Several calls of the same kind share a chip, which lights up again when that
+ * kind runs again — a batch of six file reads is one file icon, not six. The
+ * most recent kinds stay visible, so the running one never hides behind "+N".
+ * Skill, plugin and connector tools show their own logo.
+ */
+export function ToolKindStack({ tools, statuses, live }: ToolKindStackProps) {
+ const resolveCapability = useToolCapabilityResolver();
+ const kinds = useMemo(() => {
+ const byKey = new Map();
+ tools.forEach((tool, index) => {
+ const capability = resolveCapability(tool);
+ const icon = toolIconKind(tool.name);
+ const key = capability ? `cap:${capability.kind}:${capability.id}` : `icon:${icon}`;
+ const running = statuses[index] === 'running';
+ const previous = byKey.get(key);
+ byKey.set(key, previous ? { ...previous, running: previous.running || running } : { key, icon, capability, running });
+ });
+ return [...byKey.values()];
+ }, [tools, statuses, resolveCapability]);
+
+ if (!kinds.length) return null;
+ const hidden = Math.max(0, kinds.length - MAX_VISIBLE);
+ return (
+
+ {hidden > 0 && +{hidden} }
+ {kinds.slice(hidden).map((kind) => (
+
+ {kind.capability
+ ?
+ : }
+
+ ))}
+
+ );
+}
diff --git a/src/frontend/src/components/tool/ToolRunShell.tsx b/src/frontend/src/components/tool/ToolRunShell.tsx
index 808991da..504012b5 100644
--- a/src/frontend/src/components/tool/ToolRunShell.tsx
+++ b/src/frontend/src/components/tool/ToolRunShell.tsx
@@ -1,32 +1,29 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { CloseOutlined } from '@ant-design/icons';
import type { ToolCall } from '../../types';
-import { BrandLoader } from '../common';
+import { AgentMark } from '../common/AgentMark';
+import { RollingText } from '../common/RollingText';
import { ToolCallRow } from './ToolCallRow';
import { ThinkingStepRow } from './ThinkingStepRow';
-import { PendingStepRow } from './PendingStepRow';
import { CompactionStepRow } from './CompactionStepRow';
+import { ToolKindStack } from './ToolKindStack';
import { compactionLabel, type CompactionProgress } from '../../utils/compactionProgress';
import { computeEffectiveStatus } from './renderers/utils';
import { t } from '../../i18n';
import { getDoneActionLabel, getRunningActionLabel } from '../../utils/toolActionLabel';
import { resolveToolDisplayName } from '../../utils/toolMeta';
+import { agentStateForPhase, runPhase, waitLabel } from '../../utils/turnActivity';
import { useChatStore } from '../../stores';
import { useCollapseHeight } from '../../hooks/useCollapseHeight';
-/** Aggregate status of a contiguous step batch. */
-type ShellStatus = 'running' | 'success';
-
-
/**
* A single entry in the shell timeline. Tool calls, thinking blocks, and
- * tool-call prepare waits all render as steps in stream order, so the user
+ * context compactions all render as steps in stream order, so the user
* sees one combined "agent run" card instead of several inline indicators.
*/
export type ShellStep =
| { kind: 'tool'; tool: ToolCall; key: string }
| { kind: 'thinking'; content: string; active: boolean; key: string }
- | { kind: 'pending'; startTs: number; key: string }
| { kind: 'compaction'; progress: CompactionProgress; key: string };
function formatDuration(totalSec: number): string {
@@ -39,12 +36,12 @@ function formatDuration(totalSec: number): string {
/**
* Total-elapsed counter for a step batch.
*
- * While running it ticks live from the batch start alongside streamed tool
- * arguments and results when the selected model/provider exposes them.
- * Once done it shows a stable span from the first call's start to the last
- * call's finish — both persisted by the backend — so a reloaded message, or
- * the same conversation opened on another device, renders the same value
- * instead of timing itself from the moment it was rendered.
+ * While running it ticks live from the batch start. A batch that settles in
+ * front of the user stops at the moment it settled — the run includes the
+ * wait for the model's answer, so recomputing it from the tool spans would
+ * make the clock jump back. Reloaded messages (which mount settled) show the
+ * persisted span from the first call's start to the last call's finish, so the
+ * same conversation renders the same value on any device.
*/
function ShellTimer({
startTs,
@@ -64,9 +61,9 @@ function ShellTimer({
return () => window.clearInterval(id);
}, [running]);
- const sec = running
- ? Math.max(0, Math.floor((now - startTs) / 1000))
- : Math.max(0, Math.floor((endTs - startTs) / 1000));
+ // Once the interval stops, `now` holds the last tick: that is when it settled.
+ const until = running || bornRunning ? now : endTs;
+ const sec = Math.max(0, Math.floor((until - startTs) / 1000));
if (!running && sec === 0) return null;
// One-shot settle scale the moment a live run completes; bornRunning keeps
@@ -86,47 +83,55 @@ interface ToolRunShellProps {
/** Mixed steps (tool calls + thinking) belonging to one contiguous batch. */
steps: ShellStep[];
isStreaming?: boolean;
+ /**
+ * This run is the last thing in a streaming reply: once its tools finish it
+ * stays open ("analysing results") until text or the end of the stream
+ * arrives, instead of declaring itself done while the model is still busy.
+ */
+ isTail?: boolean;
+ /** Ms since the reply last showed activity; paces hand-over and wait labels. */
+ idleMs?: number;
+ /**
+ * The reply's travelling agent mark is parked on this tool-less run: the slot
+ * carries this id and stays empty. Without it the run draws its own mark.
+ */
+ agentSlotKey?: string;
+ /** Line this run takes over from (the startup row it replaces); its title rolls in from it. */
+ takeOverFrom?: string;
}
/**
* Minimal collapsible shell wrapping a contiguous batch of steps.
*
- * 始终折叠成一行,点击才展开——运行中也不例外。那一行报的是实际动作
- * ("正在执行命令" → "运行了命令")加耗时、步骤数,信息量足够,不必靠展开
- * 让用户确认它还活着。状态只靠图标形状表达,不用颜色,保持流里的安静。
- *
- * Thinking segments adjacent to a tool batch are folded in as additional
- * steps so the user sees one unified "agent run" card instead of separate
- * "thinking process / tool call" entries in the message flow.
+ * 始终折叠成一行,点击才展开——运行中也不例外。那一行依次是:这一批用到的工具种类
+ * 图标、实际动作("正在执行命令" → "运行了命令")和耗时。切换工具时标题上下滚动、
+ * 图标原地点亮,不做硬切;运行状态只靠点亮的图标和标题扫光表达。只有没有工具的一批
+ * (纯思考、纯压缩)才在行首留智能体标记的槽位。
*/
-export function ToolRunShell({ steps, isStreaming }: ToolRunShellProps) {
+export function ToolRunShell({ steps, isStreaming, isTail, idleMs = 0, agentSlotKey, takeOverFrom }: ToolRunShellProps) {
const [mountTs] = useState(() => Date.now());
// 过程默认收起,跑起来也不自动展开——折叠那一行的文案已经说明了在做什么。
const [open, setOpen] = useState(false);
const toolDisplayNames = useChatStore((s) => s.toolDisplayNames);
const collapseRef = useCollapseHeight(open);
- const tools = steps.flatMap((s) => (s.kind === 'tool' ? [s.tool] : []));
- const toolStatuses = tools.map((t) => computeEffectiveStatus(t, isStreaming));
- const anyToolRunning = toolStatuses.includes('running');
- const anyThinkingActive = steps.some((s) => s.kind === 'thinking' && s.active);
- const anyPending = steps.some((s) => s.kind === 'pending');
+ const tools = useMemo(() => steps.flatMap((s) => (s.kind === 'tool' ? [s.tool] : [])), [steps]);
+ // 有工具的一行只留工具图标:图标本身就在说"在做什么",前面再挂一枚智能体标记是重复。
+ const hasTools = tools.length > 0;
+ const toolStatuses = useMemo(() => tools.map((tool) => computeEffectiveStatus(tool, isStreaming)), [tools, isStreaming]);
const compactions = steps.flatMap(s => s.kind === 'compaction' ? [s.progress] : []);
- const activeCompaction = compactions.find(p => p.status === 'running');
const lastCompaction = compactions[compactions.length - 1];
- const running = !!activeCompaction || anyToolRunning || anyThinkingActive || anyPending;
- const status: ShellStatus = running ? 'running' : 'success';
+ const phase = runPhase(steps, { isStreaming: !!isStreaming, isTail, idleMs, anyToolRunning: toolStatuses.includes('running') });
+ const running = phase !== 'done';
- const timed = tools.filter((t): t is ToolCall & { timestamp: number } => typeof t.timestamp === 'number');
- const starts = [...timed.map(t => t.timestamp), ...compactions.map(p => p.started_at)];
+ const timed = tools.filter((tool): tool is ToolCall & { timestamp: number } => typeof tool.timestamp === 'number');
+ const starts = [...timed.map(tool => tool.timestamp), ...compactions.map(p => p.started_at)];
const startTs = starts.length ? Math.min(...starts) : mountTs;
// 收尾时刻 = 最后一个跑完的调用的开始时刻 + 它的耗时。少了这个耗时,一批工具
// 的跨度就只数到"最后一个开始"为止,最后那次执行整段不算。
- const ends = [...timed.map(t => t.timestamp + (t.durationMs ?? 0)), ...compactions.map(p => p.ended_at || p.started_at)];
+ const ends = [...timed.map(tool => tool.timestamp + (tool.durationMs ?? 0)), ...compactions.map(p => p.ended_at || p.started_at)];
const endTs = ends.length ? Math.max(...ends) : mountTs;
- // 折叠态下这行是用户唯一看得到的信息,所以报的是实际动作而不是"执行中/已完成":
- // 跑的时候跟着当前那个工具走,收尾后按这一批用过的工具类型给结论。
// MCP 连接器的名字是会话期动态下发的,先解析好再交给文案表——没登记在表里的
// 工具就靠这个名字自报家门。
const labeled = tools.map((tc) => ({
@@ -134,51 +139,59 @@ export function ToolRunShell({ steps, isStreaming }: ToolRunShellProps) {
displayName: resolveToolDisplayName(tc, toolDisplayNames),
}));
const runningTool = labeled.find((_, i) => toolStatuses[i] === 'running');
- const title = activeCompaction ? t('正在压缩上下文')
- : !running && lastCompaction ? t(compactionLabel(lastCompaction))
- : running
- ? (runningTool
- ? getRunningActionLabel(runningTool)
- : anyThinkingActive ? t('正在思考') : t('执行中'))
+ const lastTool = labeled[labeled.length - 1];
+ const title = phase === 'compaction' ? t('正在压缩上下文')
+ : phase === 'tool' ? getRunningActionLabel(runningTool!)
+ : phase === 'thinking' ? t('正在思考')
+ // 两次调用之间那一下空档仍报上一个工具,否则会闪一下"已完成"。
+ : phase === 'handoff' ? (lastTool ? getRunningActionLabel(lastTool) : t('正在思考'))
+ : phase === 'gap' ? waitLabel(idleMs, t('正在分析结果…'))
+ : lastCompaction ? t(compactionLabel(lastCompaction))
: getDoneActionLabel(labeled);
- // 折叠态下这行是唯一的进度来源。批量作业(run_job)会把一轮卡在同一个工具里几十分钟,
- // 步骤数一个都不会涨——不把里面那张卡的实时进度顶到头上,用户看到的就是"执行中 · 5 个步骤"
- // 一直转圈,除了刷新页面没有别的办法判断它到底跑没跑完。
+ // 批量作业(run_job)会把一轮卡在同一个工具里几十分钟——不把里面那张卡的实时进度
+ // 顶到这一行,用户除了刷新页面没有别的办法判断它到底跑没跑完。
const runningNote = running
? tools.find((tc, i) => toolStatuses[i] === 'running' && tc.progressNote)?.progressNote || ''
: '';
+ const failedCompaction = !running && !!lastCompaction && ['failed', 'interrupted'].includes(lastCompaction.status);
+ // 只有尾部那一批里最后一步的思考算"正在思考",早先的思考块在展开里已经是过去时。
+ const lastStepKey = steps[steps.length - 1]?.key;
+
// History / non-streaming renders are static: the `--static` modifier kills
// the shell + step-row + title entrance animations (see tool.css), so
// switching chats or reloading never replays the whole run card.
return (
-
+
setOpen((value) => !value)}
>
-
- {!running && lastCompaction && ['failed', 'interrupted'].includes(lastCompaction.status) ? : }
-
-
- {/* keyed remount → 0.15s fade when In progress ↔ Done flips */}
- {title}
+ {!hasTools && (
+
+ {failedCompaction ?
+ : agentSlotKey ? null
+ : }
+
+ )}
+
+
+
{runningNote && {runningNote} }
- {/* 折叠态下用户最关心的是"到底动了多少次手",所以有工具就直接报工具数;
- 只有纯思考、没有工具的批次才退回步骤数。 */}
-
- {tools.length > 0
- ? t('{n} 个工具', { n: tools.length })
- : t('{n} 个步骤', { n: steps.length })}
-
@@ -195,10 +208,8 @@ export function ToolRunShell({ steps, isStreaming }: ToolRunShellProps) {
if (step.kind === 'compaction') {
return
;
}
- if (step.kind === 'thinking') {
- return
;
- }
- return
;
+ const active = isTail === undefined ? step.active : phase === 'thinking' && step.key === lastStepKey;
+ return
;
})}
diff --git a/src/frontend/src/components/tool/toolIcons.tsx b/src/frontend/src/components/tool/toolIcons.tsx
new file mode 100644
index 00000000..0d3be110
--- /dev/null
+++ b/src/frontend/src/components/tool/toolIcons.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from 'react';
+import type { ToolIconKind } from '../../utils/toolIconKind';
+
+/**
+ * Line icons for host-owned tools.
+ *
+ * One icon per kind of action, not per tool function: the three knowledge-base
+ * retrievers share the book, every shell entry point shares the terminal.
+ * Drawn on a 16px canvas with a 2px safe area, 1.5 stroke, round caps and
+ * joins, no fill, `currentColor` — so a row tints the icon by colour alone
+ * (grey when done, brand blue while running).
+ *
+ * Tools contributed by skills, plugins and MCP connectors keep their own logo
+ * (`ToolCapabilityIcon`); these icons are only for what the host itself ships.
+ */
+const DOC = 'M9.5 2H5A1.5 1.5 0 0 0 3.5 3.5v9A1.5 1.5 0 0 0 5 14h6a1.5 1.5 0 0 0 1.5-1.5V5z';
+const FOLDER = 'M2 12V4.5A1.5 1.5 0 0 1 3.5 3h2.4l1.5 1.5h5.1A1.5 1.5 0 0 1 14 6v6a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12z';
+const BUBBLE = 'M3.5 2.75h9A1.5 1.5 0 0 1 14 4.25v5.5a1.5 1.5 0 0 1-1.5 1.5H8.5L5.5 13.5v-2.25h-2A1.5 1.5 0 0 1 2 9.75v-5.5a1.5 1.5 0 0 1 1.5-1.5z';
+
+const PATHS: Record = {
+ terminal: <> >,
+ fileRead: <> >,
+ fileWrite: <> >,
+ edit: <> >,
+ findFiles: <> >,
+ grep: <> >,
+ image: <> >,
+ webSearch: <> >,
+ globe: <> >,
+ knowledge: <> >,
+ graph: <> >,
+ database: <> >,
+ skill: ,
+ plugin: <> >,
+ catalog: <> >,
+ agent: <> >,
+ ask: <> >,
+ job: <> >,
+ folderUp: <> >,
+ folderDown: <> >,
+ pin: <> >,
+ word: <> >,
+ table: <> >,
+ publish: <> >,
+ chats: <> >,
+ notes: <> >,
+ compress: ,
+ thinking: <> >,
+ wrench: ,
+};
+
+interface ToolIconProps {
+ kind: ToolIconKind;
+ className?: string;
+}
+
+export function ToolIcon({ kind, className }: ToolIconProps) {
+ return (
+
+ {PATHS[kind]}
+
+ );
+}
diff --git a/src/frontend/src/hooks/chatStream/consume.ts b/src/frontend/src/hooks/chatStream/consume.ts
index e940d059..bab56a1e 100644
--- a/src/frontend/src/hooks/chatStream/consume.ts
+++ b/src/frontend/src/hooks/chatStream/consume.ts
@@ -45,7 +45,6 @@ s.metaDurationMs = null;
s.compactionPending = false;
s.parseBuffer = '';
s.deferredThinkingText = undefined;
-s.toolPending = false;
s.ontologySidebarAutoOpened = false;
s.aborted = false;
s.thinkingPhaseActive = s.enableThinking;
diff --git a/src/frontend/src/hooks/chatStream/lifecycleAndTools.ts b/src/frontend/src/hooks/chatStream/lifecycleAndTools.ts
index b770342e..ff174aee 100644
--- a/src/frontend/src/hooks/chatStream/lifecycleAndTools.ts
+++ b/src/frontend/src/hooks/chatStream/lifecycleAndTools.ts
@@ -125,42 +125,19 @@ if (eventType === 'error') {
}
throw new Error(streamError);
}
-if (eventType === 'tool_pending' && ontologyRevisionTool) {
- const revision = ensureOntologyRevision(s);
- revision.status = 'streaming';
- revision.toolPending = true;
- appendOrUpdate(s, true, s.allCitations);
- return true;
-}
-if (eventType === 'tool_pending') {
- if (!s.toolPending) {
- s.toolPending = true;
- appendOrUpdate(s, true);
- }
- return true;
-}
-if (s.toolPending && eventType !== 'heartbeat') {
- s.toolPending = false;
- appendOrUpdate(s, true);
-}
if (eventType === 'ontology_repair') {
const revision = ensureOntologyRevision(s, typeof eventObj.source === 'string' ? eventObj.source : undefined);
revision.status = eventObj.status === 'completed' ? 'completed' : 'streaming';
if (eventObj.status === 'started')
autoOpenOntologySidebar(s);
- if (eventObj.status === 'started' || eventObj.status === 'completed') {
- revision.toolPending = false;
- }
if (typeof eventObj.tool_calls === 'number')
revision.toolCallCount = eventObj.tool_calls;
appendOrUpdate(s, true, s.allCitations);
return true;
}
if (eventType === 'ontology_revision_thinking') {
- const content = String(eventObj.delta || eventObj.content || '');
- if (content)
- appendRevisionThinking(s, content);
- ensureOntologyRevision(s).toolPending = false;
+ ensureOntologyRevision(s);
+ appendRevisionThinking(s, String(eventObj.delta || eventObj.content || ''));
appendOrUpdate(s, true, s.allCitations);
return true;
}
@@ -172,7 +149,6 @@ if (eventType === 'ontology_revision') {
// whole candidate as hidden reasoning when no tag follows.
const revision = ensureOntologyRevision(s);
revision.status = 'streaming';
- revision.toolPending = false;
autoOpenOntologySidebar(s);
if (content)
appendRevisionText(s, content);
@@ -181,7 +157,6 @@ if (eventType === 'ontology_revision') {
}
if (ontologyRevisionTool && (eventType === 'tool_use' || eventType === 'tool_call_start' || eventType === 'tool_call' || eventType === 'tool_start')) {
const revision = ensureOntologyRevision(s);
- revision.toolPending = false;
const eventToolId = getEventToolId(s, eventObj);
const existingIndex = eventToolId ? toolCardIndexById(revision.toolCalls, eventToolId) : -1;
const toolInput = eventObj.input ?? eventObj.args ?? eventObj.tool_args ?? eventObj.arguments;
@@ -210,7 +185,6 @@ if (ontologyRevisionTool && (eventType === 'tool_use' || eventType === 'tool_cal
}
if (ontologyRevisionTool && eventType === 'tool_call_delta') {
const revision = ensureOntologyRevision(s);
- revision.toolPending = false;
const eventToolId = getEventToolId(s, eventObj);
const delta = typeof eventObj.arguments_delta === 'string' ? eventObj.arguments_delta : '';
const index = eventToolId ? toolCardIndexById(revision.toolCalls, eventToolId) : -1;
@@ -236,7 +210,6 @@ if (ontologyRevisionTool && eventType === 'tool_call_delta') {
}
if (ontologyRevisionTool && (eventType === 'tool_result' || eventType === 'tool_end')) {
const revision = ensureOntologyRevision(s);
- revision.toolPending = false;
const eventToolId = getEventToolId(s, eventObj);
const toolName = getEventToolRawName(s, eventObj);
// 同上:带 id 就只认 id,认不到就往下走「新建一张卡」,不按名字抢别人的。
@@ -267,7 +240,6 @@ if (ontologyRevisionTool && (eventType === 'tool_result' || eventType === 'tool_
}
if (ontologyRevisionTool && eventType === 'subagent_event') {
const revision = ensureOntologyRevision(s);
- revision.toolPending = false;
if (applySubagentEvent(revision.toolCalls, eventObj)) {
if (Array.isArray(eventObj.citations)) {
s.allCitations = [...s.allCitations, ...(eventObj.citations as CitationItem[])];
@@ -311,6 +283,20 @@ if (eventType === 'tool_use' || eventType === 'tool_call_start' || eventType ===
appendOrUpdate(s, true);
return true;
}
+if (eventType === 'tool_progress') {
+ const toolId = getEventToolId(s, eventObj);
+ if (!toolId) return true;
+ const index = toolCardIndexById(s.toolCalls, toolId);
+ if (index >= 0 && s.toolCalls[index].status === 'running') {
+ const value = typeof eventObj.progress === 'number' ? eventObj.progress : undefined;
+ const total = typeof eventObj.total === 'number' ? eventObj.total : undefined;
+ const message = typeof eventObj.message === 'string' ? eventObj.message.slice(0, 500) : '';
+ s.toolCalls[index] = { ...s.toolCalls[index], progressNote:
+ [message, value === undefined ? '' : total ? `${value}/${total}` : String(value)].filter(Boolean).join(' · ') };
+ appendOrUpdate(s, true);
+ }
+ return true;
+}
if (eventType === 'tool_call_delta') {
const eventToolId = getEventToolId(s, eventObj);
const delta = typeof eventObj.arguments_delta === 'string' ? eventObj.arguments_delta : '';
diff --git a/src/frontend/src/hooks/chatStream/messageStore.ts b/src/frontend/src/hooks/chatStream/messageStore.ts
index c0751d60..3b9a28b0 100644
--- a/src/frontend/src/hooks/chatStream/messageStore.ts
+++ b/src/frontend/src/hooks/chatStream/messageStore.ts
@@ -45,9 +45,8 @@ export function commitUpdate(s: ChatStreamState, streaming: boolean, cits?: Cita
segments: s.segments.length > 0 ? [...s.segments] : undefined,
isStreaming: streaming,
inFlight: streaming && s.runId ? { eventOffset: s.eventOffset, textSnapshotOffset: s.textSnapshotOffset } : undefined,
- toolPending: streaming && s.toolPending,
- // Persisted activity stamp — anchors the "正在准备调用工具…" timer so
- // it survives a session switch / refresh remount (see useStallDetector).
+ // Persisted activity stamp — anchors the stall clock so it survives
+ // a session switch / refresh remount (see useStallDetector).
// 取服务端下发的时刻而非本地钟:另一台设备接上同一轮时读到的是同一个值。
lastActivityTs: s.lastEventTs,
};
@@ -206,7 +205,6 @@ export function applySteerBoundary(s: ChatStreamState, eventObj: Record({ signature, idleMs: 0, since: null });
+ let current = age;
+ if (age.signature !== signature) {
+ current = { signature, idleMs: 0, since: null };
+ setAge(current);
+ }
+
+ useEffect(() => {
+ if (!active) return;
+ const timers = thresholds.map((ms) => window.setTimeout(() => {
+ setAge((prev) => (prev.signature !== signature || prev.idleMs >= ms
+ ? prev
+ : { signature, idleMs: ms, since: prev.since ?? Date.now() - ms }));
+ }, ms));
+ return () => timers.forEach((id) => window.clearTimeout(id));
+ }, [signature, active, thresholds]);
+
+ return active ? { idleMs: current.idleMs, since: current.since } : { idleMs: 0, since: null };
+}
diff --git a/src/frontend/src/hooks/useStallDetector.ts b/src/frontend/src/hooks/useStallDetector.ts
index 737e59ba..2e052ad1 100644
--- a/src/frontend/src/hooks/useStallDetector.ts
+++ b/src/frontend/src/hooks/useStallDetector.ts
@@ -5,10 +5,8 @@ import { useEffect, useState } from 'react';
*
* `signature` should change whenever the stream emits new content (text token,
* tool call, segment, etc). When it stops changing for `stallMs` we flip to
- * `waiting = true` and report the timestamp the stall started. This is the
- * frontend safety net for the case where the LLM buffers a long tool-call
- * payload server-side (e.g. MiniMax) and the backend's `tool_pending` event
- * either hasn't fired or fires late.
+ * `waiting = true` and report the timestamp the stall started, e.g. while the
+ * LLM buffers a long tool-call payload server-side (MiniMax) before emitting it.
*
* `active` says whether there is a stream to watch at all, and it is not an
* optimisation: hooks cannot be called conditionally, so every caller that
diff --git a/src/frontend/src/hooks/useToolCapability.ts b/src/frontend/src/hooks/useToolCapability.ts
index 07f0d751..72b46e79 100644
--- a/src/frontend/src/hooks/useToolCapability.ts
+++ b/src/frontend/src/hooks/useToolCapability.ts
@@ -1,14 +1,22 @@
+import { useCallback } from 'react';
import { useCatalogStore } from '../stores/catalogStore';
import { usePluginStore } from '../stores/pluginStore';
import { usePluginUiStore } from '../stores/pluginUiStore';
import type { ToolCall } from '../types';
-import { resolveToolCapability } from '../utils/toolCapability';
+import { resolveToolCapability, type ToolCapability } from '../utils/toolCapability';
-export function useToolCapability(tool: ToolCall) {
+/** Resolves any number of tools against one read of the catalog and plugin stores. */
+export function useToolCapabilityResolver(): (tool: ToolCall) => ToolCapability | null {
const catalog = useCatalogStore((state) => state.catalog);
const plugins = usePluginStore((state) => state.installed);
const contributions = usePluginUiStore((state) => state.items);
- const owner = contributions.find((item) => item.contributes.tool_meta?.some((meta) => meta.tool === tool.name));
- const meta = owner?.contributes.tool_meta?.find((entry) => entry.tool === tool.name);
- return resolveToolCapability(tool, catalog, plugins, owner ? { slug: owner.slug, icon: meta?.icon } : undefined);
+ return useCallback((tool: ToolCall) => {
+ const owner = contributions.find((item) => item.contributes.tool_meta?.some((meta) => meta.tool === tool.name));
+ const meta = owner?.contributes.tool_meta?.find((entry) => entry.tool === tool.name);
+ return resolveToolCapability(tool, catalog, plugins, owner ? { slug: owner.slug, icon: meta?.icon } : undefined);
+ }, [catalog, plugins, contributions]);
+}
+
+export function useToolCapability(tool: ToolCall) {
+ return useToolCapabilityResolver()(tool);
}
diff --git a/src/frontend/src/i18n/en/chat.ts b/src/frontend/src/i18n/en/chat.ts
index 927e7de5..96b5574d 100644
--- a/src/frontend/src/i18n/en/chat.ts
+++ b/src/frontend/src/i18n/en/chat.ts
@@ -393,8 +393,6 @@ export const CHAT_DICT: Record = {
'搜索提示词...': 'Search prompts...',
'没有匹配的提示词': 'No matching prompts',
'暂无提示词,请在管理后台添加': 'No prompts yet. Add some in the admin console.',
- '正在准备调用工具': 'Preparing tool call',
- '正在准备调用工具…': 'Preparing tool call…',
'正在开始执行任务...': 'Starting task execution...',
'Mermaid 渲染失败': 'Mermaid render failed',
'加载图表中...': 'Loading diagram...',
diff --git a/src/frontend/src/i18n/en/lab.ts b/src/frontend/src/i18n/en/lab.ts
index 6eb6fb5c..3321a125 100644
--- a/src/frontend/src/i18n/en/lab.ts
+++ b/src/frontend/src/i18n/en/lab.ts
@@ -167,7 +167,9 @@ export const LAB_DICT: Record = {
'暂无运行记录': 'No run records',
'等待下一次自动执行或手动触发任务': 'Waiting for the next scheduled or manual trigger',
'{n} 次': '{n} runs',
- '执行中,暂不可查看': 'Running — not viewable yet',
+ '执行中,点击查看实时进度': 'Running — click to view live progress',
+ '准备中,暂不可查看': 'Preparing — not viewable yet',
+ '查看进度': 'View Progress',
'耗时 {dur}': 'Duration: {dur}',
'耗时 —': 'Duration: —',
'进入对话': 'Open Chat',
diff --git a/src/frontend/src/i18n/en/tool.ts b/src/frontend/src/i18n/en/tool.ts
index 393b8f05..dab33ae2 100644
--- a/src/frontend/src/i18n/en/tool.ts
+++ b/src/frontend/src/i18n/en/tool.ts
@@ -107,7 +107,6 @@ export const TOOL_DICT: Record = {
'等{n}项': ' +{n} more',
// ThinkingStepRow.tsx
'思考': 'Thinking',
- // PendingStepRow.tsx
// CodeView.tsx (line count — same key as in ToolOutputRenderer.tsx above)
// SearchRenderer.tsx
'暂无搜索结果': 'No search results',
@@ -191,4 +190,30 @@ export const TOOL_DICT: Record = {
'正在导出 Excel 表格': 'Exporting Excel sheet',
'导出了 Excel 表格': 'Exported an Excel sheet',
'已调用工具': 'Called tools',
+ '正在查找文件': 'Finding files',
+ '查找了文件': 'Found files',
+ '正在搜索内容': 'Searching content',
+ '搜索了内容': 'Searched content',
+ '正在识别图片': 'Reading image',
+ '识别了图片': 'Read an image',
+ '正在查阅知识地图': 'Browsing knowledge map',
+ '查阅了知识地图': 'Browsed knowledge map',
+ '正在向你提问': 'Asking you',
+ '向你提了问题': 'Asked you a question',
+ '正在运行批量作业': 'Running batch job',
+ '运行了批量作业': 'Ran a batch job',
+ '正在固定到工作区': 'Pinning to workspace',
+ '固定到了工作区': 'Pinned to workspace',
+ '正在发布站点': 'Publishing site',
+ '发布了站点': 'Published a site',
+ '正在查阅历史对话': 'Reading past chats',
+ '查阅了历史对话': 'Read past chats',
+ '正在读取项目说明': 'Reading project instructions',
+ '读取了项目说明': 'Read project instructions',
+ '正在更新项目说明': 'Updating project instructions',
+ '更新了项目说明': 'Updated project instructions',
+ // ToolRunShell.tsx / TurnStatusIndicator.tsx
+ '正在分析结果…': 'Analyzing results…',
+ '正在思考下一步…': 'Working out the next step…',
+ '仍在等待模型响应…': 'Still waiting for the model…',
};
diff --git a/src/frontend/src/stores/automationChatStore.ts b/src/frontend/src/stores/automationChatStore.ts
index b233be95..6d868900 100644
--- a/src/frontend/src/stores/automationChatStore.ts
+++ b/src/frontend/src/stores/automationChatStore.ts
@@ -54,8 +54,6 @@ interface AutomationChatState {
exitAutomationChat: () => void;
selectRun: (runId: string) => void;
setRuns: (runs: AutomationRun[]) => void;
- toggleSidebarPinned: (taskId: string) => void;
- toggleSidebarFavorite: (taskId: string) => void;
setSidebarFavorite: (taskId: string, favorite: boolean) => void;
/** Switch into the given user's context (idempotent). */
hydrateForUser: (userId: string) => void;
@@ -93,8 +91,9 @@ export const useAutomationChatStore = create((set, get) =>
sidebarPrefs: {},
enterAutomationChat: (taskId, taskName, runs, initialRunId) => {
- const completedRuns = runs.filter(run => run.status !== 'running' && run.chat_id);
- const target = completedRuns.find(run => run.run_id === initialRunId) || completedRuns[0];
+ // 执行中的 run 在后端接受时就已关联对话,打开即可看到实时进度,不必等它结束
+ const viewableRuns = runs.filter(run => run.chat_id);
+ const target = viewableRuns.find(run => run.run_id === initialRunId) || viewableRuns[0];
set({ activeGroup: { taskId, taskName, runs }, selectedRunId: target?.run_id || null });
if (target?.chat_id) {
prepareRunChat(taskId, taskName, target);
@@ -107,7 +106,7 @@ export const useAutomationChatStore = create((set, get) =>
selectRun: (runId) => {
const group = get().activeGroup;
const run = group?.runs.find(item => item.run_id === runId);
- if (!group || !run?.chat_id || run.status === 'running') return;
+ if (!group || !run?.chat_id) return;
set({ selectedRunId: runId });
prepareRunChat(group.taskId, group.taskName, run);
navigateTo(pathForAutomationChat(group.taskId, run.chat_id));
@@ -118,32 +117,6 @@ export const useAutomationChatStore = create((set, get) =>
if (group) set({ activeGroup: { ...group, runs } });
},
- toggleSidebarPinned: (taskId) => set((state) => {
- const prev = state.sidebarPrefs[taskId] || {};
- const nextPrefs = {
- ...state.sidebarPrefs,
- [taskId]: {
- ...prev,
- pinned: !prev.pinned,
- },
- };
- saveSidebarPrefs(get().currentUserId, nextPrefs);
- return { sidebarPrefs: nextPrefs };
- }),
-
- toggleSidebarFavorite: (taskId) => set((state) => {
- const prev = state.sidebarPrefs[taskId] || {};
- const nextPrefs = {
- ...state.sidebarPrefs,
- [taskId]: {
- ...prev,
- favorite: !prev.favorite,
- },
- };
- saveSidebarPrefs(get().currentUserId, nextPrefs);
- return { sidebarPrefs: nextPrefs };
- }),
-
setSidebarFavorite: (taskId, favorite) => set((state) => {
const prev = state.sidebarPrefs[taskId] || {};
const nextPrefs = {
diff --git a/src/frontend/src/styles/chat.css b/src/frontend/src/styles/chat.css
index 58ecb6de..9861abf2 100644
--- a/src/frontend/src/styles/chat.css
+++ b/src/frontend/src/styles/chat.css
@@ -1,6 +1,7 @@
@import './chat/shell.css';
@import './chat/messages.css';
@import './chat/turn-status.css';
+@import './chat/agent-activity.css';
@import './chat/attachments.css';
@import './chat/toolbar-controls.css';
@import './chat/toolbar-popovers.css';
diff --git a/src/frontend/src/styles/chat/agent-activity.css b/src/frontend/src/styles/chat/agent-activity.css
new file mode 100644
index 00000000..9fcd1c20
--- /dev/null
+++ b/src/frontend/src/styles/chat/agent-activity.css
@@ -0,0 +1,226 @@
+/* ══════════════════════════════════════════════════════════════════════════
+ Agent activity in a streaming reply
+ - one vector brand mark per reply (.jx-agentMark) that glides between row
+ slots (.jx-agentSlot) as the agent moves from task to task
+ - tool-kind chips on the run header (.jx-kindChip)
+ - line tool icons (.jx-toolIcon) and rolling one-line labels (.jx-rollText)
+ Decorative motion first (each with a reduced-motion fade), the running
+ indicators that must keep moving last.
+ ══════════════════════════════════════════════════════════════════════════ */
+
+/* ── Slots and the travelling mark ── */
+/* Box shared with .jx-turnStatusMark (turn-status.css). */
+.jx-agentSlot{
+ position:relative;
+}
+.jx-agentSlot-failed{
+ font-size:13px;
+ color:var(--color-text-tertiary);
+}
+/* Out of flow, so the mark never takes part in the message's layout. */
+.jx-agentCursor{
+ position:absolute;
+ width:0;
+ height:0;
+ pointer-events:none;
+}
+.jx-agentCursor-mark{
+ position:absolute;
+ left:0;
+ top:0;
+ z-index:1;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ width:20px;
+ height:18px;
+ transition:opacity var(--motion-duration-fast) var(--motion-ease-standard);
+}
+.jx-agentCursor-mark--glide{
+ transition:transform var(--motion-duration-slow) var(--motion-ease-brand-out),
+ opacity var(--motion-duration-fast) var(--motion-ease-standard);
+}
+.jx-agentCursor-mark--hidden{
+ opacity:0;
+}
+
+/* ── The mark: brand glyph + outline trace + spark ── */
+.jx-agentMark{
+ display:block;
+ width:20px;
+ height:15px;
+ overflow:visible;
+}
+.jx-agentMark-base{
+ fill:var(--color-primary);
+ fill-opacity:.3;
+ transition:fill-opacity var(--motion-duration-normal) var(--motion-ease-standard),
+ fill var(--motion-duration-normal) var(--motion-ease-standard);
+}
+.jx-agentMark-trace{
+ fill:none;
+ stroke:var(--color-primary);
+ stroke-width:2.4;
+ stroke-linecap:round;
+ stroke-linejoin:round;
+ stroke-dasharray:24 76;
+ opacity:0;
+}
+.jx-agentMark-spark{
+ fill:var(--color-brand-spark);
+ transform-box:fill-box;
+ transform-origin:center;
+ transition:fill var(--motion-duration-normal) var(--motion-ease-standard);
+}
+/* Settled rows keep the mono glyph: the spark follows the grey (brand spec §10). */
+.jx-agentMark--rest .jx-agentMark-base{
+ fill:var(--color-text-tertiary);
+ fill-opacity:.85;
+}
+.jx-agentMark--rest .jx-agentMark-spark{
+ fill:var(--color-text-tertiary);
+}
+
+/* Brand intro (spec §10): outline 0–600ms → fill 500–700ms → spark from 650ms. */
+.jx-agentMark--intro .jx-agentMark-base{
+ fill-opacity:1;
+ animation:jx-kf-agentFill 200ms var(--motion-ease-standard) 500ms backwards;
+}
+.jx-agentMark--intro .jx-agentMark-trace{
+ opacity:1;
+ stroke-dasharray:100 100;
+ animation:jx-kf-agentDraw 600ms var(--motion-ease-standard) both;
+}
+.jx-agentMark--intro .jx-agentMark-spark{
+ animation:jx-kf-agentPop var(--motion-duration-normal) var(--motion-ease-brand-out) 650ms backwards;
+}
+@keyframes jx-kf-agentDraw{ from{ stroke-dashoffset:100; } to{ stroke-dashoffset:0; } }
+@keyframes jx-kf-agentFill{ from{ fill-opacity:0; } }
+@keyframes jx-kf-agentPop{ from{ transform:scale(0); } }
+
+/* ── Tool-kind chips on the run header ── */
+/* Side by side, not overlapped: at 20px an overlap hides part of the glyph. */
+.jx-kindStack{
+ display:inline-flex;
+ align-items:center;
+ gap:3px;
+ flex-shrink:0;
+}
+.jx-kindChip{
+ display:inline-flex;
+ align-items:center;
+ justify-content:center;
+ width:20px;
+ height:20px;
+ border-radius:6px;
+ background:color-mix(in srgb, var(--color-text) 6%, var(--color-bg-chat));
+ color:var(--color-text-secondary);
+ transition:background-color var(--motion-duration-fast) var(--motion-ease-standard),
+ color var(--motion-duration-fast) var(--motion-ease-standard);
+}
+.jx-kindChip .jx-toolIcon{
+ width:13px;
+ height:13px;
+}
+.jx-kindChip.is-running{
+ background:color-mix(in srgb, var(--color-primary) 13%, var(--color-bg-chat));
+ color:var(--color-primary);
+}
+.jx-kindChip--logo{
+ background:transparent;
+}
+.jx-kindChip--logo .jx-tcr-capabilityIcon{
+ display:inline-flex;
+}
+.jx-kindChip--more{
+ font-family:var(--font-family-number);
+ font-size:11px;
+ font-weight:600;
+ font-variant-numeric:tabular-nums;
+ color:var(--color-text-tertiary);
+}
+.jx-kindChip--enter{
+ animation:jx-kf-kindIn var(--motion-duration-normal) var(--motion-ease-brand-out) backwards;
+}
+@keyframes jx-kf-kindIn{ from{ opacity:0; transform:translateX(-6px) scale(.7); } to{ opacity:1; transform:none; } }
+
+/* ── Line tool icons (16px canvas, 1.5 stroke, currentColor) ── */
+.jx-toolIcon{
+ display:block;
+ flex-shrink:0;
+ width:14px;
+ height:14px;
+ fill:none;
+ stroke:currentColor;
+ stroke-width:1.5;
+ stroke-linecap:round;
+ stroke-linejoin:round;
+}
+.jx-tcr-kind{
+ color:var(--color-text-tertiary);
+ transition:color var(--motion-duration-fast) var(--motion-ease-standard);
+}
+.jx-tcr-kind--running{
+ color:var(--color-primary);
+}
+
+/* ── Rolling one-line label ── */
+.jx-rollText{
+ display:inline-grid;
+ overflow:hidden;
+ vertical-align:middle;
+ min-width:0;
+ max-width:100%;
+}
+.jx-rollText--sizing{
+ transition:width var(--motion-duration-normal) var(--motion-ease-brand-out);
+}
+.jx-rollText-item{
+ grid-area:1/1;
+ justify-self:start;
+ white-space:nowrap;
+}
+.jx-rollText-item--in{
+ animation:jx-kf-rollIn var(--motion-duration-normal) var(--motion-ease-brand-out) backwards;
+}
+/* Exit ≈ enter × 0.7 */
+.jx-rollText-item--out{
+ animation:jx-kf-rollOut var(--motion-duration-fast) var(--motion-ease-exit) forwards;
+}
+@keyframes jx-kf-rollIn{ from{ opacity:0; transform:translateY(75%); } to{ opacity:1; transform:none; } }
+@keyframes jx-kf-rollOut{ from{ opacity:1; transform:none; } to{ opacity:0; transform:translateY(-75%); } }
+
+/* motion-fade: movement becomes a plain fade; the brand intro just fades in. */
+@media (prefers-reduced-motion: reduce){
+ @keyframes jx-kf-agentDraw{ from{ stroke-dashoffset:0; opacity:0; } to{ stroke-dashoffset:0; opacity:1; } }
+ @keyframes jx-kf-agentFill{ from{ fill-opacity:1; opacity:0; } to{ opacity:1; } }
+ @keyframes jx-kf-agentPop{ from{ opacity:0; } to{ opacity:1; } }
+ @keyframes jx-kf-kindIn{ from{ opacity:0; } to{ opacity:1; } }
+ @keyframes jx-kf-rollIn{ from{ opacity:0; } to{ opacity:1; } }
+ @keyframes jx-kf-rollOut{ from{ opacity:1; } to{ opacity:0; } }
+ .jx-agentCursor-mark--glide{
+ transition:opacity var(--motion-duration-fast) var(--motion-ease-standard);
+ }
+}
+
+
+
+
+/* ── Running indicators: these keep moving under reduced motion ── */
+.jx-agentMark--work .jx-agentMark-trace{
+ /* motion-keep: a light running round the mark is how a busy step looks; a still mark reads as stalled */
+ opacity:1;
+ animation:jx-kf-agentTrace 1.3s linear infinite;
+}
+.jx-agentMark--think .jx-agentMark-trace{
+ /* motion-keep: the slower light says the model is still working on its answer */
+ opacity:.7;
+ stroke-dasharray:10 90;
+ animation:jx-kf-agentTrace 2.6s linear infinite;
+}
+.jx-agentMark--think .jx-agentMark-spark{
+ /* motion-keep: the breathing spark marks a wait on the model, which must not look frozen */
+ animation:jx-kf-agentBreathe 1.2s var(--motion-ease-standard) infinite;
+}
+@keyframes jx-kf-agentTrace{ from{ stroke-dashoffset:100; } to{ stroke-dashoffset:0; } }
+@keyframes jx-kf-agentBreathe{ 0%,100%{ transform:scale(1); } 50%{ transform:scale(1.6); } }
diff --git a/src/frontend/src/styles/chat/messages.css b/src/frontend/src/styles/chat/messages.css
index d162d19a..fac6d168 100644
--- a/src/frontend/src/styles/chat/messages.css
+++ b/src/frontend/src/styles/chat/messages.css
@@ -1,6 +1,13 @@
/* 消息行:竖向堆叠(不再是头像+内容横排) */
.jx-msg{
+ /* Off-screen messages skip rendering. Applied from the start — streaming
+ included — so the browser remembers each message's real height while it
+ is on screen; switching it on only once a reply settled made the long
+ reply collapse to the 240px placeholder for a frame, clamping the scroll
+ upwards and detaching stream-following. */
+ content-visibility:auto;
+ contain-intrinsic-size:auto 240px;
--motion-duration-message-enter:350ms;
--motion-duration-user-bubble-enter:300ms;
--motion-ease-message-enter:ease-out;
diff --git a/src/frontend/src/styles/chat/turn-status.css b/src/frontend/src/styles/chat/turn-status.css
index 7d7afe20..de37b7f8 100644
--- a/src/frontend/src/styles/chat/turn-status.css
+++ b/src/frontend/src/styles/chat/turn-status.css
@@ -21,16 +21,17 @@
.jx-turnStatus{align-self:flex-start;}
.jx-trs.jx-trs--turnStatus{margin:0;animation:none;}
-.jx-turnStatusMark,
-.jx-trs-mark.jx-turnStatusMark{
+/* One mark box for rows with the vector agent mark (.jx-agentSlot) and rows
+ still on the brand GIF (.jx-turnStatusMark), so their text shares a column. */
+.jx-agentSlot,
+.jx-turnStatusMark{
display:inline-flex;
align-items:center;
justify-content:center;
- flex:0 0 18px;
- width:18px;
+ flex:0 0 20px;
+ width:20px;
height:18px;
}
-.jx-trs--turnStatus .jx-trs-mark--running::before{display:none;}
/* motion-keep: Fixed-size status marks breathe while a request is active. */
.jx-turnStatusMark .jx-brandLoader:not(.jx-brandLoader--done){
@@ -51,11 +52,10 @@
text-overflow:ellipsis;
}
-.jx-trs--turnStatus .jx-trs-steps,
.jx-trs--turnStatus .jx-trs-dur{white-space:nowrap;flex-shrink:0;}
-/* One stable text surface shares the scan across title, timer and count.
- Timer ticks and title changes do not remount or restart this surface. */
+/* Title, timer and live note on one line. Only the title scans (RollingText);
+ timer ticks never remount it, so the scan does not restart every second. */
.jx-trs .jx-trs-summary{
display:inline-flex;
align-items:center;
@@ -63,18 +63,16 @@
min-width:0;
max-width:100%;
}
-.jx-trs-summary--live .jx-trs-title{
- animation:none;
-}
/* Shared reference shimmer for startup, thinking, tool calls and execution.
Match the actual label classes with enough specificity to beat tool.css's
- historical entrance/static rules; completed labels lose their live class. */
+ historical entrance/static rules; completed labels lose their live class.
+ Rolling labels (RollingText) scan their inner text span, so the roll's own
+ transform animation never competes with the scan for `animation`. */
/* motion-keep: Text scanning is an activity signal while a request is running. */
-.jx-turnStatus-label.jx-turnStatusText,
+.jx-rollText-text.is-shimmer,
.jx-tcr .jx-tcr-prefix.jx-tcr-prefix--shimmer,
-.jx-inlineFold .jx-inlineSummary .jx-inlineSummaryText--live,
-.jx-trs .jx-trs-summary.jx-trs-summary--live{
+.jx-inlineFold .jx-inlineSummary .jx-inlineSummaryText--live{
background-image:linear-gradient(90deg,
color-mix(in srgb, var(--color-text-secondary) 80%, var(--color-primary)) 0%,
var(--color-text-secondary) 35%,
diff --git a/src/frontend/src/styles/tool.css b/src/frontend/src/styles/tool.css
index 7aa9b6dc..c3dcb309 100644
--- a/src/frontend/src/styles/tool.css
+++ b/src/frontend/src/styles/tool.css
@@ -70,25 +70,10 @@
.jx-tcr-icon--running {
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
-/* Per-step category glyph (replaces the generic success tick) — quiet,
- thin-stroked, no fill; the kind of step is the signal, not "✓". */
-.jx-tcr-icon--step {
- color: color-mix(in srgb, var(--color-text) 40%, transparent);
- font-size: 13px;
- stroke-width: 0;
- transition: color 0.15s ease;
-}
-.jx-tcr-header:is(button, [role="button"]):hover .jx-tcr-icon--step {
- color: color-mix(in srgb, var(--color-text) 60%, transparent);
-}
-/* Fallback marker for tools with no category icon — a small hollow ring */
-.jx-tcr-dot {
- display: inline-block;
- width: 7px;
- height: 7px;
- flex-shrink: 0;
- border-radius: 50%;
- box-shadow: inset 0 0 0 1.4px color-mix(in srgb, var(--color-text) 30%, transparent);
+/* Step glyphs are the line icons in toolIcons.tsx (.jx-tcr-kind, agent-activity.css);
+ a settled glyph darkens a step on hover like the rest of the row. */
+.jx-tcr-header:is(button, [role="button"]):hover .jx-tcr-kind:not(.jx-tcr-kind--running) {
+ color: var(--color-text-secondary);
}
/* ── Label: prefix + chip value + count (NOT flex:1 — arrow sits next to text) ── */
@@ -109,13 +94,11 @@
color: color-mix(in srgb, var(--color-text) 65%, transparent);
flex-shrink: 0;
}
-/* "正在准备调用工具…" 等待文字流光 — 单色灰阶,参数缓冲空窗期的活性信号。
- 隐藏工具调用时那行折叠摘要(--live)、展开后的壳标题(--live)共用同一套:
- 运行中它们是唯一在动的字。 */
+/* 运行中工具行的文字流光 — 单色灰阶。
+ 隐藏工具调用时那行折叠摘要(--live)共用同一套:运行中它们是唯一在动的字。 */
/* motion-keep: 工具运行中的文字流光,定格会变成一段忽深忽浅的灰字 */
.jx-tcr-prefix--shimmer,
-.jx-inlineSummaryText--live,
-.jx-trs-title--live {
+.jx-inlineSummaryText--live {
background: linear-gradient(90deg,
color-mix(in srgb, var(--color-text) 40%, transparent) 0%,
color-mix(in srgb, var(--color-text) 88%, transparent) 50%,
@@ -127,13 +110,11 @@
color: transparent;
animation: jx-kf-shimmer 2.2s linear infinite;
}
-/* Value rendered as a subtle chip (light gray background pill) */
+/* Argument after the action: plain tertiary text, no chip — the row stays one quiet line */
.jx-tcr-value {
display: inline-block;
- padding: 0 6px;
- background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
- border-radius: 4px;
- color: color-mix(in srgb, var(--color-text) 82%, transparent);
+ padding: 0;
+ color: var(--color-text-tertiary);
font-weight: 400;
max-width: 100%;
overflow: hidden;
@@ -310,10 +291,6 @@
--fadeInUp-distance: -4px;
animation: jx-kf-fadeInUp .18s ease-out both;
}
-/* 标题(执行中 ↔ 已完成)keyed 重挂时淡入 */
-.jx-trs-title {
- animation: jx-kf-fadeIn .15s ease-out both;
-}
/* 计时器完成定格:与品牌 loader 同款 settle */
.jx-trs-dur--settled {
display: inline-block;
@@ -321,8 +298,7 @@
}
/* 历史回放 / 非流式渲染豁免:壳、步骤行、标题一律静态 */
.jx-trs--static,
-.jx-trs--static .jx-trs-body > *,
-.jx-trs--static .jx-trs-title {
+.jx-trs--static .jx-trs-body > * {
animation: none;
}
@@ -351,84 +327,12 @@
outline-offset: 1px;
}
-/* ── Status mark: brand logo (animated draw → settled) for run/done.
- A soft halo + a one-shot ring on completion
- give the batch a richer, branded focal point instead of a bare tick. ── */
-.jx-trs-mark {
- position: relative;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- flex-shrink: 0;
- border-radius: 50%;
-}
-/* Soft radial halo behind the brand mark for depth */
-.jx-trs-mark--running::before,
-.jx-trs-mark--success::before {
- content: "";
- position: absolute;
- inset: 0;
- border-radius: 50%;
- background: radial-gradient(
- circle at 50% 50%,
- color-mix(in srgb, var(--color-primary) 14%, transparent) 0%,
- transparent 70%
- );
-}
-.jx-trs-mark--success::before {
- background: radial-gradient(
- circle at 50% 50%,
- color-mix(in srgb, var(--color-text) 10%, transparent) 0%,
- transparent 70%
- );
-}
-/* One-shot expanding ring the moment the batch settles */
-.jx-trs-mark--success::after {
- content: "";
- position: absolute;
- inset: 2px;
- border-radius: 50%;
- border: 1.5px solid color-mix(in srgb, var(--color-text) 28%, transparent);
- animation: jx-trs-ring 0.55s ease-out forwards;
-}
-@keyframes jx-trs-ring {
- 0% { transform: scale(0.55); opacity: 0.9; }
- 100% { transform: scale(1.55); opacity: 0; }
-}
-/* motion-stop: 减弱动效下定格在终态(同名关键帧后定义者胜,用到它的规则一起生效) */
-@media (prefers-reduced-motion: reduce) {
- @keyframes jx-trs-ring { from, to { transform: scale(1.55); opacity: 0 } }
-}
-
-/* Gentle breathing on the live brand GIF for extra life */
-/* motion-keep: 「正在执行」的品牌动图,停下就只剩一枚静止图标,读起来像卡死 */
-.jx-trs-mark--running .jx-brandLoader {
- animation: jx-trs-breathe 1.8s ease-in-out infinite;
-}
-@keyframes jx-trs-breathe {
- 0%, 100% { transform: scale(1); }
- 50% { transform: scale(1.08); }
-}
.jx-trs-title {
font-size: 13.5px;
font-weight: 500;
letter-spacing: 0.01em;
color: color-mix(in srgb, var(--color-text) 78%, transparent);
}
-/* 运行中的壳标题跟着一起流光:展开工具调用后这行取代了 .jx-turnStatus-label 的位置,
- 同一段等待却只剩一个静止的标题,读起来像卡住了。渐变本体写在上面那组共用规则里,
- 这里只把 .jx-trs-title 系列压掉的 color / animation 抢回来。
- 选择器必须带 .jx-trs 前缀:标题上还挂着入场 fade,历史回放那条
- `.jx-trs--static .jx-trs-title{animation:none}` 又是两个类——单写 --live 只有一个类,
- 打不过它们,流光会静默失效(隐藏模式那行没人跟它抢 animation,所以一直好使)。
- 跑完摘掉 --live 落回基础规则,"执行中 → 已完成"那一次翻转照旧淡入。 */
-/* motion-keep: 运行中的标题流光,定格会变成一段忽深忽浅的灰字 */
-.jx-trs .jx-trs-title--live {
- color: transparent;
- animation: jx-kf-shimmer 2.2s linear infinite;
-}
.jx-trs-sep {
color: color-mix(in srgb, var(--color-text) 25%, transparent);
font-size: 12px;
@@ -439,10 +343,6 @@
font-variant-numeric: tabular-nums;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
-.jx-trs-steps {
- font-size: 12px;
- color: color-mix(in srgb, var(--color-text) 38%, transparent);
-}
/* 折叠态里那行实时进度(批量作业 n/m)——比步骤数显眼一档,它是这段时间唯一在动的字 */
.jx-trs-note {
font-size: 12px;
@@ -496,13 +396,22 @@
.jx-expandWrap > .jx-trs-body {
overflow: hidden;
}
+/* No guide line or step dots: each row is icon · action · argument · time.
+ Tool-less runs keep the 20px agent slot + 9px gap in their header, so the
+ 23px inset + the row's 6px padding puts step icons under the header text. */
.jx-trs-body {
display: flex;
flex-direction: column;
- gap: 1px;
- margin: 3px 0 2px 17px;
- padding-left: 13px;
- border-left: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
+ gap: 0;
+ margin: 2px 0 4px 23px;
+}
+/* Runs with tools start their header with the tool-kind chips at x=0: no inset,
+ and a 3px row padding centres the 14px step icons under the first 20px chip. */
+.jx-trs--tools .jx-trs-body {
+ margin-left: 0;
+}
+.jx-trs--tools .jx-trs-body > * > .jx-tcr-header {
+ padding-left: 3px;
}
/* Rows nested in the shell stay quiet; the shell owns the structure */
@@ -512,9 +421,8 @@
.jx-trs-body .jx-tcr-label {
font-size: 13px;
}
-.jx-trs-body .jx-tcr-value {
- background: color-mix(in srgb, var(--color-text) 4%, transparent);
- color: color-mix(in srgb, var(--color-text) 70%, transparent);
+.jx-trs-body .jx-tcr-prefix {
+ color: var(--color-text-secondary);
}
/* ══════════════════════════════════════════════════════════════════════════
diff --git a/src/frontend/src/styles/variables.css b/src/frontend/src/styles/variables.css
index e4f0568d..550a15fa 100644
--- a/src/frontend/src/styles/variables.css
+++ b/src/frontend/src/styles/variables.css
@@ -45,6 +45,8 @@
--color-primary:#126DFF;
--color-primary-hover:#3C87FF;
--color-primary-active:#0862F3;
+ /* 品牌火花点:只用在品牌标志里(含智能体标记),不当界面强调色 */
+ --color-brand-spark:#19D3A7;
--color-primary-disabled:#A0C5FF;
--color-primary-bg:#DBE9FF;
--color-primary-light:#EBF2FF;
@@ -241,6 +243,7 @@
--color-primary:#3E8BFF;
--color-primary-hover:#5FA0FF;
--color-primary-active:#2E7BF0;
+ --color-brand-spark:#19D3A7;
--color-primary-disabled:#28497E;
--color-primary-bg:rgba(62,139,255,.20);
--color-primary-light:rgba(62,139,255,.12);
diff --git a/src/frontend/src/types/automations.ts b/src/frontend/src/types/automations.ts
index 75bbd103..57bc2cb6 100644
--- a/src/frontend/src/types/automations.ts
+++ b/src/frontend/src/types/automations.ts
@@ -12,7 +12,7 @@ export type AutomationTaskType = 'prompt' | 'plan';
export type AutomationStatus = 'active' | 'paused' | 'disabled' | 'completed' | 'expired';
-export type AutomationRunStatus = 'running' | 'success' | 'failed';
+export type AutomationRunStatus = 'pending' | 'running' | 'success' | 'failed';
export type AutomationScheduleType = 'recurring' | 'once' | 'manual';
diff --git a/src/frontend/src/types/conversations.ts b/src/frontend/src/types/conversations.ts
index 2b65f7fd..5dcd9942 100644
--- a/src/frontend/src/types/conversations.ts
+++ b/src/frontend/src/types/conversations.ts
@@ -78,12 +78,9 @@ export interface ChatMessage {
* newly-generated assistant messages; backend history messages lack this field. Used
* to show "took X.Xs" next to "regenerate" in the action bar. */
durationMs?: number;
- /** Backend signals an extended LLM silence; UI replaces streaming dots with a "正在准备调用工具…" indicator. */
- toolPending?: boolean;
/** Wall-clock ms of the last streaming activity (content / tool / meta event).
- * Anchors the "正在准备调用工具…" elapsed timer to a persisted value so it
- * survives component remounts on session switch / page refresh instead of
- * resetting to zero. */
+ * Anchors the stall clock to a persisted value so it survives component
+ * remounts on session switch / page refresh instead of resetting to zero. */
lastActivityTs?: number;
attachments?: Array<{
origin?: 'local' | 'cloud';
diff --git a/src/frontend/src/types/messages.ts b/src/frontend/src/types/messages.ts
index e0c5d3a4..b79e4d64 100644
--- a/src/frontend/src/types/messages.ts
+++ b/src/frontend/src/types/messages.ts
@@ -191,7 +191,6 @@ export interface OntologyRevisionSummary {
thinking: ThinkingBlock[];
toolCalls: ToolCall[];
toolCallCount?: number;
- toolPending?: boolean;
}
/** User-visible ontology governance evidence, intentionally separate from model thinking. */
diff --git a/src/frontend/src/utils/elapsed.ts b/src/frontend/src/utils/elapsed.ts
new file mode 100644
index 00000000..341bc049
--- /dev/null
+++ b/src/frontend/src/utils/elapsed.ts
@@ -0,0 +1,5 @@
+/** Elapsed seconds as M:SS — the face of every live and settled step clock. */
+export function formatClock(totalSec: number): string {
+ const sec = Math.max(0, Math.floor(totalSec));
+ return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
+}
diff --git a/src/frontend/src/utils/toolActionLabel.ts b/src/frontend/src/utils/toolActionLabel.ts
index 2f359005..a9f6bda9 100644
--- a/src/frontend/src/utils/toolActionLabel.ts
+++ b/src/frontend/src/utils/toolActionLabel.ts
@@ -1,5 +1,6 @@
import { t } from '../i18n';
import { resolvePluginToolLabel, resolveToolDisplayName } from './toolMeta';
+import type { ToolIconKind } from './toolIconKind';
/**
* 折叠态执行卡那一行的文案真源。
@@ -10,40 +11,78 @@ import { resolvePluginToolLabel, resolveToolDisplayName } from './toolMeta';
interface ToolAction {
running: string;
done: string;
+ /** Line icon for this kind of action (components/tool/toolIcons.tsx). */
+ icon: ToolIconKind;
}
-const FILE_READ: ToolAction = { running: t('正在读取文件'), done: t('读取了文件') };
-const FILE_WRITE: ToolAction = { running: t('正在写入文件'), done: t('写入了文件') };
-const KB_QUERY: ToolAction = { running: t('正在检索知识库'), done: t('检索了知识库') };
-const CAPABILITY_LIST: ToolAction = { running: t('正在获取能力列表'), done: t('获取了能力列表') };
-const SPACE_FILE: ToolAction = { running: t('正在读写空间文件'), done: t('读写了空间文件') };
-const WORD_DOC: ToolAction = { running: t('正在生成 Word 文档'), done: t('生成了 Word 文档') };
+const action = (running: string, done: string, icon: ToolIconKind): ToolAction => ({ running, done, icon });
+const SHELL = action(t('正在执行命令'), t('运行了命令'), 'terminal');
+const FILE_READ = action(t('正在读取文件'), t('读取了文件'), 'fileRead');
+const KB_QUERY = action(t('正在检索知识库'), t('检索了知识库'), 'knowledge');
+const KNOWLEDGE_MAP = action(t('正在查阅知识地图'), t('查阅了知识地图'), 'graph');
+const DB_QUERY = action(t('正在查询数据库'), t('查询了数据库'), 'database');
+const SKILL = action(t('正在激活技能'), t('激活了技能'), 'skill');
+const CAPABILITY_LIST = action(t('正在获取能力列表'), t('获取了能力列表'), 'catalog');
+const WORD_DOC = action(t('正在生成 Word 文档'), t('生成了 Word 文档'), 'word');
+const CHAT_HISTORY = action(t('正在查阅历史对话'), t('查阅了历史对话'), 'chats');
+
+/** The one table of host-owned tools: what the row says and which icon it shows. */
const TOOL_ACTIONS: Record = {
- Bash: { running: t('正在执行命令'), done: t('运行了命令') },
- bash: { running: t('正在执行命令'), done: t('运行了命令') },
- Write: FILE_WRITE,
- Edit: { running: t('正在编辑文件'), done: t('编辑了文件') },
+ Bash: SHELL,
+ bash: SHELL,
+ run_command: SHELL,
+ execute_code: SHELL,
+ Read: FILE_READ,
view_text_file: FILE_READ,
- internet_search: { running: t('正在联网搜索'), done: t('搜索了网页') },
- web_fetch: { running: t('正在获取网页'), done: t('获取了网页') },
+ read_artifact: FILE_READ,
+ Write: action(t('正在写入文件'), t('写入了文件'), 'fileWrite'),
+ Edit: action(t('正在编辑文件'), t('编辑了文件'), 'edit'),
+ Glob: action(t('正在查找文件'), t('查找了文件'), 'findFiles'),
+ Grep: action(t('正在搜索内容'), t('搜索了内容'), 'grep'),
+ read_image: action(t('正在识别图片'), t('识别了图片'), 'image'),
+ internet_search: action(t('正在联网搜索'), t('搜索了网页'), 'webSearch'),
+ web_fetch: action(t('正在获取网页'), t('获取了网页'), 'globe'),
retrieve_dataset_content: KB_QUERY,
retrieve_local_kb: KB_QUERY,
list_datasets: KB_QUERY,
- query_database: { running: t('正在查询数据库'), done: t('查询了数据库') },
- load_skill: { running: t('正在激活技能'), done: t('激活了技能') },
- load_plugin: { running: t('正在加载插件'), done: t('加载了插件') },
+ search_knowledge_base: KB_QUERY,
+ wiki_overview: KNOWLEDGE_MAP,
+ wiki_locate: KNOWLEDGE_MAP,
+ wiki_read_page: KNOWLEDGE_MAP,
+ wiki_expand: KNOWLEDGE_MAP,
+ wiki_fetch_source: KNOWLEDGE_MAP,
+ query_database: DB_QUERY,
+ db_query: DB_QUERY,
+ execute_sql: DB_QUERY,
+ search_objects: DB_QUERY,
+ load_skill: SKILL,
+ use_skill: SKILL,
+ load_plugin: action(t('正在加载插件'), t('加载了插件'), 'plugin'),
get_skills: CAPABILITY_LIST,
get_mcp_tools: CAPABILITY_LIST,
get_agents: CAPABILITY_LIST,
- call_subagent: { running: t('正在调用智能体'), done: t('调用了智能体') },
- sandbox_put_artifact: SPACE_FILE,
- sandbox_get_artifact: SPACE_FILE,
+ call_subagent: action(t('正在调用智能体'), t('调用了智能体'), 'agent'),
+ ask_user_question: action(t('正在向你提问'), t('向你提了问题'), 'ask'),
+ run_job: action(t('正在运行批量作业'), t('运行了批量作业'), 'job'),
+ sandbox_put_artifact: action(t('正在读写空间文件'), t('读写了空间文件'), 'folderUp'),
+ sandbox_get_artifact: action(t('正在读写空间文件'), t('读写了空间文件'), 'folderDown'),
+ pin_to_workspace: action(t('正在固定到工作区'), t('固定到了工作区'), 'pin'),
word_create_from_markdown: WORD_DOC,
export_report_to_docx: WORD_DOC,
- export_table_to_excel: { running: t('正在导出 Excel 表格'), done: t('导出了 Excel 表格') },
+ export_table_to_excel: action(t('正在导出 Excel 表格'), t('导出了 Excel 表格'), 'table'),
+ publish_site: action(t('正在发布站点'), t('发布了站点'), 'publish'),
+ list_related_chats: CHAT_HISTORY,
+ read_chat: CHAT_HISTORY,
+ read_project_instructions: action(t('正在读取项目说明'), t('读取了项目说明'), 'notes'),
+ save_project_instructions: action(t('正在更新项目说明'), t('更新了项目说明'), 'notes'),
};
+/** Icon kind for a tool; unregistered tools (connectors, plugin tools) fall back to the wrench. */
+export function toolIconKind(toolName: string): ToolIconKind {
+ return TOOL_ACTIONS[toolName]?.icon ?? 'wrench';
+}
+
/** 折叠行只认名字,不需要整个 ToolCall。 */
export interface LabeledTool {
name: string;
@@ -56,11 +95,8 @@ export interface LabeledTool {
*/
function fallbackAction(tool: LabeledTool): ToolAction {
const label = resolvePluginToolLabel(tool.name) || resolveToolDisplayName(tool);
- if (!label) return { running: t('正在调用工具'), done: t('已调用工具') };
- return {
- running: t('正在调用 {label}', { label }),
- done: t('已调用 {label}', { label }),
- };
+ if (!label) return action(t('正在调用工具'), t('已调用工具'), 'wrench');
+ return action(t('正在调用 {label}', { label }), t('已调用 {label}', { label }), 'wrench');
}
function actionOf(tool: LabeledTool): ToolAction {
diff --git a/src/frontend/src/utils/toolIconKind.ts b/src/frontend/src/utils/toolIconKind.ts
new file mode 100644
index 00000000..7dbcfa15
--- /dev/null
+++ b/src/frontend/src/utils/toolIconKind.ts
@@ -0,0 +1,12 @@
+/**
+ * The line icons in components/tool/toolIcons.tsx: one per kind of action, so
+ * every shell entry point shares the terminal and the knowledge-base
+ * retrievers share the book. Which tool shows which icon lives next to its
+ * label in utils/toolActionLabel.ts (`toolIconKind`).
+ */
+export type ToolIconKind =
+ | 'terminal' | 'fileRead' | 'fileWrite' | 'edit' | 'findFiles' | 'grep' | 'image'
+ | 'webSearch' | 'globe' | 'knowledge' | 'graph' | 'database'
+ | 'skill' | 'plugin' | 'catalog' | 'agent' | 'ask' | 'job'
+ | 'folderUp' | 'folderDown' | 'pin' | 'word' | 'table' | 'publish'
+ | 'chats' | 'notes' | 'compress' | 'thinking' | 'wrench';
diff --git a/src/frontend/src/utils/turnActivity.ts b/src/frontend/src/utils/turnActivity.ts
new file mode 100644
index 00000000..ab596fd5
--- /dev/null
+++ b/src/frontend/src/utils/turnActivity.ts
@@ -0,0 +1,69 @@
+import type { ShellStep } from '../components/tool/ToolRunShell';
+import type { AgentMarkState } from '../components/common/AgentMark';
+import { computeEffectiveStatus } from '../components/tool/renderers/utils';
+import { t } from '../i18n';
+
+/**
+ * Pacing of the "what is the agent doing right now" signal while a reply
+ * streams. Every wait has a visible owner: the run that is still in flight, or
+ * the tail row under the text — never a silent gap that reads as a hang. A run
+ * with tools shows its phase through the lit tool-kind chip and the title scan;
+ * the agent mark (and `agentStateForPhase`) only appears on tool-less rows.
+ */
+
+/** Two tool calls this close apart are one hand-over: keep the previous label, don't flash "done". */
+export const HANDOFF_MS = 400;
+/** Text that has paused this long gets a "working out the next step" line under it. */
+export const TAIL_WAIT_MS = 600;
+/** Waits longer than this show their own clock. */
+export const WAIT_CLOCK_MS = 5_000;
+/** Waits longer than this say so plainly. */
+export const SLOW_WAIT_MS = 8_000;
+/** The idle steps a reply's own render depends on (the wait clock ticks in its own row). */
+export const ACTIVITY_THRESHOLDS = [HANDOFF_MS, TAIL_WAIT_MS, SLOW_WAIT_MS] as const;
+
+/**
+ * - `tool` / `compaction` — something is executing
+ * - `thinking` — reasoning is the latest step of the run
+ * - `handoff` — a tool just finished and the next one is probably coming
+ * - `gap` — tools finished, the model has the results and has not answered yet
+ * - `done` — the reply moved past this run (text came, or the stream ended)
+ */
+export type RunPhase = 'tool' | 'compaction' | 'thinking' | 'handoff' | 'gap' | 'done';
+
+interface RunPhaseInput {
+ isStreaming: boolean;
+ /**
+ * The run is the last thing in the reply. Undefined for callers that do not
+ * track the tail (Canvas transcripts, standalone shells): they keep the old
+ * rule where only an active thinking step holds the run open.
+ */
+ isTail?: boolean;
+ /** Ms since the reply last showed any activity. */
+ idleMs: number;
+ /** Callers that already resolved each tool's effective status pass the verdict in. */
+ anyToolRunning?: boolean;
+}
+
+export function runPhase(steps: ShellStep[], { isStreaming, isTail, idleMs, anyToolRunning }: RunPhaseInput): RunPhase {
+ if (steps.some((step) => step.kind === 'compaction' && step.progress.status === 'running')) return 'compaction';
+ if (anyToolRunning ?? steps.some((step) => step.kind === 'tool' && computeEffectiveStatus(step.tool, isStreaming) === 'running')) return 'tool';
+ if (!isStreaming) return 'done';
+ if (isTail === undefined) {
+ return steps.some((step) => step.kind === 'thinking' && step.active) ? 'thinking' : 'done';
+ }
+ if (!isTail) return 'done';
+ if (steps[steps.length - 1]?.kind === 'thinking') return 'thinking';
+ return idleMs < HANDOFF_MS ? 'handoff' : 'gap';
+}
+
+export function agentStateForPhase(phase: RunPhase): AgentMarkState {
+ if (phase === 'thinking' || phase === 'gap') return 'think';
+ if (phase === 'done') return 'rest';
+ return 'work';
+}
+
+/** A wait label that admits it once the wait has gone on for a while. */
+export function waitLabel(idleMs: number, label: string): string {
+ return idleMs >= SLOW_WAIT_MS ? t('仍在等待模型响应…') : label;
+}