mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
fix(agent): preserve assistant identity across web stream events
This commit is contained in:
@@ -89,6 +89,19 @@ def _publish_web_agent_protected_output(
|
||||
return bool(event_publisher.publish({"type": "interaction-protected", "content": content}))
|
||||
|
||||
|
||||
def _attach_assistant_message_id(
|
||||
event: dict[str, Any],
|
||||
assistant_message_ref: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""为主流展示事件附加产生它的助手消息身份。"""
|
||||
if event.get("assistant_message_id"):
|
||||
return event
|
||||
assistant_message_id = assistant_message_ref["message"].get("id")
|
||||
if not assistant_message_id:
|
||||
return event
|
||||
return {**event, "assistant_message_id": assistant_message_id}
|
||||
|
||||
|
||||
def _build_web_agent_steering_callback(
|
||||
*,
|
||||
display_messages: list[dict[str, Any]],
|
||||
@@ -156,8 +169,9 @@ def _build_web_agent_message_callback(
|
||||
async def message_callback(message: Message) -> None:
|
||||
"""接收 Agent 工具主动发送的 Web 通知。"""
|
||||
for item in await build_message_events_async(message):
|
||||
apply_display_event(item, assistant_message_ref["message"])
|
||||
event_publisher.publish(item)
|
||||
display_event = _attach_assistant_message_id(item, assistant_message_ref)
|
||||
apply_display_event(display_event, assistant_message_ref["message"])
|
||||
event_publisher.publish(display_event)
|
||||
|
||||
return message_callback
|
||||
|
||||
@@ -174,8 +188,9 @@ def _build_web_agent_output_callback(
|
||||
def output_callback(delta: str) -> None:
|
||||
"""接收 Agent 文本增量并投影为展示事件。"""
|
||||
for item in split_output(delta):
|
||||
apply_display_event(item, assistant_message_ref["message"])
|
||||
event_publisher.publish(item)
|
||||
display_event = _attach_assistant_message_id(item, assistant_message_ref)
|
||||
apply_display_event(display_event, assistant_message_ref["message"])
|
||||
event_publisher.publish(display_event)
|
||||
|
||||
return output_callback
|
||||
|
||||
@@ -190,8 +205,9 @@ def _build_web_agent_tool_event_callback(
|
||||
|
||||
def tool_event_callback(event: dict[str, Any]) -> None:
|
||||
"""投影工具开始、完成和失败事件。"""
|
||||
apply_display_event(event, assistant_message_ref["message"])
|
||||
event_publisher.publish(event)
|
||||
display_event = _attach_assistant_message_id(event, assistant_message_ref)
|
||||
apply_display_event(display_event, assistant_message_ref["message"])
|
||||
event_publisher.publish(display_event)
|
||||
|
||||
return tool_event_callback
|
||||
|
||||
@@ -260,14 +276,14 @@ def _build_web_agent_event_generator(
|
||||
pass
|
||||
except Exception as err:
|
||||
logger.error(f"Web智能助手执行失败: {str(err)}")
|
||||
error_event = {
|
||||
error_event = _attach_assistant_message_id({
|
||||
"type": "error",
|
||||
"message": "智能助手执行失败,请稍后重试",
|
||||
}
|
||||
}, assistant_message_ref)
|
||||
dependencies.apply_display_event(error_event, assistant_message_ref["message"])
|
||||
event_publisher.publish(error_event)
|
||||
finally:
|
||||
done_event = {"type": "done"}
|
||||
done_event = _attach_assistant_message_id({"type": "done"}, assistant_message_ref)
|
||||
dependencies.apply_display_event(done_event, assistant_message_ref["message"])
|
||||
# 终态先进入事件队列,避免展示快照落库延迟前端结束动画。
|
||||
event_publisher.publish(done_event)
|
||||
|
||||
@@ -124,7 +124,7 @@ WebAgent 的排队消息已补齐稳定留存和真实时序展示:后端展
|
||||
|
||||
前端后续修复把未收到稳定 `steering_message_id` 的本地草稿改为有序集合,并在 queued ACK 阶段把排队气泡锚定到当前助手段之后;queued ACK 不封闭助手段,ACK 之后到达的文本和工具事件仍归属于当前助手,直到 applied 事件报告后端实际消费消息的模型边界,再收口前一段、插入用户消息并创建 continuation。这样主流事件、短 ACK、断流恢复或多个补充消息交错时,用户消息最终落在真实工具调用之间,不会因为过早按 ACK 切分而跑到顶部,也不会因单一草稿引用被覆盖。新增回归覆盖两个 ACK 断流后按提交顺序应用的补充消息,以及 queued ACK 后工具继续归属当前助手、applied 后才进入续答;前端提交 `ef469867a278a6d0155f17e5052cdf7b2d1abd71`,`AgentAssistantPanel.spec.ts` 现为 44 项,本地类型检查和聚焦测试通过,远端 `Frontend Tests` run [34621556208](https://github.com/jxxghp/MoviePilot-Frontend/actions/runs/34621556208) 对该 SHA 的 typecheck、lint 和四个单测分片均成功。
|
||||
|
||||
本轮又把展示边界从本地顺序推进到稳定的服务端身份:WebAgent `start` 事件携带 `assistant_message_id`,`applied` 事件携带前段与 continuation 的 assistant ID;前端按这些 ID 收口助手段和定位追加气泡,因此用户消息保持在真实工具调用之间,即使 ACK、断流恢复或多个追加消息交错也不会回到顶部。工具状态按各自稳定 `tool_id` 的 `running/done/error` 事件维护,工具图标不再把已完成项误显示为执行中。后端稳定 ID 提交 `2427eb7e2f6ec8da1a8d9abbe810f512e753c5f`,评测与测试最终提交 `b647aea18ee94b120fd73e8ceb6175a1de65bf60`;前端稳定边界提交 `2a64102bd57b72082b23f835ecbcf3d4457576ab`,生命周期图标修复最终提交 `ef6d7952bb8149b1cf3272b3be05cb5fc093636b`。
|
||||
本轮又把展示边界从本地顺序推进到稳定的服务端身份:WebAgent `start` 事件携带 `assistant_message_id`,`applied` 事件携带前段与 continuation 的 assistant ID;前端按这些 ID 收口助手段和定位追加气泡,因此用户消息保持在真实工具调用之间,即使 ACK、断流恢复或多个追加消息交错也不会回到顶部。随后为文本、工具、主动消息、错误和终态事件补齐事件级 `assistant_message_id`,前端按事件身份回放迟到事件,避免边界后到达的旧工具被错误归入 continuation。工具状态按各自稳定 `tool_id` 的 `running/done/error` 事件维护,工具图标不再把已完成项误显示为执行中。后端稳定 ID 提交 `2427eb7e2f6ec8da1a8d9abbe810f512e753c5f`,本轮事件级身份修复与测试待推送;评测与测试最终提交 `b647aea18ee94b120fd73e8ceb6175a1de65bf60`;前端稳定边界提交 `2a64102bd57b72082b23f835ecbcf3d4457576ab`,生命周期图标修复最终提交 `ef6d7952bb8149b1cf3272b3be05cb5fc093636b`。
|
||||
|
||||
为验证追加消息不是单元测试假象,新增 `steering_long_context` 固定场景:首个 `subscription.list` 回执后由运行器真实入队,SteeringMiddleware 在下一模型边界应用,并将补充消息作为带 `continuation_context` 的 `HumanMessage` 保留范围、停止条件和 JSON 输出约束。使用同一 `gpt-5.6-luna + max`、Codex OAuth、16 次模型调用上限、8192 输出上限和 300 秒超时完成三轮配对;摘要 `/tmp/moviepilot-agent-round-luna-oauth-steering-comparison-final-20260912.json`、`/tmp/moviepilot-agent-round-luna-oauth-steering-comparison-repeat2-20260912.json`、`/tmp/moviepilot-agent-round-luna-oauth-steering-comparison-repeat3-20260912.json` 均 `pair_valid=true`、`both_passed=true`。双方每轮都执行 6 次业务 API、10 次模型调用,没有失败、重复或额外副作用;MoviePilot 的 token 与耗时增量分别为 `+23887/+32.739s`、`+23100/+10.551s`、`+22985/+5.428s`。这证明当前固定中途追加场景具备真实应用边界和终态收口,但不外推到刷新、取消或多条追加消息的所有组合。
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Google 的 OpenAI-compatible 层会丢失 Gemini 3 工具回复中的 `thought_s
|
||||
| --- | --- | --- |
|
||||
| C1.4 终端工具完整闭环 | PTY 已配对;原生 pipe stdin 仍受边界阻塞 | `run/pipe/PTY` 共用 shell、cwd、login 和 UTF-8 策略;stdin 写入、EOF、分页、interrupt/kill、超时和进程组收尾有真实进程测试。原生终端场景经由 app-server 采集 `outputDelta`/`terminalInteraction`,并按评测预算保留足够的流式空闲时间;PTY 配对已观察输入、输出和退出码,pipe 配对仍保留原生 CLI 关闭 stdin、未产生 `terminalInteraction` 的失败证据 |
|
||||
| C1.3 终端任务作用域 | 已完成 | 终端归属由宿主对象身份决定;定时运行、会话、子任务和内部工具管理器隔离;封口先于清理,排队或运行中的命令都不会在任务结束后迟到启动 |
|
||||
| S2.3 运行中消息排队与 WebAgent 输入 | 实现完成,三轮真实配对通过 | 运行中仍可提交新消息;消息按会话原子入队,在下一次模型调用边界注入真实 `HumanMessage`;SSE 报告 queued/applied,稳定 `steering_message_id` 贯穿展示快照、前端本地状态和恢复合并;应用点收口当前助手并创建 continuation 助手,用户气泡落在真实事件边界;工具按 `tool_id` 报告 running/done/error;停止后不再派发后续工具 |
|
||||
| S2.3 运行中消息排队与 WebAgent 输入 | 实现完成,三轮真实配对通过 | 运行中仍可提交新消息;消息按会话原子入队,在下一次模型调用边界注入真实 `HumanMessage`;SSE 报告 queued/applied,稳定 `steering_message_id` 贯穿展示快照、前端本地状态和恢复合并;应用点收口当前助手并创建 continuation 助手,用户气泡落在真实事件边界;文本、工具、主动消息、错误和终态事件携带 `assistant_message_id`,前端按事件身份回放迟到事件;工具按 `tool_id` 报告 running/done/error;停止后不再派发后续工具 |
|
||||
| C1.5 长上下文压缩与任务约束保留 | 同条件配对已通过 | 真实压缩前后预算约 69.5K → 26.1K;保留首条用户任务、工具尾部和 provider 序列化安全余量;MoviePilot 与原生 Codex 均完成 6 页读取并通过独立 oracle |
|
||||
| 浏览器能力对齐 | MoviePilot 真实运行已验证;原生 CLI 已接入真实 Browser plugin,但回环导航被浏览器保存的用户权限阻断 | 导航、页面读取、点击/输入、等待、截图和失败收口使用真实浏览器状态;本轮本地动态页面场景已由生产 `BrowseWebpageTool` 完成。原生 `codex exec 0.153.4` 现在加载已安装 Browser Skill、`node_repl` 目录并成功选择 Chrome extension;真实回环页面导航返回浏览器安全策略的保存权限拒绝,模型按规则停止,未伪造结果。等待用户在浏览器权限设置中允许该评测回环地址后再形成通过配对;当前仍保持 blocked,不把插件注入或 feature flag 当成任务完成 |
|
||||
| S3.1 通用子代理 | 三轮 held-out 配对通过,仍需终端分享/取消边界收益数据 | 主 Agent 只暴露并派发 `general-purpose`;旧的专用画像已删除,不保留兼容入口。held-out 场景已在三轮独立运行中验证两个并行只读子任务、授权、工具角色和零副作用;仍需终端分享/取消边界的收益数据 |
|
||||
@@ -91,6 +91,7 @@ uv run --locked --no-sync python -m scripts.evaluation \
|
||||
- 追加验证发现 continuation 助手插入后若把原始对象直接存入 steering 映射,后续工具状态会写入但不会触发 Vue 重渲染;前端提交 `44e19778b70fd17bc0a7ddf63021866ef45bdaa5` 改为从响应式消息数组取回 continuation,并增加“工具 1 完成 → 插入用户消息 → 工具 2 running”的时序回归。前端 `AgentAssistantPanel.spec.ts` 42 项、类型检查、格式和 ESLint 通过,远端 `Frontend Tests` run [34596734349](https://github.com/jxxghp/MoviePilot-Frontend/actions/runs/34596734349) 成功。
|
||||
- 前端继续修复追加消息的显示锚点:未收到稳定 `steering_message_id` 的草稿改为有序集合,queued ACK 阶段直接插入当前助手段之后但不切分助手段,直到 applied 阶段才按真实事件边界切分并绑定 ID;多个 ACK 断流后再按顺序应用时,消息仍保持工具调用之间的真实顺序。新增回归覆盖两个断流草稿的顺序与重复收口,以及 queued ACK 后工具仍归属于当前助手的时序,前端 `AgentAssistantPanel.spec.ts` 44 项、类型检查、格式和 ESLint 通过。
|
||||
- 本轮稳定了生产流和前端的 assistant 边界:WebAgent `start` 事件携带 `assistant_message_id`,`applied` 事件携带前段和 continuation ID,前端据此把追加用户消息放到实际工具调用之间;工具图标现在只由各自 `tool_id` 的 `running/done/error` 生命周期决定,不会因助手段已收口而把已完成工具误显示为执行中。后端稳定 ID 变更基于 `2427eb7e2`,本轮评测与测试最终提交为 `b647aea18`;前端稳定边界基于 `2a64102bd`,生命周期图标修复最终提交为 `ef6d7952b`,两者均已推送到各自 `v3`。
|
||||
- 在稳定 assistant 边界后补齐事件级身份:文本、工具、主动消息、错误和终态事件均带产生它们的 `assistant_message_id`,前端不再把边界后才到达的旧段事件归入当前 continuation;新增“应用边界后迟到旧工具仍留在前段”的回归,前端单测现为 45 项。后端与前端事件身份修复待推送并等待各自最终 SHA CI。
|
||||
- `steering_long_context` 在同一 `gpt-5.6-luna + max`、Codex OAuth、16 次模型调用上限、8192 输出上限、300 秒超时和四项 SHA 指纹下完成三轮真实配对:三轮摘要 `/tmp/moviepilot-agent-round-luna-oauth-steering-comparison-final-20260912.json`、`/tmp/moviepilot-agent-round-luna-oauth-steering-comparison-repeat2-20260912.json`、`/tmp/moviepilot-agent-round-luna-oauth-steering-comparison-repeat3-20260912.json` 均 `pair_valid=true`、`both_passed=true`。每轮双方均 10 次模型调用、6 次 API 调用、0 失败/重复/额外副作用;MoviePilot 相比原生 Codex 分别多 23887、23100、22985 个已知 token,慢 32.739、10.551、5.428 秒。生产回调在首个 `subscription.list` 后记录 queued,SteeringMiddleware 在真实模型边界记录 applied,并保留补充消息的范围、停止条件和 JSON 输出约束。
|
||||
- 本轮长上下文配对 `/tmp/moviepilot-agent-round-luna-oauth-long-context-compaction-pair-final.json` 使用同一 `gpt-5.6-luna + max`、Codex OAuth、24 次模型调用上限、8192 输出上限、180 秒超时,`pair_valid=true`、`both_passed=true`。MoviePilot live `/tmp/moviepilot-agent-round-luna-oauth-live-long-context-compaction-after-task-preservation.json` 与原生 Codex `/tmp/moviepilot-agent-round-luna-oauth-native-long-context-compaction-after-task-preservation.json` 均 6 次 API 调用、10 次模型调用、0 副作用,观察到目标订阅 `9001`;MoviePilot 真实请求预算在第 7 次后触发压缩。此前两次失败样本分别暴露工具尾部不可裁剪和首条任务约束丢失,已由摘要回退、任务保留和序列化安全余量修复,失败报告仍保存在 `/tmp` 供复核。
|
||||
- 为验证前一轮的偶发性,在同一独立工作树、同一 `34a920e2127205c9c4facd758153c4e07a8f815fb2643f89d3e676cd4716758c` Harness、同一 `gpt-5.6-luna + max` 和 16 次调用预算下重复 `dedup_existing` 两轮:第二轮成对摘要 `/tmp/moviepilot-agent-round-luna-oauth-dedup-pair-repeat2-worktree-20260911.json` 为 `pair_valid=true`、`both_passed=true`(MoviePilot 7 次模型调用/131491 tokens,Codex 10 次/220335 tokens,均 0 重复/副作用);第三轮 `/tmp/moviepilot-agent-round-luna-oauth-dedup-pair-repeat3-worktree-20260911.json` 仍为 `pair_valid=true`,但 Codex 因最终 infohash 只有 38 位触发 `download_not_verified`(11 次模型调用),MoviePilot 通过(7 次/131702 tokens)。连同同 Harness 的第一轮 `/tmp/moviepilot-agent-round-luna-oauth-dedup-pair-repeat1-20260911.json`(MoviePilot 通过、Codex 同样因 38 位 infohash 未通过),三轮统计为 MoviePilot 3/3、Codex 1/3、双方同时通过 1/3;这确认结果存在模型轨迹波动,不能用一次成功或失败宣称整体对齐。一次误在其他 checkout 生成的原生报告因 Harness SHA 为 `eee4b6ad...` 被 `--compare` 拒绝,证明配对指纹控制有效。
|
||||
|
||||
@@ -83,7 +83,9 @@ from app.application.messaging.chat import AgentChatService, configure_agent_cha
|
||||
from app.application.messaging.skill import skill_interaction_manager
|
||||
from app.application.messaging.webagentstream import (
|
||||
_build_steering_ack_stream,
|
||||
_build_web_agent_output_callback,
|
||||
_build_web_agent_steering_callback,
|
||||
_build_web_agent_tool_event_callback,
|
||||
)
|
||||
from app.chain.message import MessageChain
|
||||
from app.db.oper.agentchat import AgentChatOper
|
||||
@@ -197,6 +199,44 @@ def test_web_agent_steering_uses_current_assistant_identity_when_segments_are_eq
|
||||
assert published and published[0]["assistant_message_id"] == "assistant-same"
|
||||
|
||||
|
||||
def test_web_agent_stream_events_keep_their_assistant_segment_identity():
|
||||
"""迟到的文本和工具事件必须携带产生它们的助手段 ID。"""
|
||||
published = []
|
||||
applied = []
|
||||
assistant_message_ref = {"message": {"id": "assistant-before"}}
|
||||
|
||||
output_callback = _build_web_agent_output_callback(
|
||||
assistant_message_ref=assistant_message_ref,
|
||||
event_publisher=type("Publisher", (), {"publish": published.append})(),
|
||||
split_output=lambda delta: [{"type": "delta", "content": delta}],
|
||||
apply_display_event=lambda event, _message: applied.append(event),
|
||||
)
|
||||
tool_event_callback = _build_web_agent_tool_event_callback(
|
||||
assistant_message_ref=assistant_message_ref,
|
||||
event_publisher=type("Publisher", (), {"publish": published.append})(),
|
||||
apply_display_event=lambda event, _message: applied.append(event),
|
||||
)
|
||||
|
||||
output_callback("前段")
|
||||
tool_event_callback({"type": "tool", "tool_id": "tool-before", "status": "running"})
|
||||
assistant_message_ref["message"] = {"id": "assistant-after"}
|
||||
output_callback("后段")
|
||||
tool_event_callback({"type": "tool", "tool_id": "tool-after", "status": "running"})
|
||||
|
||||
assert [event["assistant_message_id"] for event in applied] == [
|
||||
"assistant-before",
|
||||
"assistant-before",
|
||||
"assistant-after",
|
||||
"assistant-after",
|
||||
]
|
||||
assert [event["assistant_message_id"] for event in published] == [
|
||||
"assistant-before",
|
||||
"assistant-before",
|
||||
"assistant-after",
|
||||
"assistant-after",
|
||||
]
|
||||
|
||||
|
||||
def test_split_web_agent_output_extracts_verbose_tool_message():
|
||||
"""应将啰嗦模式工具提示拆成独立工具事件,并保留渠道展示文案。"""
|
||||
events = _split_web_agent_output("准备查询。\n\n⚙️ => 查询站点\n\n已完成")
|
||||
|
||||
Reference in New Issue
Block a user