OnWorks Agent 任务原理
基于 OnWorks work_desk 封装整理。Codex CLI 的请求结构、工具循环、rollout、沙盒和原生压缩见 onworks 和 codex 对比.html。
1. 总链路
浏览器 query
↓ POST /tasks
OnWorks FastAPI
├─ MySQL: sessions / tasks / task_files
├─ 后台线程 _run_task
├─ 组装 stdin prompt
↓
codex exec --json --ephemeral
├─ 组装 Responses API 请求
↓
LLM provider(SSE)
↓
Codex agent loop(执行本地工具)
↓ stdout JSONL
OnWorks FastAPI
├─ MySQL: tasks.steps / tasks.result / task_files / usage_records
↓
浏览器 GET /tasks?sessionId=...
OnWorks 不使用 codex exec resume,也不保存 thread_id。 每个任务轮次都是短命进程。文本连续性由 OnWorks 数据库历史拼回新 prompt;文件连续性由同一会话共享的 scratch 目录保留。
2. 完整测试任务
假设用户在会话中发送:
sessionId = s-123
requirement = 把 sales.xlsx 按月份汇总,生成 summary.xlsx
files = [sales.xlsx]
2.1 创建任务:MySQL 初始写入
POST /tasks 先保存原始请求,再启动后台线程:
-- tasks
id = t-456
user_id = u-001
session_id = s-123
title = 把 sales.xlsx 按月份汇总,生成 summary.xlsx
requirement = 把 sales.xlsx 按月份汇总,生成 summary.xlsx
status = processing
progress =
steps =
result =
error =
model = <active model>
-- task_files
id = f-input-1
task_id = t-456
filename = sales.xlsx
stored_name = 9f8e7d6c.xlsx
content_type = application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
kind = spreadsheet
size = 20480
role = input
原始文件保存在磁盘任务目录,task_files.stored_name 只保存文件名映射。此时还没有 token 计费。
2.2 OnWorks 组装给 Codex 的 stdin prompt
_run_task() 读取同会话聊天消息和已完成任务,排除当前任务,转成 Markdown transcript。下面是本轮 stdin 的完整形状:
你是 onworks AI 助手——这个工作台自带的 AI 助手。用户无论是在「文本」里和你对话,还是把一份长程任务交给你执行,你都是同一个 onworks AI 助手:始终以这个身份回答,不要用别的名字介绍自己,也不要提及背后的模型、厂商或技术细节;被问到你是谁时,就说明你是 onworks AI 助手。回答直接、具体、可用:先给结论或结果,再给必要的说明;资料里没有的、你也不确定的内容就直说,不要编造数据、来源或引用。使用与用户相同的语言作答。
# 对话历史(同一对话中此前的轮次,最近的在后)
## 用户
上周我们讨论过销售报告口径,月度汇总用订单创建日期。
## 助手
好的,后续报告按订单创建日期统计月份。
# 任务要求
把 sales.xlsx 按月份汇总,生成 summary.xlsx
# 附件资料(已放在当前工作目录)
1. sales.xlsx(Excel,20480 字节)→ ./attachments/t456ab12/01-sales.xlsx
已解析的纯文本:./attachments/t456ab12/01-sales.xlsx.txt
# 交付要求
- 需要计算、核对或转换时,直接用当前目录下的文件与 shell 工具完成,不要凭印象估算。
- 最终回复用 Markdown 直接给出可交付内容,不要复述任务本身。
- 回复里不要出现任何文件路径(不要写 output/… 或 work/…):用户不需要知道文件存在哪里,系统会在回复下方自动列出交付文件供预览和下载。你只需在回复里说明做了什么和结果要点。
- 不要修改或删除 attachments/ 目录里的原始附件。
- 如果任务要求交付文件(修改后的文档、生成的表格/代码等),把用户点名要的成品写进当前目录下的 output/ 目录,文件名沿用用户要求的名称;系统会自动把它列给用户。回复正文不要提这个路径。
- 系统 Python 已装好以下库(`import` 即可用,不要 pip install):python-docx(Word 读写)、openpyxl(Excel 读写)、weasyprint(HTML 转 PDF)。需要其他包时用 `pip3 install --break-system-packages 包名` 装到系统(所有对话共享,装一次就行),不要往当前工作目录里装——那里的包只对这一次任务可见,下次对话又得重新下载。
- 交付文件只放用户要的成品,通常就一个。同一内容的其它格式(表格旁的 .csv 核对副本)、支撑图表的数据、对照表、脚本、临时文件等一切中间产物都放进当前目录下的 work/ 目录,不要当成交付文件——成品被自己的副产物淹没,用户就得在一堆过程文件里找交付物。已经把图表页、核对副本写进 output/ 也没关系,系统会按它们的角色归入「中间产物」,照样可预览可下载,只是不列进「交付文件」。
- Markdown 不算交付文件(正文直接在最终回复里用 Markdown 给出即可):.md 不会被列为产物,草稿放在 output/ 之外。
- 需要图表或可视化时,额外产出一个自包含的 output/report.html(数据内嵌,图表库内联或走 CDN 都行):系统会把它当页面直接渲染出来,并归入「中间产物」;原始数据导出一份 csv 放进 work/ 备查,只有用户明确要一份数据文件时才放进 output/。图表由浏览器里的 JS 渲染,不需要在沙箱里安装浏览器、截图或校验渲染效果;也不要为了出图去装一堆依赖——能内联的库直接下载,其余用标准库。
# Excel 表格
- 本轮的表格附件:sales.xlsx。
- 每张表都已解析成同名的 .txt(按工作表分开,用制表符分列):数值、日期和布尔值都按 Excel 里显示的样子给出,读取内容以它为准,不要凭文件名猜。
- 解析文本若被截断,末尾会写明「本表共 N 行,此处只提供前 …」;这时用 shell 工具直接读 attachments/ 里的原文件补齐,不要用半张表做统计。
- 需要交付表格时,把成品写进 output/:用已装好的 openpyxl 直接生成,并同时导出一份 .csv 方便用户核对与二次计算(这份 .csv 只是核对副本,不是交付文件,系统会归入「中间产物」)。
- attachments/ 里的原表不要改动。
- 使用与任务要求相同的语言作答。
2.3 OnWorks 启动 Codex 的具体命令
外层还有 mount namespace 隔离,真正执行的 Codex 命令近似:
codex exec \
--json \
--ephemeral \
--skip-git-repo-check \
-C /tmp/.onworks/work \
-s workspace-write \
-c approval_policy="never" \
-c sandbox_workspace_write.network_access=true \
-m "$MODEL" \
-
- 表示 prompt 从 stdin 读入。外层 unshare 把真实目录 backend/app/media/work/s-123/work bind 到 /tmp/.onworks/work,并把 CODEX_HOME bind 到受限视图。
2.4 文件处理发生在拼 prompt 前
是的。Agent 模式的顺序是:读取 task_files → 调 _codex_workdir() → 复制和抽取文件 → 生成 attachment notes → 调 _codex_prompt()。所以 stdin prompt 里的文件说明已经包含解析结果;Codex 看到任务时,scratch 目录里已经有原文件和 .txt 副本。
上传/创建任务:
backend/app/media/tasks/t-456/9f8e7d6c.xlsx
↓
_codex_workdir() 复制到会话工作目录:
backend/app/media/work/s-123/work/attachments/t456ab12/01-sales.xlsx
↓
documents.extract() 解析并写纯文本:
attachments/t456ab12/01-sales.xlsx.txt
↓
把文件路径/解析状态写进 notes
↓
_codex_prompt(task, notes, history)
各类型处理规则:
| 类型 | 解析器 | 抽取结果与截断 |
|---|---|---|
.docx | 读 zip 里的 Word XML,包括正文、文本框、页眉页脚 | 提取成多行文本;.doc 旧二进制不解析。结果进入 prompt 前最多 30,000 字符,超长写「…(内容过长已截断)」。 |
.xlsx/.xlsm/.xltx/.xltm | openpyxl 按工作表读 | 输出 ## 工作表名 和制表符分列的行;prompt 侧每文件最多 30,000 字符。模型仍可用 shell/openpyxl 读原文件补齐。 |
.xls | 安装了 xlrd 才解析;否则只传文件名并说明原因 | 解析成功后同样截断到 30,000 字符。 |
.pdf | pypdf 读文本层;没有文本层时用 pdftoppm + tesseract OCR | 文本层最多读 80 页,OCR 最多 40 页;结果进入 prompt 前仍受 30,000 字符截断。 |
.pptx | 读演示文稿 XML | 提取成多页文本;旧 .ppt 不解析。 |
| 文本/代码/CSV/JSON/HTML 等 | 直接按文本解码 | 同样进入 30,000 字符截断。 |
| 图片/其他二进制 | 不解析正文 | 只按文件名和「解析失败原因」参与 prompt。 |
2.5 Agent 任务涉及的 MySQL 表
按一次 POST /tasks 的完整生命周期算,Agent 任务通常涉及 7 张表。更精确地说:
| 阶段 | 表 | 动作 | 为什么需要 |
|---|---|---|---|
| 鉴权 | users | 读 | 从 JWT subject 找到当前用户,并读取 is_admin 判断是否绕过额度。 |
| 额度检查 | usage_records | 读 | 统计今天 kind in ("chat","image") 的次数;task 本身还没写,所以要靠已有账本判断额度。 |
| 定位会话 | sessions | 读 | 确认 sessionId 存在且属于当前用户。 |
| 创建任务 | tasks | 写 | 保存任务要求和 processing 状态。 |
| 保存附件 | task_files | 写 | 每份上传文件一行,role=input。 |
| 历史拼装 | messages | 读 | 读取同会话普通聊天轮次。 |
| 历史拼装 | tasks | 读 | 读取同会话已完成/失败任务,作为 assistant 侧历史。 |
| 执行中/结束 | tasks | 写 | 更新 progress、steps、status、result 或 error。 |
| 保存产物 | task_files | 写 | 把交付文件和中间产物落成 role=output/intermediate。 |
| 记账 | usage_records | 读 + 写 | 额度检查时读;任务完成后写一条 kind=chat、ref_id=task_id 的账目。 |
| 额度被拒(异常) | quota_blocks | 写 | 记录一次被 429 拒绝的尝试;成功任务不写这张表。 |
所以可以总结成三种口径:
- 成功任务写入:3 张 —
tasks、task_files、usage_records。 - 成功任务涉及:6 张 — 上面 3 张加上
users、sessions、messages。注意messages一定被查询;如果没有聊天历史,查询结果为空。 - 额度被拒涉及:7 张 — 再加
quota_blocks。此时通常不会创建任务、保存附件或运行 Codex。
长历史的压缩摘要默认写在文件 media/work/<session_id>/.compaction.json,不是 MySQL 表。如果部署关闭压缩,历史超出窗口就只加省略说明。
-- sessions:对话容器,只读
id 会话 id
user_id 归属用户
project_id 可选项目 id
title 对话标题
created_at 创建时间
updated_at 更新时间
-- messages:普通聊天历史,只在拼 prompt 时读
id 消息 id
session_id 所属会话
role user / assistant
content 消息正文
created_at 创建时间
-- tasks:一次 Agent 执行
id 任务 id
user_id 归属用户
session_id 任务挂到哪个会话;可空
title 列表/时间线标题
requirement 用户原始任务要求
status processing / completed / failed / cancelled
progress 执行中最近一条提示,例如「排队中...」
steps JSON 数组:UI 需要的思考/命令/写文件摘要,不是原始 JSONL
result 最终 agent_message 清洗后的 Markdown
error 失败原因
model 当时使用的模型
created_at 创建时间
updated_at 状态变更时间
-- task_files:输入附件 + 输出产物 + 中间产物
id 文件行 id
task_id 所属任务
filename 展示文件名
stored_name 磁盘上的随机文件名;不暴露原始路径
content_type MIME 类型
kind word / pdf / other;注意 Excel 是 other
size 字节数
role input / output / intermediate
created_at 入库时间
-- usage_records:计费/额度账本
id 账目 id
user_id 归属用户
kind chat / image / internal
model 模型名
input_tokens 输入 token
output_tokens 输出 token
quantity 默认 1
amount 计算后的金额
ref_id 回链到任务 id
created_at 记账时间
-- quota_blocks:额度拒绝埋点,正常成功任务不写
id 拒绝记录 id
user_id 被拒用户
surface task / chat / image
created_at 时间
关键点:task_files.kind 只有三类,不细分 Excel;Excel/CSV/PPT 等靠 filename 扩展名识别。role 才决定它出现在输入附件、交付文件还是中间产物。
3. Prompt 位置与缓存
当前 OnWorks 把任务所需的业务内容拼成一份 stdin prompt。Codex CLI 会把它作为 Responses API input 里的最后一条 user message;Codex 自己的开发者上下文和环境上下文在前,OnWorks 的业务信息不在外层 instructions 里。
- 同一次
codex exec内的多次模型请求:可能命中。Codex 使用同一个prompt_cache_key,并且下一轮 input 保留上一轮的前缀,再追加 function call 和 tool output。 - 跨 OnWorks 任务轮次:不太可靠。每轮都是新的
codex exec --ephemeral,新的 thread 通常有新的prompt_cache_key;OnWorks 重建的历史、当前 query 和附件注记也使完整 prompt 变化。 - 真实命中与否要看
turn.completed里的cached_input_tokens,不能只凭“前缀看起来一样”推断。
值得优化,但不一定值得立刻改。如果把身份、输出格式、交付规则这类真正静态的内容放到稳定的 developer instructions,并把动态历史、当前 query、附件注记放在后面的 user prompt,静态前缀更容易形成稳定 cache 前缀。历史和 query 不应该放进 instructions,否则它们会破坏这个前缀,还会混淆“固定规则”和“本次输入”。
当前实现里,persona.BASE 是静态前缀,但后面紧跟动态对话历史;交付要求又出现在动态内容之后。也就是说,较长的静态规则没有处在完整 prompt 的稳定前缀位置。要优化时,应该把静态规则放在最前,动态内容放最后;同时不要把长文档塞进 instructions。更稳妥的做法是用 codex exec 的真实请求和 cached_input_tokens 做对比后再改。
4. 任务标题与 query
tasks.requirement 来自前端创建任务时提交的表单字段 requirement(也就是用户在任务输入框里填的 query)。FastAPI 的 POST /tasks 用 requirement: str = Form("") 接收,校验不超过 50,000 字符后,保存的是 requirement.strip()。所以它是本轮用户原始任务要求的完整正文,不是从标题、附件或模型推导出来的。
tasks.title 是列表/时间线的展示标题,不是模型生成的。创建任务时,_make_title() 取 requirement 的第一个非空行,最多截断 60 个字符;如果 requirement 为空,就取第一个上传附件的文件名,也最多 60 个字符;仍然没有内容时使用「未命名任务」。
requirement = "把 sales.xlsx 按月份汇总\n顺便生成图表"
title = "把 sales.xlsx 按月份汇总"
完整 query 不等于标题。tasks.requirement 保存完整的用户任务要求,上限是 MAX_MESSAGE_CHARS = 50,000 字符;很短的 query 会保存原文,很长的 query 也保存原文,只是 tasks.title 另外截成 60 字符。如果 query 超过 50,000 字符,创建接口先返回 413,不会落库执行。
5. 文件读取与裁剪
OnWorks 有两条路径,裁剪规则不同:
| 路径 | 处理 |
|---|---|
| 普通 LLM 任务 | 调用 complete_llm();每个解析文件最多 ONWORKS_PER_FILE_CHARS(默认 30,000)字符,所有附件合计另受 ONWORKS_ATTACH_CHARS(默认 120,000)字符限制。模型只看到解析文本,看不到原文件。 |
| Codex Agent 任务 | 每个解析成功的附件生成一个 .txt 旁路文件,单个解析文本最多 30,000 字符;没有叠加 120,000 总限制。原附件也复制进会话 scratch 目录,Agent 可用工具读取原文件补齐。 |
所以「30,000 字符」不是只在解析失败后才生效。只要 Codex 路径成功提取了正文,就会先截断这 30,000 字符作为快捷上下文。解析失败时,OnWorks 不让任务失败,而是在附件注记中写明原因,并提示 Agent 用 shell、Python 等工具自行读取原文件。历史也有独立预算:Agent 历史 ONWORKS_AGENT_HISTORY_CHARS 默认 80,000 字符,单轮历史最多 6,000 字符。
6. stdout JSONL 的接收与存储
普通 Codex 会话可以写本地 rollout 文件,codex exec --json 也会同时向 stdout 输出事件。但 OnWorks 使用 --ephemeral,含义就是“不把 session 文件持久化到磁盘”。OnWorks 也不打开、不解析本地 rollout 文件,只读取子进程的 stdout JSONL。
- 原始 stdout JSONL:不保存到 MySQL,也不保存成文件。OnWorks 在内存里逐行解析。
reasoning、命令、命令输出、文件变更:只提取成面向 UI 的步骤摘要,经过截断后写入tasks.steps。agent_message:清洗内部路径后写入tasks.result。file_change和结束时收集到的产物:复制到任务目录,并把文件元数据写入task_files。turn.completed的 usage:写入usage_records。
用户看到的不是原始事件流。浏览器轮询拿到的是 API DTO;任务失败或取消时也只看到 tasks.error 或状态,不给原始 stdout。
7. 响应字段与数据库字段
更准确地说:result、steps 是 tasks 表上的真实字段;outputs、intermediates 不是数据库字段,而是 API 序列化时按 task_files.role 过滤出来的列表。
| 响应字段 | 来源 | 含义 |
|---|---|---|
result | tasks.result | 最终 agent message 清洗后的 Markdown。 |
steps | tasks.steps 中的 JSON 数组 | 思考、执行命令、写入文件等 UI 摘要;不是完整 agent 轨迹。 |
outputs | task_files.role = "output" 的行 | 用户要的交付文件。 |
intermediates | task_files.role = "intermediate" 的行 | 脚本、图表、核对副本等中间产物。 |
files | task_files.role = "input" 的行 | 本轮上传附件的元数据。 |
它们不是在任务运行中实时更新。三个角色分别在三个时点产生:
files(role=input):在POST /tasks保存上传文件时创建。原始字节先写入backend/app/media/tasks/<task_id>/<uuid><ext>,然后为每个文件插入一行task_files,角色固定为input。之后 Agent 不修改这些行。outputs(role=output):Codex 进程结束且_collect_outputs()判定某文件是deliverable后,由_save_outputs()复制到任务目录,并在同一事务里插入task_files行。重跑任务时,_drop_outputs()会删除旧output行和对应磁盘文件,然后新运行重新生成。intermediates(role=intermediate):同一个收尾阶段创建。判定来源包括工作根目录新增文件、Codexfile_change事件、以及output/里的辅助文件(图表页、核对副本等)。重跑时不会被_drop_outputs()清空;只有删除任务或清理会话才会删除。
MySQL 行只保存元数据;文件内容在磁盘。下面是一次任务结束后的例子:
-- task_files
id = f-in-1
task_id = t-456
filename = sales.xlsx
stored_name = 8f1a2b3c4d5e6f70.xlsx
content_type = application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
kind = other
size = 20480
role = input → API task.files
id = f-out-1
task_id = t-456
filename = summary.xlsx
stored_name = 1a2b3c4d5e6f7081.xlsx
content_type = application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
kind = other
size = 12288
role = output → API task.outputs
id = f-mid-1
task_id = t-456
filename = report.html
stored_name = 5e6f7a8b9c0d1e2f.html
content_type = text/html
kind = other
size = 45056
role = intermediate → API task.intermediates
-- 对应磁盘
backend/app/media/tasks/t-456/8f1a2b3c4d5e6f70.xlsx
backend/app/media/tasks/t-456/1a2b3c4d5e6f7081.xlsx
backend/app/media/tasks/t-456/5e6f7a8b9c0d1e2f.html
浏览器先轮询 GET /tasks 或 GET /tasks/{id} 拿到列表;点击文件卡时才请求下载/预览端点,后端按 task_id + file_id 查行,再把 stored_name 映射到磁盘文件返回。