# 开发参考 API、SSE、扩展方式和框架约束。运行、调试、打包与部署见 [项目 README](../README.md)。以下源码命令均在项目根目录执行,使用项目虚拟环境。 当前运行策略:所有 Agent 独立执行,已关闭扩展子 Agent 和框架默认 `general-purpose`,模型无 `task` 工具。业务注册定义继续支持独立业务 API、网关与批次入口。下文的子 Agent、委派和跟踪章节保留为历史设计参考,不代表当前启用能力。 ## API 主入口与 1.5 一致,额外新增了若干辅助接口。 ### 对话 | 方法 | 路径 | 说明 | |---|---|---| | POST | `/api/chat` | 主入口,SSE 流式 | | POST | `/api/chat/resume` | 恢复被中断挂起的对话 | | GET | `/api/chat/interrupt` | 查挂起的中断(刷新页面补渲染卡片);有则 200,无则 204 | | GET | `/api/chat/history/{session_id}` | 会话历史 | 不提供 `/api/chat/only`,统一使用 `/api/chat`。 `POST /api/chat` 请求体是 `multipart/form-data`: | 字段 | 必填 | 说明 | |---|---|---| | `message` | 是 | 用户消息,支持 `#skill:` 与 `#session:` 前缀 | | `session_id` | 否 | 缺省则新建会话 | | `thread_id` | 否 | **必须等于 `session_id`**,否则 404 | | `mode` | 否 | `plan`(先出计划待批)或 `full`(直接执行) | | `file` | 否 | 附件,落到 `/workspace/uploads/` | | `request_id` | 否 | 客户端生成的请求唯一键;同一用户重复提交返回 409 和原 `session_id`、`run_id` | | `user_name` | 否 | 归属用户,缺省 `default`(**2.0 新增,替代 1.5 的鉴权依赖**) | 响应是 `text/event-stream`,头含 `X-Session-Id`、`X-Trace-Id` 与 `X-Run-Id`。 ### 会话与用量 | 方法 | 路径 | 说明 | |---|---|---| | GET | `/api/sessions` | 会话列表(置顶优先) | | GET | `/api/sessions/search?q=` | 标题模糊搜索 | | DELETE | `/api/sessions/{id}` | 删除会话(**同时清图状态**) | | DELETE | `/api/messages/{id}` | 删除单条消息 | | GET/PUT | `/api/sessions/{id}/mode` | 权限模式 | | PUT | `/api/sessions/{id}/pin` · `/title` | 置顶 · 重命名 | | GET | `/api/sessions/{id}/runs` | 最近 50 次执行的状态与错误 | | GET | `/api/sessions/{id}/context` | 上下文用量明细与压缩存档索引 | | GET | `/api/usage/today` | 当日用量与额度 | | GET | `/api/usage/recent` | 最近用量流水 | | PUT | `/api/usage/quota` | 调整每日额度(0 = 不限) | | GET | `/api/usage/pricing` | 模型单价表 | | GET | `/api/preferences` · PUT · DELETE | 用户偏好(每轮自动注入) | ### 模型与工具 | 方法 | 路径 | 说明 | |---|---|---| | GET | `/api/model` | 模型配置与 agent 组装摘要 | | GET | `/api/model/tools` | 工具清单(按分组) | | POST | `/api/model/thinking` | 切换思考级别(触发 agent 重建) | | POST | `/api/model/reload` | 强制重建 agent | ### 定时任务 | 方法 | 路径 | 说明 | |---|---|---| | GET/POST | `/api/schedules` | 列表 / 新建 | | GET/PUT/DELETE | `/api/schedules/{id}` | 详情 / 更新 / 删除 | | POST | `/api/schedules/{id}/pause` · `/resume` · `/run` | 暂停 / 恢复 / 立即执行 | | GET | `/api/schedules/{id}/runs` | 执行记录 | ### 运维 | 方法 | 路径 | 说明 | |---|---|---| | GET | `/health` | 逐子系统健康状态 | | GET | `/api/health/billing` | 计费自检(单价是否覆盖当前模型) | | GET | `/` | 重定向到测试页 | | GET | `/static/index.html` | Vue工作台:对话与智能体测试 | --- ## SSE 事件契约 14 种业务事件 + 1 种通用错误。**字段名是从 1.5 前端逐行反查出来的**, 不是猜的。 | 事件 | 前端读取的字段 | |---|---| | `agent_start` | `agent` · `cn_agent` | | `thinking` | `message` · `cn_agent` | | `thinking_token` | `content` | | `executing` | `message` · `cn_agent` · `tools[]` · `cn_tools[]` | | `tool_call` | `tool` · `cn_tool` · `args` · `agent_id` | | `tool_result` | `tool` · `cn_tool` · `content` · `error` · `agent_id` | | `token` | `content` | | `updated_todo_list` | `todos` | | `progress` | `message` | | `interrupt` | `tool_approval{tool,args,reason,side_effect}` 或 `ask_user{questions[]}` 或 `message` | | `citations` | `items[]` · `model_only` | | `agent_done` | `agent` · `cn_agent` · `duration_ms` · `progress` · `context_tokens` | | `agent_error` | `agent` · `cn_agent` · `message` · `duration_ms` | | `done` | `duration_ms` · `usage{}` | | `error` | `message` · `code` · `retry_after` | 帧格式:`data: {json}\n\n`,每帧恒带 `session_id`,绑定 trace 时带 `trace_id`。 HTTP 错误语义:额度超限 429;并发超限 429(带 `retry_after`);上传 400/413; 会话不存在或越权 404;恢复时无挂起中断 409。 **事件字段统一定义在 `runtime/events.py`,SSE 编码只在 `api/sse_events.py`。** 其他模块不手拼 `data: ` 字符串。改字段时同步检查 `tests/test_sse_contract.py`。 --- ## 四项核心能力(相对 1.5 的做法改进) 这是 2.0 与 1.5 的主要差异所在。 ### 子智能体跟踪 | | 做法 | |---|---| | 1.5 | 从子图命名空间字符串 `tools:` **反查** `subagent_type`;id 被上下文压缩裁掉后反查落空,子智能体身份丢失 | | 2.0 | 收到 task 调用的**那一刻**就建 `tool_call_id → {类型, 描述}` 表,之后只按 id 查表;表由跟踪器持有,不受历史窗口影响 | 见 `runtime/subagent_tracker.py`。 ### 上下文计量 | | 做法 | |---|---| | 1.5 | tiktoken **本地估算**优先,为此专门开了 `fix/context-usage-accounting` 分支修口径 | | 2.0 | **真实 usage 优先**(`usage_metadata`),provider 不给才回落估算并标 `usage_source=estimate` | 两个量刻意分开:`input_tokens` 累加值是计费口径,`context_tokens`(最近一次 调用的 input)是当前上下文占用。 见 `runtime/context_meter.py`。 ### 自动压缩 | | 做法 | |---|---| | 1.5 | 自研 `auto_compact.py`,默认**关闭**,未被信任到敢开 | | 2.0 | 直接用 deepagents 的 `SummarizationMiddleware`:85% 窗口触发,**把被淘汰的历史追加落盘**到 `/conversation_history/{thread_id}.md` 并把路径嵌进摘要,agent 能自己读回来 | 调节旋钮是 `CONTEXT_LIMIT`(声明窗口),不是阈值比例——阈值由框架固定为 85%。压缩存档位置可在 `GET /api/sessions/{id}/context` 的 `archives` 里查。 ### 来源标注 | | 做法 | |---|---| | 1.5 | 靠提示词让**模型自觉**在正文写引用,再用启发式规则去正文里刮 | | 2.0 | **工具侧结构化产出**:工具返回 `sourced(data, sources)`,SSE 层从 ToolMessage 抽取、聚合、去重 | 模型漏写和编造这两种失败模式,在读工具返回值的路径上根本不存在。 见 `runtime/source_tracker.py` 与 `tools/registry.py`。 --- ## 执行层与入口 ```text 网页聊天 / 恢复 → runtime 执行事件 → api/sse_core.py 编码 SSE → 浏览器 定时任务 → runtime 执行任务 → ExecutionResult → 调度记录 批处理 → runtime.execute → ExecutionResult → 调用方处理 ``` `runtime/` 统一处理准入、会话互斥、Agent 事件、取消、超时、审批、持久化和计费;不导入 `api`、FastAPI 或 Starlette。API 层保留请求参数、HTTP 错误转换和 SSE 编码,现有客户端字段不变。 ### 非 HTTP 调用 独立脚本使用公共运行环境;以下示例保存为项目根目录的 Python 文件后即可运行: ```python import asyncio from db import chat_store from runtime.environment import RuntimeEnvironment from runtime.execution import execute async def main(): async with RuntimeEnvironment(): session = await asyncio.to_thread(chat_store.create_session, "report-user") result = await execute( user_name="report-user", session_id=session["id"], message="生成本次报告", kind="batch", graph_config={"recursion_limit": 30}, ) if result.status == "success": print(result.output) elif result.status == "waiting_input": # 保存任务状态,等待用户确认。 print(result.interrupts) else: print(result.error) if __name__ == "__main__": asyncio.run(main()) ``` - `execute()` 复用会话归属校验、额度及并发闸,最后释放资源。已有审批未处理时拒绝开始新任务。 - 返回 `ExecutionResult`:`run_id`、`session_id`、`status`、`output`、`error`、`usage`、`citations`、`interrupts`。 - 准入失败抛异常;运行中失败返回 `status="failed"`,取消则继续向调用方传播取消异常。 - 流式消费者使用 `admit()`、`build_execution()` 和 `Execution.events()`;事件为字典,使用 `contextlib.aclosing()` 包住迭代,提前结束时也要关闭。若尚未开始迭代,则由调用方释放 `admission`。 - 审批恢复使用 `runtime.interrupts.ResumeDecision` / `resume_value()`,并在持有会话锁期间读取和校验最新图状态,再将结果作为 `resume_payload` 交给 `build_execution()`。 - 每个 `Execution` 只能消费一次;`run()` 消费事件并清理后返回结果,`events()` 供逐条消费,不能对同一个对象重复调用。 ### 公共运行环境 `RuntimeEnvironment` 负责加载和校验扩展、初始化业务库与计费单价、标记上次意外中断的执行、打开 checkpoint,并在后台预热 Agent、重试待结算流水。退出时停止可选调度器,等待维护和编译完成,清除 Agent 缓存,关闭扩展服务、checkpoint 与业务库连接。启动失败或调用方取消时同样清理。 | 参数 | 默认值 | 用途 | |---|---|---| | `scheduler` | `False` | 网页服务传 `True`;脚本默认不触发定时任务 | | `prewarm` | `True` | 后台编译 Agent;关闭后首次执行时编译 | | `billing_retry` | `True` | 后台重试待结算记录;测试可关闭,本轮执行仍正常计费 | | `recover_interrupted` | `True` | 将上次残留的运行中记录标为中断;自检时关闭 | - `main.py` 与自检脚本已使用这一入口。网页请求内部直接调用执行层,不要为每个请求创建环境。 - 同一进程仅允许一个活动环境;嵌套或同时启动会报错。退出后可以重新进入,会重新建立连接和 Agent 缓存。 - 并行批处理应在环境内部使用 `asyncio.TaskGroup()`;退出前必须等待自己的任务完成,或取消并等待其收尾。 - 沿用单进程部署约束。独立脚本使用单独的数据配置,或先停止共享同一套数据的服务;关闭恢复选项也不代表支持多进程共享并发锁。 - 集成测试可以使用 `RuntimeEnvironment(prewarm=False, billing_retry=False, recover_interrupted=False)`,并在导入模块前设置测试数据库与 checkpoint 路径。配置仍按进程加载,环境重入不会重新读取 `.env`。 --- ## 日志与排查 继续使用 `LOG_LEVEL`(默认 `INFO`)与 `LOG_FORMAT`(`text` / `json`)。业务日志和 Uvicorn 日志使用相同格式、毫秒时间戳与时区;发布脚本也读取配置中的日志级别。输出仍走标准输出,部署脚本写入 `shared/logs/server.log`,systemd 使用 journal。 | 级别 | 内容 | |---|---| | INFO | 运行环境/扩展服务就绪、执行开始与结束、计费、业务请求汇总 | | WARNING | 额度不足、取消、4xx、估算用量等需要留意的情况 | | ERROR | 执行或收尾失败、5xx、落库与结算异常;关键异常保留堆栈 | | DEBUG | 并发位申请释放、缓存失效、健康检查及静态资源成功请求 | 常见第三方 HTTP 客户端、数据库驱动和调度器内部日志仍保持 WARNING,避免调试业务时输出大量底层细节。 ### 按一次执行定位问题 1. 从响应头 `X-Trace-Id` 或 SSE 事件取得 `trace_id`。也可以传入由字母、数字、点、下划线、冒号和连字符组成的 1–128 位 ID;非法值会替换为新 ID。 2. 按该 ID 搜索日志,再按 `run_id` 区分具体执行、按 `session_id` 关联会话。定时任务还包含 `schedule_id`,其调度记录 ID 为 `schedule_run_id`。 3. 查看 `execution.completed` 的 `status`、`duration_ms`、输入/输出 token 和 `error_code`,异常详情看同一 trace 下的堆栈。 `http.request.completed` 在响应流和应用清理结束后记录一次,因此 SSE 耗时包含整个连接过程。HTTP 200 只代表流已建立,Agent 是否成功要看 `execution.completed`。健康接口当前可能以 HTTP 200 返回 `status=down`;日志降噪按 HTTP 状态码判断,监控仍需检查响应内容。 独立批处理自动生成 trace;定时执行和后台结算重试也会携带关联 ID。并发请求的上下文互相隔离,传到 `asyncio.to_thread()` 的数据库/计费日志沿用同一上下文。 JSON 日志可这样筛选(服务器装有 `jq` 时): ```bash jq -c 'select(.trace_id == "这里填写trace_id")' ~/vent-agent/shared/logs/server.log jq -c 'select(.event == "execution.completed" and .status == "failed")' ~/vent-agent/shared/logs/server.log ``` 以上命令适用于文件内容均为 JSON 的情况;切换格式前留下的文本日志可用 `rg 'trace_id值' 文件路径` 搜索。生产文件的保留与轮转由部署环境管理,本次没有新增第二份应用日志文件。 ### 扩展模块如何记日志 ```python from obs import get_logger logger = get_logger(__name__) logger.info("检索完成", extra={"kv": { "event": "rag.search.completed", "hits": 5, "duration_ms": 38, }}) ``` `kv` 在文本和 JSON 中都会输出。`ts`、`level`、`logger`、`trace_id`、`message`、`exc` 是保留字段,不能通过 `kv` 覆盖;当前绑定的执行身份优先。异常处理使用 `logger.exception()` 保留堆栈。 请求日志不记录正文、查询参数、Cookie 或 Authorization;任务摘要不记录提示词、回答和工具参数。框架输出会遮蔽结构化凭证字段,以及消息/堆栈中常见的密钥赋值、Bearer 凭证和 URL 用户密码。扩展仍应只记录排查所需元数据,避免把整段业务数据或客户端配置直接写入日志。 --- ## 扩展方式 ### 新增业务 Agent 在 `extensions/<业务名>/` 下添加 `__init__.py` 和 `extension.py`。启动按目录名排序,自动加载每个 `extension.py` 的 `setup(registry)`,不需要修改工具模块列表、`agent_factory.py` 或主提示词。 例如 `extensions/ventilation/extension.py`: ```python from extensions import define_tool @define_tool(group="通风", label="计算风量", name="calculate_airflow") def airflow(area: float, speed: float) -> float: """根据断面积和平均风速计算风量,返回立方米每秒。 Args: area: 断面积,单位平方米,必须大于零。 speed: 平均风速,单位米每秒,不能为负。 """ if area <= 0 or speed < 0: raise ValueError("断面积必须大于零,风速不能为负") return area * speed def setup(registry): registry.register_agent( name="ventilation", description="处理矿井通风量计算;需要用断面积和风速求风量时委派。", prompt="你是通风计算助手。先确认单位和输入,调用工具计算,说明结果单位。", tools=[airflow], ) ``` `define_tool` 只声明元数据,不在导入时注册,函数可以直接单测。`setup()` 收集声明,全部通过校验后统一生效;不要在其中访问外部服务或执行业务操作。 - `description` 自动加入主 Agent 的委派说明;`prompt` 是子 Agent 的系统提示词,不继承父级提示词。 - `tools` 支持函数或已注册工具名。函数自动注册;字符串引用在所有扩展加载完成后校验,允许跨模块引用。 - 所有注册工具都会提供给主 Agent;子 Agent 只绑定自己的工具列表及框架内置能力。 - 只加工具时,在 `setup()` 调用 `registry.register_tool(airflow)`。已有工具传给多个 Agent 时会复用同一个注册名。 - `name` 为实际工具调用名,可以与 Python 函数名不同;异步工具使用 `async def`。 - 提示词也可以放在业务目录的 Markdown 文件中,通过 `Path(__file__).with_name("prompt.md").read_text(encoding="utf-8")` 读取;发布包会包含这些文件。 内置示例见 `extensions/demo/extension.py`;`extensions/core/extension.py` 注册计划审批和询问用户工具,主提示词与恢复流程依赖它们,需保留。 ### 扩展服务与中间件 RAG 客户端、记忆存储等共享资源通过 `register_service()` 声明,由 `RuntimeEnvironment` 启动和关闭。模型调用前后处理通过 `register_middleware()` 接入,无需修改主入口和 Agent 工厂。 完整离线示例见 `extensions/context_demo/extension.py`:共享术语表 → 检索工具 → 模型调用前补充上下文。在 `.env` 添加 `CONTEXT_DEMO_ENABLED=1` 后重启即可体验;默认关闭,不访问外部服务。这只是接口示例,真实知识库的索引、长期记忆存储和上下文预算仍需业务模块实现。 扩展可提供三个同步入口: ```python def enabled(): return config.get_bool("MY_RAG_ENABLED", False) def validate_config(): # 仅检查配置,不开连接;抛 ValueError 会阻止启动。 if not config.get_str("MY_RAG_URL", ""): raise ValueError("启用 RAG 时必须配置 MY_RAG_URL") def setup(registry): registry.register_service("rag_client", open_rag_client) registry.register_tool(search_knowledge, requires=["rag_client"]) registry.register_middleware( "RecallMiddleware", make_recall_middleware, requires=["rag_client"], targets=["main", "researcher"], ) ``` 以上是接口示意;`researcher` 必须由当前或其他启用的扩展注册。`enabled()` 和 `validate_config()` 都可省略;省略开关默认启用。关闭模块会跳过配置校验与 `setup()`,因此也不会注册其工具、服务或中间件。模块本身仍需导入才能读取开关,可选第三方依赖应放到服务工厂或中间件工厂内延迟导入。 **服务工厂**采用异步上下文管理器: ```python from contextlib import asynccontextmanager from extensions import get_service @asynccontextmanager async def open_rag_client(dependencies): # Client 为所选知识库 SDK 的客户端,在这里延迟导入。 client = Client(config.get_str("MY_RAG_URL")) try: yield client finally: await client.aclose() async def search_knowledge(query: str) -> dict: """检索业务知识库,返回片段与来源。""" client = get_service("rag_client") return await client.search(query) ``` - 工厂参数是只读映射,仅包含 `requires` 声明的服务。服务可以依赖其他服务,按依赖顺序启动,逆序关闭。 - 工具在调用时通过 `get_service()` 获取资源;不要在导入或 `setup()` 阶段取服务。有服务依赖的工具先显式注册,再用名称或函数绑定到子 Agent。 - 服务开始初始化后,即使调用方取消,也会等待初始化收尾再清理。工厂在 `yield` 前失败时,必须在自己的 `try/finally` 中释放已申请的资源。 - 服务是进程内共享实例。异步客户端应由异步工具在同一事件循环中调用;用户、会话相关数据需要明确作用域,不能直接存进共享实例或中间件字段。执行身份可从工具的 `ToolRuntime.config["configurable"]` 中读取 `workspace_user`、`workspace_session`。 - 环境退出会等待调度、预热和计费任务,清除 Agent 缓存,然后关闭扩展服务、checkpoint 和业务库;独立脚本创建的任务仍需自行等待完成。 **中间件工厂**使用同步函数 `factory(services)`,返回一个 `AgentMiddleware`,实例的 `name` 必须与注册名一致。 - `targets` 默认 `["main"]`;可填写已注册子 Agent 名称。没有隐式继承,不支持向框架自动创建的 `general-purpose` 注入。 - `requires` 声明需要使用的服务。模型或工具包装方法应实现对应的异步钩子,框架实际通过异步路径执行。 - `before=["OtherMiddleware"]`、`after=["OtherMiddleware"]` 控制扩展中间件列表顺序;引用必须存在并有共同目标。没有顺序约束时按名称稳定排序。 - 对 `wrap_model_call`,列表靠前者在外层:进入时按顺序,返回时逆序。它们仍位于框架内置中间件之后;主 Agent 的动态时间中间件位于扩展之前。 - 启动时会试构造一次,Agent 编译时为每个目标重新构造。因此工厂必须无副作用,每次返回新实例;连接、后台任务应由服务管理。Agent 本身为单例,中间件不能在实例字段中保存某次请求的状态。 - 框架内置中间件名称被保留,重复摘要/权限/审批中间件会被拒绝。默认压缩行为保持原样。后续自定义压缩应使用独立名称并显式关闭内置压缩(`CTX_COMPACT_ENABLED=0`),同时自行保留历史归档与工具调用配对等约束;当前没有通用的内置中间件替换接口。 `GET /api/model` 的 `agent` 对象会列出 `services`、有序的 `middleware` 及其目标,以及 `disabled_extensions`;不返回服务实例和连接配置。修改扩展或启用开关后重启服务。 ### 声明审批 有写入或外部副作用的工具使用 `@define_tool(..., requires_approval=True)`。也可以在 Agent 定义中设置 `approval_tools=[工具函数或名称]`,但必须包含在该 Agent 的 `tools` 中。 **审批要求按工具合并:** 任意扩展要求某个工具审批后,主 Agent 和所有使用该工具的子 Agent 都会先等待批准。`HITL_TOOLS` 环境变量可以追加工具名;拼写错误会阻止启动。 ### 校验与生效 启动会在数据库初始化、调度器启动之前检查: - 工具和 Agent 名称格式、重名、与框架内置名称冲突。 - 工具函数能否生成参数 schema,是否提供 docstring 或 summary。 - Agent 描述和提示词非空,工具引用有效,审批工具确实绑定。 - 服务依赖、中间件目标及顺序引用有效,没有循环、重名或内置名称冲突。 - 启用模块的配置校验、`setup()` 缺失或加载失败、目录/入口符号链接等问题。 数据库与 checkpoint 初始化后,按依赖启动扩展服务,再验证中间件工厂的实际返回类型与名称;失败会释放已启动资源,不进入调度与预热阶段。 新增或修改扩展后**重启服务**,再用 `GET /api/model` 查看 `agent.extensions` 和子 Agent 列表,用 `GET /api/model/tools` 查看工具。`/api/model/reload` 只重建已加载定义,不重新导入业务代码。 开发时运行: ```bash .venv/bin/python -m pytest .venv/bin/python scripts/selfcheck.py ``` 自检使用当前数据库配置,建议在开发环境执行。扩展代码及 `.md/.txt/.json/.yaml/.yml` 资源自动进入发布白名单;其他资源类型需调整 `release/build.py`。 旧 `tools.registry.register` 保留兼容;新业务统一使用 `define_tool` 与 `setup(registry)`,避免导入时修改全局注册状态。 ### 来源标注和技能 工具需要返回引用来源时,使用 `tools.registry.sourced(data=..., sources=[source(...)])`,SSE 层自动生成引用事件。 技能仍放在 `skills//SKILL.md`,带有 `name`、`description` 的 YAML frontmatter。用户可以用 `#skill:` 指定技能;技能是操作手册,不会替代工具注册。 --- ## 配置 全部配置项见 `.env.example`,每项都有默认值。几个容易搞错的: | 变量 | 说明 | |---|---| | `DEEP_AGENT_ENV_PRIORITY` | `.env` 与进程环境谁优先。`dotenv`(默认)/ `process` / `none`。**跑变体实例用 `process`**:`DEEP_AGENT_ENV_PRIORITY=process DB_DIALECT=mysql PORT=8081 python main.py` | | `DB_DIALECT` | `sqlite`(默认,业务库无需外部服务)/ `mysql`(旧部署显式兼容) | | `DB_NAME` | MySQL 库名;库不存在时启动会自动建库,不需要手工 `CREATE DATABASE` | | `CONTEXT_LIMIT` | 声明的上下文窗口,**压缩的唯一调节旋钮**。0 = 用模型登记值 | | `OPENAI_MAX_RETRIES` | 模型 API 的 SDK 退避重试次数,默认 8,不含首次请求;0 禁用。针对 429 等临时错误重试请求,已开始的流不重放;修改后重启 | | `CTX_COMPACT_ENABLED` | 关掉会同时失去历史落盘能力,长会话会超窗报错 | | `CHECKPOINTER` | `sqlite`(默认,落盘)/ `memory`(重启丢图状态) | | `HITL_TOOLS` | 追加需要人工审批的工具名,逗号分隔。如 `calculate` 便于自测中断闭环 | | `BILLING_ALLOW_UNPRICED` | 模型未定价时是否按 0 计费。**默认 0 = 直接报错**(fail-closed) | | `TFAGENTS_DAILY_TOKEN_LIMIT` | 每用户每日 token 限额,0 = 不限 | **配置冲突会被大声报出来**:若 `.env` 与进程环境里有同名但不同的值,启动 日志会打 WARNING(含脱敏后的生效值),`/health` 的 `config_conflicts` 也能 看到。这解决的是一个很费时间的现象:改了配置却不生效,却没有任何提示。 --- ## 验证 ```bash # 离线单元 + SSE 契约测试(不需要密钥,不需要数据库服务) python -m pytest # 结构自检:导入、建表、agent 编译、工具注册 python scripts/selfcheck.py # HTTP 层端到端(需服务已启动 + 可用密钥) python scripts/http_e2e.py ``` `http_e2e.py` 覆盖:SSE 帧格式与响应头、落库与顺序、错误语义 (404/400/409)、**中断/恢复闭环**、定时任务的手动触发与 cron 校验。 --- ## 目录结构 ``` main.py FastAPI 入口(lifespan / 健康检查 / 静态挂载) config.py 配置唯一入口(.env 读取与冲突检测) api/ HTTP 参数、路由与协议适配 sse_events.py SSE 编码(保留前端字段) sse_core.py 事件转 SSE、断连关闭内层生成器 algorithms/ 独立算法源码及调用封装(特性曲线识别等,见 algorithms/README.md) runtime/ 公共生命周期与传输无关的执行层 execution.py 准入、装配、execute() 非 HTTP 入口 engine.py ★ 事件流、运行结果、落库与收尾 events.py ★ 统一事件字段 interrupts.py 审批载荷、校验与恢复决策 concurrency.py 会话互斥与并发限制 context_meter.py 用量计量 source_tracker.py 来源聚合 subagent_tracker.py 子 Agent 跟踪 agents/ agent_factory · agent_registry · model_factory · prompts · middleware · agent_memory extensions/ registry · core/extension.py · demo/extension.py(统一业务扩展) tools/ registry(工具元数据与转换)· demo_tools(示例工具) skills/demo-skill/ 示例技能 db/ db_conn(双方言)· mysql_schema.sql · schema_sqlite.sql · 三个 store billing/ pricing · guard(预检)· meter(入账) scheduler/ scheduler(APScheduler)· runner(独立会话执行) obs/ log_setup · tracing(trace_id) frontend/ Vue单文件组件、流式客户端与工作台测试 static/index.html Vue生产构建入口;static/ui为打包后的本地资源 tests/ pytest(离线单元、HTTP 与真实图引擎回归) scripts/ selfcheck.py · http_e2e.py · start_server.sh ``` 标 ★ 的两个文件是核心,改动时同步检查 SSE 契约与流式执行测试。 --- ## 执行可靠性与隔离 - **统一准入**:聊天、恢复和定时执行共用额度预检、并发闸、会话互斥、总超时、计量与收尾。并发满返回 429;同会话冲突返回 409。 - **运行状态**:`execution_runs` 保存 `running / waiting_input / success / failed / cancelled / interrupted`。中断只表示等待输入,不发送完成事件;启动时将崩溃遗留的 running 标记为 interrupted。 - **请求去重**:聊天可提交 `request_id`(1–128 字符)。相同用户的相同键不会再次运行,返回 409 和原会话、运行 ID;不重放旧 SSE。新任务应使用新键。 - **取消收尾**:断连时停止消费图,保存已收到的部分回答、结算已观测用量,并释放占位。定时任务停机时等待取消收尾后再关闭 checkpoint。 - **持久化结算**:同一流水使用稳定 ID,写账与累计额度在事务中完成。待结算数据保存在 `billing_pending`,服务启动后及每分钟重试。数据库完全不可写时仍只能日志告警;进程被强杀前尚未观测到的 provider 用量需要外部账单对账。 - **文件隔离**:虚拟 `/workspace/`、`/conversation_history/` 映射到 `data/workspaces/<用户哈希>/<会话哈希>/`。子智能体共享其父会话目录,其他会话不可见;`/skills/` 共享只读。 - **定时任务**:采用同一执行流程,注入模式和用户偏好;cron 与手动触发共用计划互斥,等待审批的运行记录为 `waiting_input`,从聊天恢复后同步结果状态。cron 显式使用 Asia/Shanghai。 ### Agent 人机交互 - `ask_user(questions)`:提出一至三个必要问题并暂停,前端显示输入框;刷新页面可恢复问题卡。 - `request_plan_approval(plan)`:提交计划等待确认。plan 模式提示词会准确指向这个工具。 - 批量工具审批卡完整展示每项工具及参数,批准按钮明确写出批次数量。 - 审批卡带 `interrupt_token`;恢复接口必须原样携带。服务端在会话锁内核对当前中断,过期、缺失和重复确认返回 409。 - 业务工具可在 `@define_tool(..., requires_approval=True)` 声明强制审批,组装时自动进入框架 HITL。plan 提示词本身不构成所有工具的硬权限边界,真实写操作应声明强制审批。 - 子智能体和摘要模型的用量计入总费用,其内部正文不混入主回答,也不覆盖主线程上下文占用。 ### 升级已有部署 启动自动增建 `execution_runs`、`billing_pending`、`request_keys`,不会删除旧表。 修改前端后执行 `cd frontend && npm run build`,同步发布 `static/index.html` 和 `static/ui/`;自定义客户端需补传 `interrupt_token`。定时任务的详情、修改、删除、运行接口需携带其归属 `user_name`。 旧的共享 `workspace/`、`conversation_history/` 原样保留,**不会自动猜测文件归属或复制到新目录**。 旧会话需要的附件可重新上传;需要迁移的历史文件应先确认用户和会话归属。 未登记的新模型现在会在执行前拒绝请求,不再自动写入零价;既有单价记录不会被覆盖。 ## 已知缺口 **当前只适合内网或开发环境**,主要因为: 1. **没有鉴权** —— `user_name` 由客户端自报,可伪造。计费与会话归属都建立在 这个值上。需要为所有路由接入统一身份依赖,并区分普通用户与管理操作。 2. **单进程** —— SQLite checkpointer、并发计数、调度器都是进程内的。多 worker 会导致会话状态分裂、定时任务重复触发。 3. **并发计数不跨进程** —— 多进程部署需挪到 Redis/MySQL。 尚未提供业务工具/技能、MCP 接入及 `remember`;默认标题使用文本截断。