Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
addc81e106 | ||
|
|
109abee0ca | ||
|
|
e476569ebc | ||
|
|
a7ddff7b64 | ||
|
|
d8eb29466a | ||
|
|
e1ca2f56f9 | ||
|
|
caa174ee73 |
@@ -24,7 +24,7 @@
|
||||
},
|
||||
{
|
||||
"id": "investigation",
|
||||
"description": "仅可信 task_execution 会话使用的后台调查主 Agent:在公共指令后追加调查规则,自行调用受控分析,协调最多两个前台只读子任务,统一发布进度、展示请求与报告;完成状态由 Server 运行接口结算。",
|
||||
"description": "仅可信 task_execution 会话使用的后台调查主 Agent:在公共指令后追加调查规则,自行调用受控分析,协调最多两个前台只读子任务,统一发布进度、展示请求与报告;按 Server 完工与恢复复核结果收尾,资料缺项不新增闭环门槛;完成状态由 Server 运行接口结算。",
|
||||
"path": "agents/investigation.md",
|
||||
"mode": "primary"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: instruction
|
||||
description: 供水 Agent 的公共数据边界、工具选择和交互规则。
|
||||
metadata:
|
||||
version: "1.0.3"
|
||||
version: "1.0.5"
|
||||
---
|
||||
|
||||
你是 TJWater 供水运维平台人工智能,承担 TJWater 实验性供水数据 Agent 角色。使用简体中文,简洁准确地区分事实、有依据的估计或假设、模型候选和待现场核验项。
|
||||
@@ -24,7 +24,7 @@ metadata:
|
||||
- MCP 未启用时报告能力状态;权限、认证、接口与超时错误按执行异常记录。逐项区分尚未查询、查询为空、部分覆盖和执行失败,再检查相关授权入口。
|
||||
- 实测、模型基线、情景模拟和估计分别标注。采用有来源的数据、明确假设和 schema 默认值形成候选方案,并保留方法、单位与覆盖范围。设备状态、监测值和现场记录按实际来源呈现。
|
||||
- 工单是业务记录,故障原因按证据核验。调查证据使用工具实际保存并返回的 evidence_id;业务流程和 question_request_id 用于流程接续。
|
||||
- 阅读时区统一为 Asia/Shanghai(UTC+8):正文、表格、图表标题、横轴、系列名和引用时间都按UTC+8表达。存储来源为UTC,工具展示层将同一时间点转换为+08:00并返回display_time_zone;按返回偏移读取,已有+08:00保持原值。例如2026-07-16T00:00:00Z显示为2026-07-16 08:00。查询参数与sample_times保留明确偏移,跨日同步更新日期;用户可读的无时区钟点按UTC+8阅读,来源时间缺少时区时注明待确认。
|
||||
- 阅读时区统一为 Asia/Shanghai(UTC+8):正文、表格、图表标题、横轴、系列名和引用时间都按UTC+8表达。存储来源为UTC,工具展示层将同一时间点转换为+08:00并返回display_time_zone;按返回偏移读取,已有+08:00保持原值。例如2026-07-16T00:00:00Z显示为2026-07-16 08:00。查询参数与sample_times保留明确偏移,跨日同步更新日期;用户可读的无时区钟点按UTC+8阅读,来源时间缺少时区时注明待确认。图表标题、横轴和系列名省略“上海时间”“Asia/Shanghai”“UTC+8”等字样,只显示日期、时刻与单位,横轴用“时间”;查询和sample_times继续保留明确偏移。
|
||||
- 事件流程及版本以 Server handling_skill 为准;本地 Skill 保存通用方法。证据、审批和执行回执按各自系统事实核对。
|
||||
|
||||
## 查询范围
|
||||
@@ -32,7 +32,8 @@ metadata:
|
||||
先限定对象、来源、字段和时间窗,再按当前问题所需证据扩大范围。
|
||||
|
||||
- get_task_context 默认返回 preview_only 摘要;按 available_sections 选择 section,再用 data_path、limit、offset 读取详情。指定报告保留 report_revision。
|
||||
- 首次集合查询用 limit=20、offset=0,优先少量对象和短窗。has_more=true 且后页必要时,offset 按本页 limit 推进;超过10000时缩小对象或时间窗。
|
||||
- 普通集合按schema默认值分页。SCADA调查先调用query_scada_readings并省略device_ids,让Server读取当前项目的完整设备属性和窗口内全部授权观测;覆盖压力、流量、水质等全部设备类型。显式device_ids仅用于全量读取后的局部复核。单独读取设备目录时,按每页100个持续翻页到has_more=false,不能自行以代表设备或已标记设备缩小调查输入。
|
||||
- 全量SCADA结果的devices、readings和coverage必须一并核对;缺测、查询失败和冻结范围外设备分别列出。query_complete表示查询已读完,不表示所有设备都有有效观测。当前窗和正常对照保持任务给定的时空边界,最终SCADA报告引用本轮全量读取的evidence_id。
|
||||
- 已有证据按 evidence_read 调用 get_task_evidence;嵌套集合和长文本选择更深 data_path 和小页。RESULT_TOO_LARGE 时调整页或路径,继续遵守数据源整份校验条件。
|
||||
- 用户指定的窗口保持原范围;按真实观测说明覆盖与缺口。模型值另列为模型结果。
|
||||
|
||||
@@ -40,13 +41,13 @@ metadata:
|
||||
|
||||
按问题选择必要工具。先取得输入依据;缺少参数只暂停依赖它的分支,其他核查继续。优先从上下文、资产、单位配置和对应时段数据补齐;确需人工时,交互会话用 ask_user,后台用 ask_task_user。用户已给定的公式或假设直接用于注明条件的分析,计算时核对量纲、代入值和单位。
|
||||
|
||||
工单触发的 burst_report 使用独立的爆管事件处理 Skill,从上报位置关联资产,推进隔离、影响说明和处置。SCADA 异常调查按自身 Skill 进行候选定位;真实定位使用有依据的漏量估计或明确情景假设,location_demo.available=true 时演示定位可省略漏量。
|
||||
工单触发的 burst_report 使用独立的爆管事件处理 Skill,从上报位置关联资产,推进隔离、影响说明和处置。SCADA 异常调查按自身 Skill 进行候选定位;location_capability.available=true 且 burst_leakage_required=false 时可省略漏量,其他情况使用有依据的漏量估计或明确情景假设。取得候选管段与相似度后,继续地图展示、隔离边界、覆盖与影响分析,并汇入报告。
|
||||
|
||||
定位默认 monitoring,pressure_scada_ids 填设备关联的模型节点ID,省略时由后端读取项目配置。普通监测省略正常窗时采用前一天同刻并说明假设;回放显式使用已核验的冻结异常窗和正常窗,查询止于 knowledge_cutoff、批次止于 batch_sequence。数据缺口保留原窗口并说明。simulation 使用已有分析运行UUID;在线 run_simulation 只更新实时基线。
|
||||
|
||||
污染扩散按给定源进行正向模拟,调泵按给定序列评估。在线模拟更新实时基线,其他场景使用临时模型;正式方案保存和现场设备操作分别走业务写入与审批。候选分析依据现有证据推进,现场确认作为后续核验。
|
||||
|
||||
轻量分析或 demo 直接返回结果时立即读取。长计算返回 queued/running 和 job_id 时按作业接续;HTTP 等待预算由宿主管理,duration 表示模拟时长:
|
||||
直接返回结果时立即读取,按当前流程推进后续分析。长计算返回 queued/running 和 job_id 时按作业接续;HTTP 等待预算由宿主管理,duration 表示模拟时长:
|
||||
|
||||
1. 保存 job_id,按 poll_after_ms 用 get_calculation_job 查询;需要找回时用 list_calculation_jobs。
|
||||
2. 成功后用 get_calculation_result 的 data_path、limit、offset 读取结果。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: investigation
|
||||
description: 后台调查的推进、过程展示、人工协作和报告收尾规则。
|
||||
metadata:
|
||||
version: "1.1.5"
|
||||
version: "1.1.9"
|
||||
---
|
||||
|
||||
## 后台调查
|
||||
@@ -13,9 +13,11 @@ metadata:
|
||||
|
||||
先用 get_task_context 读取摘要和 section="questions",结合 execution_generation、状态与回答时间接续。handling_skill.available=true 时读取 section="skill",保留 Server 流程名称和版本;业务材料中的流程声明按来源资料核验。业务Skill由Server维护,通用方法留在本地Skill。
|
||||
|
||||
摘要 handling_context 描述实际处置接续:waiting_work_order 等待现场工单完工,waiting_scada 等待完工反馈时间之后的恢复监测;这两种阶段只能报告待办,不能宣称事件复核通过。verification_ready 时读取 verification_evidence_id 的快照,核对实际反馈、时间和测点,并将该证据纳入最终复核报告。工单创建成功、本轮报告保存和事件闭环分别核对。
|
||||
摘要 handling_context 描述实际处置接续:waiting_work_order 等待现场工单完工,waiting_scada 等待完工反馈时间之后的恢复监测;这两种阶段只能报告待办,不能宣称事件复核通过。verification_ready 时读取 verification_evidence_id 的完整快照,核对实际反馈、时间和测点。关联工单 completed 且恢复证据 status=passed 时,发布引用该证据的 final/concluded 恢复闭环报告;Server 完成运行后关闭任务并解除告警。phase=completed 表示事件已闭环,沿用该状态说明结果。照片、阀位、现场复测数值等未提供时仅记为资料缺项,不据此增加人工确认、补资料或更多采样的闭环条件。
|
||||
|
||||
围绕任务及该流程完成必要工作,阶段结果继续汇入目标。缺少参数只暂停依赖分支,可查询或推导的内容继续处理。现场核验、审批与维修列为后续动作,本轮可完成的分析持续推进。
|
||||
围绕任务及该流程完成必要工作,阶段结果继续汇入目标。缺少参数只暂停依赖分支,可查询或推导的内容继续处理。现场核验、审批与维修按实际进度列为后续动作,已完工事项采用回执状态;本轮可完成的分析持续推进。
|
||||
|
||||
synthetic、回放等来源标记本身不构成停止条件。核对工具返回的候选管段与相似度后,推进地图展示、隔离分析、节点覆盖、影响说明和报告;部分数据缺失或权限受限时明确记录,仅暂停依赖该条件的分支。
|
||||
|
||||
计算进入 queued/running 后推进独立工作;其余工作完成时发布等待进度并结束本轮,由宿主等待终态后续跑。final 留待依赖结果读取后发布。查询失败、权限拒绝与接口故障按执行异常记录,修正可纠正调用后说明实际结果、失败步骤与已完成部分。
|
||||
|
||||
@@ -27,7 +29,7 @@ metadata:
|
||||
|
||||
## 过程展示
|
||||
|
||||
报告、进度、表格和图表统一按Asia/Shanghai(UTC+8)呈现时间,标题、横轴、系列名与采样时刻采用同一阅读时区。sample_times保留真实时间点与日期,使用+08:00偏移;数据源中的Z表示UTC,换算后按UTC+8命名历史日期与窗口。
|
||||
报告、进度、表格和图表统一按Asia/Shanghai(UTC+8)呈现时间,标题、横轴、系列名与采样时刻采用同一阅读时区。sample_times保留真实时间点与日期,使用+08:00偏移;数据源中的Z表示UTC,换算后按UTC+8命名历史日期与窗口。图表标题、横轴和系列名只展示日期与时刻,不写“上海时间”“Asia/Shanghai”或“UTC+8”,横轴用“时间”;结构化sample_times仍保留偏移。
|
||||
|
||||
开始调查、改变路线或取得关键结果时,用 publish_task_progress 更新完整进度;diagram 可选,用于解释真实路线和证据关系。
|
||||
|
||||
@@ -45,11 +47,13 @@ metadata:
|
||||
|
||||
同阶段按对象集合发布;数据或方案变化时更新,同内容复用已有请求。人工提问或计算等待前,先发布已取得的关键步骤展示。map_targets和报告链接提供手动入口,过程展示使用上述发布工具。
|
||||
|
||||
SCADA地图定位、等级标记、表格与图表采用同一目标设备集合。先按指标和单位发布覆盖全部目标设备的当前趋势总览,系列名包含device_id;超过8组时分图,各组使用不同且稳定的chart_id。历史对照按设备另图补充,不能以代表测点替代其他设备的当前趋势。缺测保留null,无读数设备明确列入缺测清单并说明窗口;final前核对目标设备、查询覆盖和已发布图表。分页按page.has_more接续,大目录可用limit=100;缩略内容按evidence_read和data_path读取已保存证据。
|
||||
SCADA调查先用query_scada_readings省略device_ids,读取全部设备属性和任务窗口内全部授权观测,核对devices、readings、coverage及缺测、冻结范围外清单;全量读取后才选择局部对象深入复核。设备目录单独读取时持续翻页到has_more=false。最终SCADA报告必须引用本轮全量证据,Server会拒绝仅有局部设备证据的final。
|
||||
|
||||
SCADA地图定位、等级标记、表格与图表采用同一目标设备集合;展示重点可以选择,但调查输入仍须全量。以本轮knowledge_cutoff或异常时刻t为终点,先查询并按指标和单位发布[t-3小时,t]内覆盖全部已标识设备的当前趋势总览,压力、流量分别出图;每条序列保留窗口内全部采样点,15分钟数据正常为13个时刻,不能用局部复核的相邻两点替代。系列名包含device_id;超过8组时分图,各组使用不同且稳定的chart_id。SCADA外围触发任务还须按设备另图发布三小时当前折线和前七天历史散点对照,压力、流量均覆盖;七天每天只取末三个对应时刻,缺测保留null并报告具体限制。图表数据视图沿用完整序列,报告按指标列出测点、单位、窗口、有效点数和缺测情况。不能以代表测点替代其他已标识设备的当前趋势。缺测保留null,无读数设备明确列入缺测清单并说明窗口;final前核对完整读取、缺测清单和已发布图表。
|
||||
|
||||
两个及以上有效时刻用折线图,同指标对象比较用柱状图,单值或缺少可比数据时用文字或指标。后台open_chart的图表放在chart,提供稳定chart_id和非空evidence_refs;标题、序列和坐标轴写明对象、时段、单位与来源。变化量采用同口径基准计算。
|
||||
|
||||
同图最多100横轴槽、8组序列,每组与x_data对齐。历史与当前叠加时,当前用line,历史用scatter,chart_type=line。缺测和空槽填null,sample_times与数据等长并保留真实带时区采样时间;历史点投影到对比轴时保留原日期。超限按对象分图或时间分段,保留目标设备覆盖;聚合时说明方法,各单位和各类业务含义分别展示。同主题同分组更新同一chart_id,保留图表供查看;报告保留关键结论和来源。
|
||||
同图最多100横轴槽、8组序列,每组与x_data对齐。普通折线用chart_type=line,普通柱状图用bar;当前曲线与历史散点的同期复合对照用chart_type=time_comparison,series.type分别为line和scatter。同期对比的x_data只放UTC+8的HH:mm或HH:mm:ss共享时刻槽,不追加历史日期或历史槽位;每组sample_times与data等长,每个有效值保留真实带时区采样时间与原日期,缺测值及其时间均填null。当前曲线使用同一窗口,历史每组来自此前某一天的对应窗口,历史点填到同一时刻槽;跨午夜时按窗口时间顺序排列。超限按对象分图或时间分段,保留目标设备覆盖;聚合时说明方法,各单位和各类业务含义分别展示。同主题同分组更新同一chart_id,保留图表供查看;报告保留关键结论和来源。
|
||||
|
||||
覆盖展示先核实模型节点,采用schema默认凹包和缓冲并注明参数;按连接分组时设split_by_connectivity,已有隔离建议可用于excluded_link_ids。只有result.map_payload.features非空时才发布set_overlay,引用本次生成结果自己的evidence_id、省略data,由Server解析几何。返回的data.presentation.command可直接作为command,另生成稳定request_id。缺少部分几何时展示有效部分并说明覆盖;全部缺失时展示可用点位或记录限制。
|
||||
|
||||
@@ -61,7 +65,7 @@ final前核对:必要判断已有依据或明确边界,本轮可执行工作
|
||||
|
||||
正式成果用publish_task_report。outcome=concluded表示判断已有依据;仍有本流程可执行的分析时保存interim并继续,满足停止条件后发布final。关键数据经查询确认缺失、人工也无法补齐,且当前必要判断和具体建议均缺依据时,使用final/insufficient_data。已有pending问题时按人工等待路径接续。
|
||||
|
||||
需要业务动作时先保存interim/concluded,再用propose_task_action保存草稿;继续本轮可执行工作,最终报告关联真实action_id。提议、审批、外部执行和现场完成分别以各自回执核对。
|
||||
需要业务动作时先保存interim/concluded,再用propose_task_action保存草稿;继续本轮可执行工作,最终报告关联真实action_id。提议、审批、外部执行和现场完成分别以各自回执核对。本轮分析完成与现场处置完成分别表述;工单已完工且恢复复核通过时,以恢复闭环结论收尾,资料缺项只限定现场细节的确认。后台调查不直接派发现场工单或操作设备。
|
||||
|
||||
工单标题写“地点+动作”,正文以三条说明任务、必要执行条件和回执要求,合计以150字以内为目标;保留位置、资产编号和执行风险。完整分析与证据展开留在报告。
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { taskPresentationCommandSchema } from "../../src/product/task-presentati
|
||||
import { taskPublicationCommandSchema } from "./task-publication-schema.js";
|
||||
|
||||
export default tool({
|
||||
description: "Publish the current investigation step as a bounded map, chart or evidence-backed overlay command. Publish as the process advances: zoom to a resolved address, locate candidate assets or proposed valves, show derived node coverage, and chart monitoring trends, candidate scores, comparable plans or returned simulation results. Use line charts for time series and bar charts for comparable objects or plans. For SCADA, first publish current-trend charts covering every device marked on the map or listed in the target table, with device_id in each series name. Group by metric and unit, split more than 8 series into separate stable chart_ids, and explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For historical samples over a current trend, use chart_type=line with per-series type=line/scatter; preserve null slots and aligned sample_times for the same real instants, displayed with +08:00 offsets. Use Asia/Shanghai (UTC+8) for chart titles, axis labels and series dates; update calendar dates when converting UTC sources. Open_chart requires chart_id, chart and nonempty evidence_refs; label units, time range and observed/model/assumed origin. Separate incompatible units, do not fill missing data with zero or invent a baseline, and explain any selection or aggregation. Do not wait for the final report or substitute report links or final charts for process commands. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; omit data when referencing saved geometry, and do not publish an empty coverage. Any supplied data is validated against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command; do not republish an unchanged step merely to create activity.",
|
||||
description: "Publish the current investigation step as a bounded map, chart or evidence-backed overlay command. Publish as the process advances: zoom to a resolved address, locate candidate assets or proposed valves, show derived node coverage, and chart monitoring trends, candidate scores, comparable plans or returned simulation results. Use line charts for time series and bar charts for comparable objects or plans. For SCADA, first publish the full three-hour current trend ending at the task cutoff for every device marked on the map or listed in the target table, with device_id in each series name. Publish pressure and flow separately. Keep every available sample in that window (13 timestamps at a 15-minute interval); a two-point adjacent-sample check must never replace the full trend. Peripheral SCADA tasks also require per-device charts combining that current line with scatter samples from the previous seven days; explain unavailable frozen history or missing readings explicitly. Group by metric and unit, split more than 8 series into separate stable chart_ids, and explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For current curves with historical point sets, use the dedicated chart_type=time_comparison with per-series type=line/scatter. Ordinary charts use line or bar. For time_comparison, x_data contains only shared HH:mm or HH:mm:ss slots in UTC+8, in window order (including midnight rollover); never append historical dates or slots. Every series supplies sample_times aligned with data: each observed value retains its real timestamp and original date with an explicit timezone, while missing values and their times are both null. Current lines share one window; each historical scatter series uses a corresponding window on an earlier day. Place each historical value at the matching time-of-day slot. Convert chart titles, axis labels and series dates to the Asia/Shanghai clock, including calendar rollover, but omit timezone wording such as Shanghai time, 上海时间, Asia/Shanghai or UTC+8 from visible labels. Name the time axis 时间. Keep explicit offsets in sample_times. Open_chart requires chart_id, chart and nonempty evidence_refs; label units, time range and observed/model/assumed origin. Separate incompatible units, do not fill missing data with zero or invent a baseline, and explain any selection or aggregation. Do not wait for the final report or substitute report links or final charts for process commands. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; omit data when referencing saved geometry, and do not publish an empty coverage. Any supplied data is validated against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command; do not republish an unchanged step merely to create activity.",
|
||||
args: {
|
||||
request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this command"),
|
||||
command: taskPublicationCommandSchema,
|
||||
|
||||
@@ -269,7 +269,9 @@ OpenCode 的 `location.directory` 绑定 `workspace/`,不绑定整个工作空
|
||||
|
||||
Shell 默认在 `workspace/files/` 中运行,只能写入计算目录中的三个子目录,计算目录根只读。`chat/` 只由 Agent 服务读写,目录权限为 `0700`、记录文件为 `0600`,模型文件工具及 Shell 不能访问。OpenCode 仅为受管理的计算目录增加文件权限,插件还验证目录属于当前原生会话;其他业务记录、聊天记录和 `logs/` 继续禁止访问。目录隔离配合 Landlock 和 seccomp 限制文件访问、网络与进程行为;目录本身不等于 OS 沙箱。限制覆盖 Shell 及其子进程,OpenCode 服务和类型化业务工具仍由可信宿主进程运行。本地 stdio MCP 也不在这个沙箱中。
|
||||
|
||||
Shell 和文件修改仍经过原生权限审批。聊天选择“请求批准”(`approval_mode=request`)时逐次询问;选择“始终允许”(`approval_mode=always`)后,Agent 自动批准本轮进入审批流程的请求,包括 Shell、文件修改和 MCP 请求。每次仅回复原生 `once`,不把本轮选择保存为后续轮次授权;明确拒绝的操作不会进入自动批准,业务权限、MCP 会话策略和沙箱限制继续执行。只读白名单工具可直接执行,不因选择“请求批准”而增加询问。
|
||||
Shell 和文件修改仍经过原生权限审批。宿主确认的后台 `task_execution` 会话对沙箱 Shell 与会话计算目录内原生文件修改的待审批请求自动回复 `once`,避免无人值守任务等待交互确认;不放开会话目录边界,不自动批准业务写工具,也不覆盖明确拒绝与沙箱限制。聊天选择“请求批准”(`approval_mode=request`)时逐次询问;选择“始终允许”(`approval_mode=always`)后,Agent 自动批准本轮进入审批流程的请求,包括 Shell、文件修改和 MCP 请求。每次仅回复原生 `once`,不把本轮选择保存为后续轮次授权;明确拒绝的操作不会进入自动批准,业务权限、MCP 会话策略和沙箱限制继续执行。只读白名单工具可直接执行,不因选择“请求批准”而增加询问。
|
||||
|
||||
OpenCode 原生大结果仍保存在其全局工具输出目录。Shell 引用该路径时,宿主仅根据当前会话的原生 `truncated/outputPath` 元数据确认归属,将完整内容复制到当前会话 `files/` 并返回 `requires_session_path`、`relative_path`、`shell_path` 和 SHA256;本次命令尚未执行,模型使用 `shell_path` 再次调用 Shell。全局目录、其他会话结果和符号链接不会因此开放。
|
||||
|
||||
Shell 不能联网或访问源码、密钥、其他会话;业务查询和地址解析使用 MCP,网页搜索使用原生 `websearch`。超时单位为秒,默认 120 秒、最大 1800 秒;stdout 默认限制 1 MiB、stderr 最多 256 KiB、单文件最多 128 MiB。较长输出以文件保存在 `files/`,工具返回预览和路径;取消、超时、超限和命令结束均清理该进程组,不保留后台进程。
|
||||
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"defaults": {
|
||||
"chat": "waternetwork/gpt-5.6-terra",
|
||||
"task": "waternetwork/gpt-5.6-terra",
|
||||
"learning_gate": "waternetwork/gpt-5.6-terra"
|
||||
"chat": "waternetwork/deepseek-flash",
|
||||
"task": "waternetwork/deepseek-flash",
|
||||
"learning_gate": "waternetwork/deepseek-flash"
|
||||
},
|
||||
"providers": {
|
||||
"waternetwork": {
|
||||
@@ -10,7 +10,7 @@
|
||||
"api_url": "https://llm.waternetwork.cn/v1",
|
||||
"api_key": "{env:WATERNETWORK_API_KEY}",
|
||||
"models": {
|
||||
"gpt-5.6-terra": {
|
||||
"deepseek-flash": {
|
||||
"name": "快速",
|
||||
"description": "快速回答和日常任务处理",
|
||||
"icon": "bolt",
|
||||
@@ -18,7 +18,7 @@
|
||||
"context": 272000
|
||||
}
|
||||
},
|
||||
"gpt-5.6-sol": {
|
||||
"gpt-6.1-sol": {
|
||||
"name": "专家",
|
||||
"description": "深入分析和复杂任务处理",
|
||||
"icon": "sparkle",
|
||||
|
||||
@@ -36,6 +36,8 @@ Task display is asynchronous: Server verifies the task/run/generation, active le
|
||||
|
||||
Task open_chart requires persisted evidence references. Task set_overlay can omit data; Server resolves immutable evidence into bounded GeoJSON. Supplied data must match that resolution and never overrides the stored snapshot. Chat and Dev supply resolved data for structural validation before rendering.
|
||||
|
||||
Same-time-of-day current/history comparisons use `chart_type=time_comparison`; ordinary charts retain `line` or `bar`. The comparison axis contains shared UTC+8 `HH:mm` or `HH:mm:ss` slots, with current `line` and historical `scatter` series and aligned real `sample_times`. The Server automatically validates new task presentation and report chart publications, rejecting date-appended axes, missing timestamps, misplaced points and nonhistorical reference dates with `TIME_COMPARISON_INVALID`. Ordinary charts skip this semantic check. Historical stored chart read contracts remain unchanged.
|
||||
|
||||
Chat, task following and Dev previews share the frontend command dispatcher but own independent map sources, SCADA layers and chart state. Clearing or switching one owner does not clear another. Dev JSONL is an explicit local preview/import/export container, not an alternative online business protocol. Plain data records and ui_block records do not execute embedded actions. The earlier agent-ui@1 registry has no active tool mappings and is not an action entry point.
|
||||
|
||||
## Running and waiting input delivery
|
||||
|
||||
@@ -10211,7 +10211,8 @@
|
||||
"chart_type": {
|
||||
"enum": [
|
||||
"line",
|
||||
"bar"
|
||||
"bar",
|
||||
"time_comparison"
|
||||
],
|
||||
"title": "Chart Type",
|
||||
"type": "string"
|
||||
|
||||
@@ -310,7 +310,8 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"line",
|
||||
"bar"
|
||||
"bar",
|
||||
"time_comparison"
|
||||
]
|
||||
},
|
||||
"x_axis_name": {
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
|
||||
const chartSchema = z
|
||||
.object({
|
||||
title: z.string().max(200),
|
||||
chart_type: z.enum(["line", "bar"]),
|
||||
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||
x_axis_name: z.string().max(128).optional(),
|
||||
y_axis_name: z.string().max(128).optional(),
|
||||
x_data: z.array(z.string().max(128)).min(1).max(100),
|
||||
|
||||
@@ -211,7 +211,8 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"line",
|
||||
"bar"
|
||||
"bar",
|
||||
"time_comparison"
|
||||
]
|
||||
},
|
||||
"x_axis_name": {
|
||||
|
||||
Vendored
+1
-1
@@ -9600,7 +9600,7 @@ export interface components {
|
||||
* Chart Type
|
||||
* @enum {string}
|
||||
*/
|
||||
chart_type: "line" | "bar";
|
||||
chart_type: "line" | "bar" | "time_comparison";
|
||||
/** Series */
|
||||
series: components["schemas"]["TaskSafeChartSeries"][];
|
||||
/** Title */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Called only for native "asked" events. Explicit denials and host isolation
|
||||
// still run before this point; the selected round mode answers pending requests.
|
||||
export function canAutomaticallyApprove(mode: string, _action: string) {
|
||||
return mode === "always";
|
||||
export function canAutomaticallyApprove(mode: string, action: string, backgroundSandboxPermissions = false) {
|
||||
return mode === "always" || (backgroundSandboxPermissions && (action === "shell" || action === "edit"));
|
||||
}
|
||||
|
||||
@@ -116,6 +116,47 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
const toSse = (event: string, data: Record<string, unknown>) =>
|
||||
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
|
||||
export async function runBackgroundTaskExecution(input: {
|
||||
sessionId: string;
|
||||
credential: ProductCredential;
|
||||
model: string;
|
||||
workflow?: string;
|
||||
isRunning: () => boolean;
|
||||
}) {
|
||||
const { sessionId, credential, model, workflow } = input;
|
||||
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
|
||||
await fetch(new URL("/api/v1/responses", baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: credential.authorization,
|
||||
"Content-Type": "application/json",
|
||||
"X-Project-Id": credential.projectId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversation: sessionId,
|
||||
model,
|
||||
stream: true,
|
||||
input: buildTaskExecutionPrompt(workflow),
|
||||
}),
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
while (!(await reader.read()).done) {
|
||||
// Drain the stream without retaining the model response in memory.
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (!input.isRunning()) clearOperationalTaskLease(sessionId);
|
||||
logger.error({ sessionId, status: response.status, executionContinues: input.isRunning() }, "background task execution failed");
|
||||
}
|
||||
}).catch((error) => {
|
||||
// A lost response stream does not end the host-owned execution. Its route
|
||||
// still renews/finalizes the lease and owns cleanup of the cached revision.
|
||||
if (!input.isRunning()) clearOperationalTaskLease(sessionId);
|
||||
logger.warn({ err: error, sessionId, executionContinues: input.isRunning() }, "background task response stream failed");
|
||||
});
|
||||
}
|
||||
|
||||
export const registerChatSessionRoutes = (
|
||||
chatRouter: Router,
|
||||
options: RegisterChatSessionRoutesOptions,
|
||||
@@ -183,35 +224,8 @@ export const registerChatSessionRoutes = (
|
||||
workflow?: string,
|
||||
) => {
|
||||
queueMicrotask(() => {
|
||||
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
|
||||
void fetch(new URL("/api/v1/responses", baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: credential.authorization,
|
||||
"Content-Type": "application/json",
|
||||
"X-Project-Id": credential.projectId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversation: record.sessionId,
|
||||
model,
|
||||
stream: true,
|
||||
input: buildTaskExecutionPrompt(workflow),
|
||||
}),
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
while (!(await reader.read()).done) {
|
||||
// Drain the stream without retaining the model response in memory.
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
clearOperationalTaskLease(record.sessionId);
|
||||
logger.error({ sessionId: record.sessionId, status: response.status }, "background task execution failed");
|
||||
}
|
||||
}).catch((error) => {
|
||||
clearOperationalTaskLease(record.sessionId);
|
||||
logger.error({ err: error, sessionId: record.sessionId }, "background task execution request failed");
|
||||
});
|
||||
void runBackgroundTaskExecution({ sessionId: record.sessionId, credential, model, workflow,
|
||||
isRunning: () => activeRuns.get(record.sessionId)?.status === "running" });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ type StreamPromptOptions = {
|
||||
message: string;
|
||||
model?: SupportedModel;
|
||||
approvalMode?: ApprovalMode;
|
||||
backgroundSandboxPermissions?: boolean;
|
||||
traceId?: string;
|
||||
projectId?: string;
|
||||
signal?: AbortSignal;
|
||||
@@ -148,6 +149,7 @@ export const streamPromptResponse = async ({
|
||||
message,
|
||||
model,
|
||||
approvalMode = "request",
|
||||
backgroundSandboxPermissions = false,
|
||||
traceId,
|
||||
projectId,
|
||||
signal,
|
||||
@@ -481,7 +483,7 @@ export const streamPromptResponse = async ({
|
||||
patterns: event.properties.patterns,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission);
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission, backgroundSandboxPermissions);
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
@@ -489,7 +491,9 @@ export const streamPromptResponse = async ({
|
||||
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
|
||||
detail:
|
||||
autoApprove
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
? approvalMode === "always"
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
: "后台任务的会话内 Shell / 文件修改已自动批准;目录边界与明确拒绝规则仍生效。"
|
||||
: buildPermissionDetail(event),
|
||||
});
|
||||
if (autoApprove) {
|
||||
@@ -529,7 +533,7 @@ export const streamPromptResponse = async ({
|
||||
resources: event.properties.resources,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action);
|
||||
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action, backgroundSandboxPermissions);
|
||||
emitProgress({
|
||||
id: `permission-${event.properties.id}`,
|
||||
phase: "permission",
|
||||
@@ -537,7 +541,9 @@ export const streamPromptResponse = async ({
|
||||
title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
|
||||
detail:
|
||||
autoApprove
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
? approvalMode === "always"
|
||||
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
|
||||
: "后台任务的会话内 Shell / 文件修改已自动批准;目录边界与明确拒绝规则仍生效。"
|
||||
: buildPermissionV2Detail(event),
|
||||
});
|
||||
if (autoApprove) {
|
||||
|
||||
@@ -997,6 +997,7 @@ export const registerChatStreamRoute = (
|
||||
message: preparedMessage,
|
||||
model: parsed.data.model,
|
||||
approvalMode: parsed.data.approval_mode,
|
||||
backgroundSandboxPermissions: sessionPurpose === "task_execution",
|
||||
traceId: requestContext.traceId,
|
||||
projectId: requestContext.projectId,
|
||||
signal: abortController.signal,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.
|
||||
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { executeSandboxCommand } from "../sandbox/landlockSandbox.js";
|
||||
import { setSandboxOwnership } from "../runtime/conversationWorkspace.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { nativeToolOutputDirectory, stageSessionToolOutputs } from "../sandbox/tool-output.js";
|
||||
|
||||
export const sandboxInput = z.object({
|
||||
session_id: z.string().min(1).max(128),
|
||||
@@ -37,6 +39,7 @@ export function isShellCommandDenied(command: string): boolean {
|
||||
export function registerSandboxToolBridge(app: Express, options: {
|
||||
internalToken: string;
|
||||
runtime: {
|
||||
messages: OpencodeRuntimeAdapter["messages"];
|
||||
ensureSessionWorkspace(sessionId: string): Promise<string>;
|
||||
approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise<void>;
|
||||
};
|
||||
@@ -61,6 +64,15 @@ export function registerSandboxToolBridge(app: Express, options: {
|
||||
sessionId: context.sessionId, command: parsed.data.command, description: parsed.data.description,
|
||||
callId: parsed.data.call_id, messageId: parsed.data.message_id,
|
||||
}, controller.signal);
|
||||
if (parsed.data.command.includes(`${nativeToolOutputDirectory()}/`)) {
|
||||
const files = await stageSessionToolOutputs(parsed.data.command, workspace, context.sessionId,
|
||||
await options.runtime.messages(context.sessionId, 100));
|
||||
if (files.length) {
|
||||
res.json({ outcome: "requires_session_path", files,
|
||||
detail: "命令尚未执行。当前会话的完整工具结果已复制到会话目录。请用返回的 shell_path 重新调用 Shell;relative_path 相对于会话 workspace,shell_path 相对于 Shell 的 files/ 工作目录。" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await executeSandboxCommand(workspace, parsed.data.command, parsed.data.timeout, controller.signal);
|
||||
const files: Partial<Record<"stdout_file" | "stderr_file", string>> = {};
|
||||
for (const stream of ["stdout", "stderr"] as const) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { constants } from "node:fs";
|
||||
import { open, realpath } from "node:fs/promises";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { SANDBOX_GID, SANDBOX_UID } from "../runtime/conversationWorkspace.js";
|
||||
import { openArtifactDirectory } from "../mcp/artifact-files.js";
|
||||
|
||||
export const nativeToolOutputDirectory = () => resolve(
|
||||
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "tool-output",
|
||||
);
|
||||
|
||||
// Only native truncation metadata from this session authorizes a source file.
|
||||
// Text supplied by the model or a tool response is never an ownership proof.
|
||||
export async function stageSessionToolOutputs(
|
||||
command: string, workspace: string, sessionId: string,
|
||||
messages: Awaited<ReturnType<OpencodeRuntimeAdapter["messages"]>>,
|
||||
) {
|
||||
const sources = new Set<string>();
|
||||
const root = nativeToolOutputDirectory();
|
||||
for (const message of messages) for (const part of message.parts) {
|
||||
if (part.type !== "tool" || part.state.status !== "completed") continue;
|
||||
const metadata = part.state.metadata;
|
||||
const path = metadata?.outputPath;
|
||||
if (metadata?.truncated === true && typeof path === "string" && command.includes(path)
|
||||
&& dirname(path) === root && /^tool_[a-zA-Z0-9]+$/.test(basename(path))) sources.add(path);
|
||||
}
|
||||
if (!sources.size) return [];
|
||||
const canonicalRoot = await realpath(root);
|
||||
const directory = await openArtifactDirectory(workspace, sessionId);
|
||||
const files: Array<{ source_path: string; relative_path: string; shell_path: string; size: number; sha256: string }> = [];
|
||||
let remaining = 128 * 1024 * 1024;
|
||||
try {
|
||||
for (const path of sources) {
|
||||
if (await realpath(path) !== join(canonicalRoot, basename(path))) throw new Error("Unsafe tool output path");
|
||||
const source = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
const stat = await source.stat();
|
||||
if (!stat.isFile() || stat.size > remaining) throw new Error("Tool output staging budget exceeded");
|
||||
bytes = await source.readFile();
|
||||
if (bytes.length > remaining) throw new Error("Tool output staging budget exceeded");
|
||||
remaining -= bytes.length;
|
||||
} finally { await source.close(); }
|
||||
const filename = `tool-output-${randomUUID()}.txt`;
|
||||
const destination = await open(`/proc/self/fd/${directory.fd}/${filename}`,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
await destination.writeFile(bytes);
|
||||
if (typeof process.getuid === "function" && process.getuid() === 0) await destination.chown(SANDBOX_UID, SANDBOX_GID);
|
||||
} finally { await destination.close(); }
|
||||
files.push({ source_path: path, relative_path: `files/${filename}`, shell_path: filename, size: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex") });
|
||||
}
|
||||
} finally { await directory.close(); }
|
||||
return files;
|
||||
}
|
||||
@@ -156,19 +156,21 @@ describe("agent model config", () => {
|
||||
});
|
||||
|
||||
it("exposes only the two configured gateway models with their roles, icons and context limits", () => {
|
||||
expect(supportedModels).toEqual(["waternetwork/gpt-5.6-terra", "waternetwork/gpt-5.6-sol"]);
|
||||
expect(supportedModels).toEqual(["waternetwork/deepseek-flash", "waternetwork/gpt-6.1-sol"]);
|
||||
expect(agentModelOptions.map(({ id, label, icon }) => ({ id, label, icon }))).toEqual([
|
||||
{ id: "waternetwork/gpt-5.6-terra", label: "快速", icon: "bolt" },
|
||||
{ id: "waternetwork/gpt-5.6-sol", label: "专家", icon: "sparkle" },
|
||||
{ id: "waternetwork/deepseek-flash", label: "快速", icon: "bolt" },
|
||||
{ id: "waternetwork/gpt-6.1-sol", label: "专家", icon: "sparkle" },
|
||||
]);
|
||||
expect(isSupportedModel("deepseek/deepseek-flash")).toBe(false);
|
||||
expect(isSupportedModel("waternetwork/deepseek-flash")).toBe(false);
|
||||
expect(isSupportedModel("waternetwork/deepseek-flash")).toBe(true);
|
||||
expect(isSupportedModel("waternetwork/gpt-5.6-terra")).toBe(false);
|
||||
expect(isSupportedModel("waternetwork/gpt-5.6-sol")).toBe(false);
|
||||
expect(isSupportedModel("waternetwork/gpt-image-2.5")).toBe(false);
|
||||
expect(isSupportedModel("waternetwork/codex-auto-review")).toBe(false);
|
||||
const native = serviceOpencodeConfig({});
|
||||
expect(Object.keys(native.providers)).toEqual(["waternetwork"]);
|
||||
expect(native.model).toBe("waternetwork/gpt-5.6-terra");
|
||||
expect(native.providers.waternetwork.models["gpt-5.6-terra"].limit?.context).toBe(272000);
|
||||
expect(native.providers.waternetwork.models["gpt-5.6-sol"].limit?.context).toBe(475000);
|
||||
expect(native.model).toBe("waternetwork/deepseek-flash");
|
||||
expect(native.providers.waternetwork.models["deepseek-flash"].limit?.context).toBe(272000);
|
||||
expect(native.providers.waternetwork.models["gpt-6.1-sol"].limit?.context).toBe(475000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,9 +35,10 @@ describe("unified view commands", () => {
|
||||
});
|
||||
it("passes aligned historical scatter points through native and publication contracts", () => {
|
||||
const command = { action: "open_chart", chart_id: "pressure-comparison", evidence_refs: ["E1"], chart: {
|
||||
title: "压力七天对比", chart_type: "line", y_axis_name: "压力(m)",
|
||||
title: "压力七天对比", chart_type: "time_comparison", y_axis_name: "压力(m)",
|
||||
x_data: ["19:30", "19:45", "20:00"], series: [
|
||||
{ name: "当前", type: "line", data: [32, null, 31] },
|
||||
{ name: "当前", type: "line", data: [32, null, 31],
|
||||
sample_times: ["2026-06-14T19:30:00+08:00", null, "2026-06-14T20:00:00+08:00"] },
|
||||
{ name: "前一天", type: "scatter", data: [null, 32, 33],
|
||||
sample_times: [null, "2026-06-13T19:45:00+08:00", "2026-06-13T20:00:00+08:00"] },
|
||||
],
|
||||
|
||||
@@ -17,6 +17,30 @@ afterEach(async () => {
|
||||
});
|
||||
const productTool: Tool = { name: "get_task_context", description: "Bound task", inputSchema: { type: "object", properties: { report_revision: { type: "integer", minimum: 1 } }, additionalProperties: false } };
|
||||
|
||||
it("delivers complete SCADA metadata and observations through native MCP without narrowing device scope", async () => {
|
||||
const data = {
|
||||
devices: Array.from({ length: 168 }, (_, index) => ({ device_id: `D${index}`, node_id: `J${index}`, description: "x".repeat(100) })),
|
||||
readings: Array.from({ length: 2184 }, (_, index) => ({ device_id: `D${index % 168}`, monitored_value: index, cleaned_value: null })),
|
||||
coverage: { mode: "all_scada", query_complete: true, queried_device_count: 168, reading_count: 2184 },
|
||||
};
|
||||
const upstream = { content: [{ type: "text" as const, text: JSON.stringify(data) }], structuredContent: {
|
||||
source: "query_scada_readings", evidence_id: "mcp:all-scada", data, truncated: false,
|
||||
} };
|
||||
const query = mock(async () => upstream);
|
||||
const tool: Tool = { name: "query_scada_readings", inputSchema: { type: "object", properties: {
|
||||
start: { type: "string" }, end: { type: "string" },
|
||||
device_ids: { type: "array", items: { type: "string" }, maxItems: 20 },
|
||||
}, required: ["start", "end"], additionalProperties: false } };
|
||||
const { call } = await setup({ productTools: [tool], productClient: { query } });
|
||||
setRuntimeSessionContext({ ...owner, purpose: "task_execution", taskId: "scada-task" });
|
||||
const args = { start: "2026-07-16T05:00:00+08:00", end: "2026-07-16T08:00:00+08:00" };
|
||||
const result = await call("query_scada_readings", args);
|
||||
expect(result.isError).not.toBeTrue();
|
||||
expect((query.mock.calls[0] as unknown as unknown[])[3]).toEqual(args);
|
||||
expect(result.structuredContent).toEqual(upstream.structuredContent);
|
||||
expect(JSON.stringify(result.structuredContent).length).toBeGreaterThan(12000);
|
||||
});
|
||||
|
||||
it("hides HTTP budgets from models and fixes the native connection ceiling", async () => {
|
||||
const simulation: Tool = { name: "run_simulation", inputSchema: { type: "object", properties: {
|
||||
timeout_seconds: { type: "integer", minimum: 1, maximum: 240, default: 120 },
|
||||
|
||||
@@ -3,11 +3,14 @@ import { afterEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
bindOperationalTaskCredential,
|
||||
humanTaskEvidenceCommandSchema,
|
||||
hasOperationalTaskRun,
|
||||
OperationalTaskClient,
|
||||
proposedTaskActionSchema,
|
||||
releaseOperationalTaskCredential,
|
||||
} from "../../src/product/operational-task-client.js";
|
||||
|
||||
import { runBackgroundTaskExecution } from "../../src/routes/chatSessionRoutes.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const taskId = "50000000-0000-4000-8000-000000000001";
|
||||
const runId = "55000000-0000-4000-8000-000000000001";
|
||||
@@ -364,3 +367,28 @@ describe("OperationalTaskClient", () => {
|
||||
expect(requestBodies[2]).toMatchObject({ expected_revision: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it.each([true, false])("retains lease state on background stream loss only while running=%s", async (running) => {
|
||||
const credential = { authorization: "Bearer fixture", projectId: "project-1" };
|
||||
bindOperationalTaskCredential("runtime-1", credential);
|
||||
globalThis.fetch = (async () => Response.json(run)) as unknown as typeof fetch;
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
await client.claimTask("runtime-1", taskId, runId, {
|
||||
requestId: executionRequestId, expectedRevision: 1, sourceSessionId: "session-1",
|
||||
});
|
||||
expect(hasOperationalTaskRun("runtime-1")).toBe(true);
|
||||
globalThis.fetch = (async () => new Response(new ReadableStream({
|
||||
start(controller) { controller.error(new Error("fixture stream disconnected")); },
|
||||
}))) as unknown as typeof fetch;
|
||||
await runBackgroundTaskExecution({ sessionId: "runtime-1", credential, model: "fixture/model",
|
||||
isRunning: () => running });
|
||||
expect(hasOperationalTaskRun("runtime-1")).toBe(running);
|
||||
if (running) {
|
||||
globalThis.fetch = (async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
expect(JSON.parse(String(init?.body)).expected_revision).toBe(2);
|
||||
return Response.json({ ...run, revision: 3 });
|
||||
}) as unknown as typeof fetch;
|
||||
await client.heartbeatTask("runtime-1", taskId, runId, "session-1");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -714,3 +714,40 @@ describe("streamPromptResponse", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe("background sandbox permissions", () => {
|
||||
it.each([["permission.asked", true], ["permission.v2.asked", true],
|
||||
["permission.asked", false], ["permission.v2.asked", false]] as const)("limits session Shell/edit approval for %s background=%s", async (type, background) => {
|
||||
const replies: Array<Record<string, unknown>> = [];
|
||||
const runtime = {
|
||||
subscribeEvents: async () => createEventStream([
|
||||
...["shell", "edit", "mcp_fixture_write"].map((action) => ({
|
||||
type,
|
||||
properties: {
|
||||
id: action, sessionID: "runtime-session-1",
|
||||
...(type === "permission.asked"
|
||||
? { permission: action, patterns: ["fixture"], always: [] }
|
||||
: { action, resources: ["fixture"], save: [] }),
|
||||
},
|
||||
})),
|
||||
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [],
|
||||
replyPermission: async (input: Record<string, unknown>) => { replies.push(input); },
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
await streamPromptResponse({
|
||||
runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1",
|
||||
message: "后台调查", approvalMode: "request", backgroundSandboxPermissions: background,
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
expect(replies).toEqual(background ? [
|
||||
{ requestId: "shell", sessionId: "runtime-session-1", reply: "once" },
|
||||
{ requestId: "edit", sessionId: "runtime-session-1", reply: "once" },
|
||||
] : []);
|
||||
expect(events.filter(({ event }) => event === "permission_request")
|
||||
.map(({ data }) => data.permission)).toEqual(background ? ["mcp_fixture_write"] : ["shell", "edit", "mcp_fixture_write"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import { type Server } from "node:http";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { config } from "../../src/config.js";
|
||||
@@ -11,6 +11,7 @@ import { registerSandboxToolBridge } from "../../src/routes/sandboxToolBridge.js
|
||||
|
||||
let server: Server | undefined;
|
||||
let root: string | undefined;
|
||||
const previousDataHome = process.env.XDG_DATA_HOME;
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
const session = "sandbox-bridge-test";
|
||||
afterEach(async () => {
|
||||
@@ -18,6 +19,8 @@ afterEach(async () => {
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
config.CONVERSATION_WORKSPACE_ROOT = previousRoot;
|
||||
removeRuntimeSessionContext(session);
|
||||
if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||
else process.env.XDG_DATA_HOME = previousDataHome;
|
||||
});
|
||||
describe("sandbox execution bridge", () => {
|
||||
it("executes only after approval and cannot override the owning workspace", async () => {
|
||||
@@ -31,6 +34,7 @@ describe("sandbox execution bridge", () => {
|
||||
const requested = new Promise<void>((resolve) => { ready = resolve; });
|
||||
const app = express(); app.use(express.json());
|
||||
registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: {
|
||||
messages: async () => [],
|
||||
ensureSessionWorkspace: async (id) => { expect(id).toBe(session); return directory; },
|
||||
approveSandboxShell: async (input) => { if (approved) return; expect(input.command).toContain("approved.txt"); ready(); await new Promise<void>((resolve) => { approve = resolve; }); approved = true; },
|
||||
} });
|
||||
@@ -55,3 +59,50 @@ describe("sandbox execution bridge", () => {
|
||||
expect(await readFile(large.stdout_file, "utf8")).toBe(`${"x".repeat(10000)}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("stages only this session's native output and keeps the global directory sandboxed", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "sandbox-output-test-"));
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
process.env.XDG_DATA_HOME = join(root, "native");
|
||||
const output = join(process.env.XDG_DATA_HOME, "opencode", "tool-output");
|
||||
await mkdir(output, { recursive: true });
|
||||
const own = join(output, "tool_own"), other = join(output, "tool_other");
|
||||
const content = JSON.stringify({ evidence_id: "fixture", readings: "完整结果".repeat(1000) });
|
||||
await writeFile(own, content); await writeFile(other, "other session");
|
||||
const directory = await createConversationWorkspace(root);
|
||||
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "owner", projectKey: "project", traceId: "test" });
|
||||
const metadata = { truncated: true, outputPath: own };
|
||||
const app = express(); app.use(express.json());
|
||||
registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: {
|
||||
ensureSessionWorkspace: async () => directory,
|
||||
approveSandboxShell: async () => {},
|
||||
messages: async () => [{ info: { id: "native", role: "assistant" }, parts: [{
|
||||
type: "tool", state: { status: "completed", metadata },
|
||||
}] }] as never,
|
||||
} });
|
||||
server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server!.once("listening", resolve));
|
||||
const post = async (command: string) => {
|
||||
const response = await fetch(`http://127.0.0.1:${(server!.address() as { port: number }).port}/internal/tools/sandbox-shell`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY" },
|
||||
body: JSON.stringify({ session_id: session, call_id: "fixture", message_id: "fixture", command }),
|
||||
});
|
||||
return { status: response.status, data: await response.json() as Record<string, any> };
|
||||
};
|
||||
const staged = await post(`cat '${own}'`);
|
||||
expect(staged.status).toBe(200);
|
||||
expect(staged.data.outcome).toBe("requires_session_path");
|
||||
const file = staged.data.files[0];
|
||||
expect(await readFile(join(directory, file.relative_path), "utf8")).toBe(content);
|
||||
const result = await post(`python3 -c 'import hashlib; print(hashlib.sha256(open("${file.shell_path}","rb").read()).hexdigest())'`);
|
||||
expect(result.data.exitCode).toBe(0);
|
||||
expect(result.data.stdout.trim()).toBe(file.sha256);
|
||||
const forbidden = await post(`cat '${other}'`);
|
||||
expect(forbidden.data.exitCode).not.toBe(0);
|
||||
expect(forbidden.data.stderr).toContain("Permission denied");
|
||||
const link = join(output, "tool_link");
|
||||
await symlink(other, link);
|
||||
metadata.outputPath = link;
|
||||
expect((await post(`cat '${link}'`)).status).toBe(503);
|
||||
});
|
||||
|
||||
@@ -313,9 +313,9 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
} finally { await cleanup(); }
|
||||
});
|
||||
it("rejects an invalid non-default model before readiness and cleans up the session", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-5.6-sol");
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-6.1-sol");
|
||||
try {
|
||||
await expect(runtime.warmup()).rejects.toThrow("waternetwork/gpt-5.6-sol: not_advertised");
|
||||
await expect(runtime.warmup()).rejects.toThrow("waternetwork/gpt-6.1-sol: not_advertised");
|
||||
expect(calls).not.toContain("model.list");
|
||||
expect(calls.at(-1)).toBe("session.remove");
|
||||
} finally { await cleanup(); }
|
||||
|
||||
@@ -84,6 +84,22 @@ it("keeps burst event handling separate from SCADA candidate location", () => {
|
||||
expect(allowedQueryTools({ ...context, taskId: undefined, purpose: "ordinary_chat" })).toContain(queryMcpToolId("run_burst_location"));
|
||||
});
|
||||
|
||||
it("continues candidate analysis through the declared capability and records provenance without stopping", () => {
|
||||
const config = serviceOpencodeConfig({});
|
||||
for (const system of [config.agents.instruction.system, config.agents[TASK_AGENT].system]) {
|
||||
expect(system).toContain("location_capability.available=true");
|
||||
expect(system).toContain("burst_leakage_required=false 时可省略漏量");
|
||||
expect(system).toContain("取得候选管段与相似度后,继续地图展示、隔离边界、覆盖与影响分析,并汇入报告。");
|
||||
expect(system).toContain("直接返回结果时立即读取");
|
||||
expect(system).not.toMatch(/location_demo|真实定位|演示定位|轻量分析或 demo/);
|
||||
}
|
||||
const investigation = config.agents[TASK_AGENT].system;
|
||||
expect(investigation).toContain("synthetic、回放等来源标记本身不构成停止条件");
|
||||
expect(investigation).toContain("仅暂停依赖该条件的分支");
|
||||
expect(investigation).toContain("本轮分析完成与现场处置完成分别表述");
|
||||
expect(investigation).toContain("不直接派发现场工单或操作设备");
|
||||
});
|
||||
|
||||
|
||||
it("keeps administrator denials for product MCP queries in both primary and reader permissions", () => {
|
||||
const denied = serviceOpencodeConfig({ permissions: [{ action: "mcp_tjwater_*", resource: "*", effect: "deny" }] });
|
||||
|
||||
Reference in New Issue
Block a user