From d7f0c451641a64d974c8cea5501a2a13f51a2e07 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Jul 2026 21:34:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=8F=96=E7=94=BB=E5=B8=83=20Agent=20?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E6=89=A7=E8=A1=8C=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增业务中立的 function-calling harness 与公共提示词协议 统一画布 Agent 工具路由约束和待确认控制语义 保留失败前输出并事务化收口 memory、deadline 与取消状态 迁移画布 Agent 和 API 编排并同步架构文档与项目记忆 --- .../shared-memory/decision-log.md | 12 +- docs/project-memory/shared-memory/pitfalls.md | 15 +- ...构下线】旧创作模板业务退役方案-2026-07-17.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 +- .../【编辑器】画布Agent对话面板-2026-07-03.md | 14 +- server-rs/Cargo.lock | 10 + server-rs/Cargo.toml | 2 + .../crates/api-server/src/editor_agent/api.rs | 170 +- .../crates/platform-agent-harness/Cargo.toml | 12 + .../src}/agent.rs | 14 +- .../src}/agent_builder.rs | 8 +- .../platform-agent-harness/src/error.rs | 100 ++ .../src}/hook.rs | 4 +- .../crates/platform-agent-harness/src/lib.rs | 10 + .../platform-agent-harness/src/memory.rs | 57 + .../platform-agent-harness/src/prompt.rs | 105 ++ .../crates/platform-agent-harness/src/run.rs | 1581 +++++++++++++++++ .../src}/tool.rs | 2 + .../crates/platform-editor-agent/Cargo.toml | 1 + .../platform-editor-agent/src/agent/agent.rs | 62 +- .../platform-editor-agent/src/agent/mod.rs | 1 + .../platform-editor-agent/src/agent/prompt.rs | 238 +++ .../src/agent/tools/common.rs | 1 - .../src/agent/tools/edit_image.rs | 7 +- .../agent/tools/generate_background_music.rs | 4 +- .../src/agent/tools/generate_character.rs | 9 +- .../agent/tools/generate_icon_spritesheet.rs | 8 +- .../src/agent/tools/generate_image.rs | 7 +- .../src/agent/tools/generate_sound_effect.rs | 4 +- .../src/agent/tools/generate_ui_design.rs | 8 +- .../src/agent/tools/generate_video.rs | 4 +- .../src/agent/tools/mod.rs | 1 - .../src/framework/error.rs | 53 - .../src/framework/memory.rs | 31 - .../src/framework/mod.rs | 8 +- .../src/framework/run.rs | 458 ----- 36 files changed, 2310 insertions(+), 720 deletions(-) create mode 100644 server-rs/crates/platform-agent-harness/Cargo.toml rename server-rs/crates/{platform-editor-agent/src/framework => platform-agent-harness/src}/agent.rs (86%) rename server-rs/crates/{platform-editor-agent/src/framework => platform-agent-harness/src}/agent_builder.rs (75%) create mode 100644 server-rs/crates/platform-agent-harness/src/error.rs rename server-rs/crates/{platform-editor-agent/src/framework => platform-agent-harness/src}/hook.rs (80%) create mode 100644 server-rs/crates/platform-agent-harness/src/lib.rs create mode 100644 server-rs/crates/platform-agent-harness/src/memory.rs create mode 100644 server-rs/crates/platform-agent-harness/src/prompt.rs create mode 100644 server-rs/crates/platform-agent-harness/src/run.rs rename server-rs/crates/{platform-editor-agent/src/framework => platform-agent-harness/src}/tool.rs (99%) create mode 100644 server-rs/crates/platform-editor-agent/src/agent/prompt.rs delete mode 100644 server-rs/crates/platform-editor-agent/src/agent/tools/common.rs delete mode 100644 server-rs/crates/platform-editor-agent/src/framework/error.rs delete mode 100644 server-rs/crates/platform-editor-agent/src/framework/memory.rs delete mode 100644 server-rs/crates/platform-editor-agent/src/framework/run.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index deafcaa38..932eebae8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4250,8 +4250,8 @@ - 背景:VectorEngine Apifox `api-349239079` 暴露 OpenAI-compatible `POST /v1/chat/completions`;创意 Agent 和通用 LLM 代理需要统一到 VectorEngine 文本服务,并将默认文本模型切换为 `gpt-5.4-mini`。 - 决策:创意 Agent 的 `CREATIVE_AGENT_GPT5_MODEL` 固定为 `gpt-5.4-mini`,协议切到 Chat Completions,不再携带旧 APIMart `official_fallback` 字段;画布 Agent 侧边栏聊天规划请求也复用该模型和 Chat Completions 协议,不再显式使用 `gpt-4o` / Responses。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`。未单独配置 `GENARRATIVE_LLM_API_KEY` 时,api-server 可复用 `VECTOR_ENGINE_API_KEY`;前端 LLM 客户端必须兼容 OpenAI `choices`、api-server raw `{content}` 和项目 envelope `{ok,data:{content}}` 三种非流式响应,以及 OpenAI SSE delta 和 api-server `event: delta` 两种流式响应。 -- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit_image` 并引用该结果;不能因为本轮没有手动附件而退回 `generate_image`。 -- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。 +- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `imageId` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit-image` 并把 `latestGeneratedImage.imageId` 传入 `edit-image.object_image_id`;不得生成工具 schema 中不存在的 `source_image_id`,也不能因为本轮没有手动附件而退回 `generate-image`。 +- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate-image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate-image`,只有实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`。 - 影响范围:`server-rs/crates/platform-agent`、`server-rs/crates/api-server/src/config.rs`、`src/services/llmClient.ts`、`.env.example`、`deploy/env/api-server.env.example`、`scripts/test-ve-llm.mjs`。 - 验证方式:`npm run test -- src/services/llmClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 @@ -4510,3 +4510,11 @@ - 对账边界:保留管理员显式手动对账。owner 始终可用;member 必须单独持有 `profile-wallet-consumption-reconcile` 独立操作权限,任意 Tab 都不隐式授予。`POST /admin/api/profile/users/reconcile-consumption` 经二次确认后调用 runtime service identity 受限 procedure,扫描该用户全部权威流水、比较并校准投影,记录管理员与对账时间。 - 展示边界:现有共享“用户详情”弹窗的钱包区增加“历史花费”,前端只展示 BFF 顶层字段,不自行汇总账单;只有 BFF 返回 `canReconcileConsumption=true` 时展示手动对账按钮。 - 验证方式:SpacetimeDB 钱包聚合测试、api-server / admin-web 定向测试、`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run admin-web:typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-07-28 画布 Agent 的通用 function-calling harness 与画布 prompt 分层 + +- 背景:画布 Agent 的 JSON 输出协议、tool schema 注入、memory / hook、轮次保护和“全部工具待确认即结束回合”原先位于 `platform-editor-agent/src/framework`,与规范展板、已有图编辑路由、模型超时和画布工具混在同一 crate;八类工具还重复携带待确认控制话术。旧 `platform-agent` 已随 Creative Agent 退役,不能作为新公共层复活。 +- 决策:新增无旧玩法依赖的现役 `platform-agent-harness`,只承载业务中立的 function-calling 执行协议;`platform-editor-agent` 通过兼容 re-export 复用该 crate,并继续承载画布 LLM profile、角色 prompt、公共美术工具路由策略、图片上下文和工具实现。无工具场景同样注入 JSON 响应格式;prompt 不再宣称工具并发执行;request 级 system prompt 必须真实进入本轮请求。画布对话额外注入最近一条已完成图片的有界 `latestGeneratedImage` 元数据,后续编辑仍只用 SHA-256 `imageId` 选图。 +- 执行与失败决策:prompt 每轮通过 `AgentMemory::begin_staged` 使用与调用方 memory 行为等价、写入隔离的 `StagedAgentMemory` 事务;成功或已有工具活动时显式 `commit()`,直接 drop 表示回滚。无工具活动失败时回滚本轮 staged 增量,已发生工具活动后失败时提交已发生工具事实并追加 terminal error closure。外部 future drop / abort 若发生在工具完成后,提交工具结果与取消闭环;若发生在工具执行中,提交“已启动、结果未知”与取消闭环,后续先 reconcile,不能假装副作用未发生。harness 通过 `PromptRunError { error, partial_outputs }` 显式返回终态错误和失败前输出;结构化工具失败还必须向调用方保留 `ToolFailure.kind/retryable/fatal` 与原始 `output`,不在 harness 内压成单一字符串。api-server 的 18 分钟总 deadline 以 runtime future 下沉到 runner:completion 可被 deadline 终止,工具在开始前检查、开始后等待返回、返回后携带结果收口;禁止外层 timeout drop prompt 或中途取消 effectful tool 后伪造空 partial。 +- 保留边界:会话幂等、OSS 消息、120 秒前端软提示、20 分钟 transport、18 分钟 handler 总 deadline、1024 tokens、8 分钟 provider attempt、泥点计费、确认入队和 external job 懒回填均不进入公共 harness。SpacetimeDB schema、前端 wire DTO 和侧边栏 UI 不变。 +- 验证方式:`cargo test -p platform-agent-harness`、`cargo test -p platform-editor-agent`、`cargo test -p api-server editor_agent`、`cargo check -p api-server --locked`、DDD 边界检查、Rustfmt、编码检查和 `git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 419c0aace..8c63ce94b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3272,8 +3272,17 @@ - 现象:画布 Agent 已生成有效工具规划,却最终只保存 `ERROR max turns reached: 3`,助手文本和待确认工具卡都消失。 - 原因:八类画布工具的 `call()` 只返回待用户确认的规划结果,但 function-calling runner 在成功工具后仍继续请求 LLM,只靠 prompt 要求模型不再重试;模型连续返回工具调用直到上限后,错误结果又丢弃此前累积的输出。 - 处理:工具通过框架契约显式声明 `requires_user_confirmation`;当本批全部工具都成功且等待确认时,runner 在处理完整批次后立即返回已有助手文本和工具结果。未知工具、参数错误、hook skip、普通连续工具和不可解析响应仍继续受 `max_turns` 门禁保护。不要用单纯提高轮次上限掩盖终止条件缺失。 -- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”和“普通连续工具仍会触发 max-turn 门禁”。 -- 关联:`server-rs/crates/platform-editor-agent/src/framework/run.rs`、`server-rs/crates/platform-editor-agent/src/framework/tool.rs`、`server-rs/crates/platform-editor-agent/src/agent/tools/`。 +- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”“普通连续工具仍会触发 max-turn 门禁”“多工具按数组顺序执行”“request 级 system prompt 真实进入请求”;公共 prompt 在无工具时仍必须包含 runner 所需的 JSON 响应格式,且不得宣称并发执行。 +- 关联:`server-rs/crates/platform-agent-harness/src/run.rs`、`server-rs/crates/platform-agent-harness/src/tool.rs`、`server-rs/crates/platform-editor-agent/src/agent/tools/`。 + +## Agent 终态失败不能吞掉已发生的工具事实 + +- 现象:同一轮 prompt 中前面工具已经成功生成待确认结果,但后续工具、hook、completion 或 `max_turns` 失败后,API 只保存最后一条 `ERROR `,已执行工具和用户本轮语义从会话历史中消失。 +- 原因:runner 只返回单一 `PromptError`,或者直接向 committed memory 逐步写入,无法区分“尚未发生外部工具事实,整轮可回滚”与“已发生工具事实,只能提交并闭合错误”。工具失败若被压成字符串,调用方还会丢失 `kind`、`retryable`、`fatal` 和原始 `output`。 +- 处理:用 `PromptRunError { error, partial_outputs }` 保留失败前输出,并将本轮 memory 先写入 staged buffer。无工具活动失败时整体回滚 staged 增量;有成功或失败工具活动时提交已发生事实,并追加 terminal error closure。api-server 按 `partial_outputs` 顺序先持久化成功工具的 `not_completed` 待确认消息,再追加 `ERROR ` 终态消息;`ToolFailed` 保留给调用方做诊断和流程决策,不伪装成成功确认卡。 +- 取消边界:不能在 prompt future 内对 `agent.memory.take()` 后跨 await 持有,也不能用统一 `VecMemory` staging 绕过自定义 memory 的限长、摘要或脱敏规则。`AgentMemory::begin_staged` 必须产生行为等价、写入隔离的 `StagedAgentMemory`,成功或已有工具活动时显式 `commit()`,直接 drop 才表示回滚。外部 drop 若发生在工具完成后,guard 必须提交结果与取消闭环;若工具仍在执行,至少提交“已启动、结果未知”事实,供后续 reconcile。正式总 deadline 应作为 runner 内部 future 终止 completion;工具开始前检查 deadline,一旦开始则不能中途 drop,必须等待结果后再携带 partial outputs 收口。外层 timeout 只适合作为进程级最后保险,不能承担业务收口。 +- 验证:至少覆盖“无工具 completion 失败回滚 staged 用户消息”“非 fatal 工具失败对调用方暴露 `kind/retryable/fatal/output`”“成功工具后终态失败保留 partial tool output”“有工具活动时 committed memory 末尾存在 error closure”以及“API 增量中待确认工具位于 terminal `ERROR ` 之前”。上一张图继续编辑的 prompt 测试必须断言 `latestGeneratedImage.imageId -> edit-image.object_image_id`,且最终 prompt 不含 `source_image_id`。 +- 关联:`server-rs/crates/platform-agent-harness/src/run.rs`、`server-rs/crates/platform-agent-harness/src/tool.rs`、`server-rs/crates/platform-editor-agent/src/agent/prompt.rs`、`server-rs/crates/api-server/src/editor_agent/api.rs`。 ## 画布 Agent 的规划请求不能关闭瞬时失败重试 @@ -3281,7 +3290,7 @@ - 原因:规划请求虽然有 Agent 专用单次 timeout,但 `editor_agent_llm_client` 把 `max_retries` 硬编码为 0;VectorEngine `gpt-5.4-mini` 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 `completion error` 前缀也被原样暴露给用户。 - 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次且重试退避最多 60 秒。不要只计算单次 complete 的最坏时间:runner 还可因非法 JSON/工具校验失败进入后续轮次,必须从 handler 入口开始计算 18 分钟总 deadline,进入 `agent.prompt(...)` 时扣除会话锁/上下文准备已用时间,为持久化和前端 20 分钟 timeout 留出余量。响应头后的体读取/解析错误按明确失败收口,必须使用真实 attempt 计数;规划、配置和定价错误对用户统一为中文,原始诊断只记后端日志。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。 - 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`platform-llm` 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;`api-server` 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。 -- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs`、`server-rs/crates/platform-editor-agent/src/framework/error.rs`、`server-rs/crates/api-server/src/state.rs`、`src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts`、`src/components/image-editor/EditorAgentConversation/MessageBubble.tsx`、`src/services/image-editor/editorAgentClient.ts`。 +- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs`、`server-rs/crates/platform-agent-harness/src/error.rs`、`server-rs/crates/api-server/src/state.rs`、`src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts`、`src/components/image-editor/EditorAgentConversation/MessageBubble.tsx`、`src/services/image-editor/editorAgentClient.ts`。 ## 前端退役目录不能只靠扫描和 ignore 隔离 diff --git a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md index fa81d9c6a..804bcd7a9 100644 --- a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md +++ b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md @@ -59,7 +59,7 @@ - SpacetimeDB schema guard 比较当前工作树与基线提交时,两侧都必须分别读取各自 `Cargo.toml` 的 `lib.path`,再沿 `mod` / `#[path]` 只扫描该快照 crate root 可达的 schema;不得递归扫描整个 `src/`,否则原位保留的旧源码会与现役历史数据壳产生假 accessor 重复。 - `module-runtime` 仍是账号、钱包、公共设置、追踪和 feature gate 的现役领域 crate;其混合源码中的 `CreationEntry*`、旧公开作品、旧存档 / 浏览历史 / 游玩统计 DTO、command、mapper 和规则必须以编译条件退出,且不再依赖只为旧创作契约存在的 `shared-contracts`。历史 schema 只继续编译 `RuntimeBrowseHistoryThemeMode` 六个变体和完整保序的 `RuntimeProfileWalletLedgerSourceType` 等持久化 ABI,不保留围绕这些类型的旧业务实现。 - 纯模板 crate 和专属运行态 crate 不属于 workspace members、default members 或任何在运 crate 的依赖图;源码目录保持原样。 -- `platform-agent` 及其专属 `langchainrust` 依赖同样退出 workspace 与 `api-server` 依赖图;现役编辑器 Agent 仅需的模型常量收口到 `platform-llm`,不再通过旧拼图 Phase 1 / Creative Agent 执行器 crate 复用。 +- `platform-agent` 及其专属 `langchainrust` 依赖同样退出 workspace 与 `api-server` 依赖图;现役编辑器 Agent 仅需的模型常量收口到 `platform-llm`,不再通过旧拼图 Phase 1 / Creative Agent 执行器 crate 复用。后续抽出的 `platform-agent-harness` 是无旧玩法依赖的通用 JSON function-calling 底座,不得依赖、复用或重新挂回本条退役 crate。 - `platform-auth` 不再编译 runtime guest token;`platform-wechat` 不再编译旧生成结果订阅服务,只保留现役认证和支付协议。 ## 验收 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d56a8a254..38b717aed 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -24,7 +24,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` - HTTP 与运维入口:`api-server`、`pingora-gateway`、`server-manager-panel`。 - 现役领域模块:`module-ai`、`module-assets`、`module-auth`、`module-editor-agent`、`module-runtime`。`module-runtime` 继续承载账号、钱包、公共设置、追踪、功能门禁等现役平台领域能力;该 crate 名称不代表旧玩法 runtime 路由仍在运行。 -- 平台副作用:`platform-agent`、`platform-auth`、`platform-audio`、`platform-hyper3d`、`platform-image`、`platform-llm`、`platform-matting`、`platform-oss`、`platform-speech`、`platform-wechat`。 +- 平台副作用:`platform-agent-harness`、`platform-editor-agent`、`platform-auth`、`platform-audio`、`platform-hyper3d`、`platform-image`、`platform-llm`、`platform-matting`、`platform-oss`、`platform-speech`、`platform-wechat`。已退役 Creative Agent 的旧 `platform-agent` 只保留历史源码,不属于现役 workspace 或依赖图。 - 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。 - SpacetimeDB:`spacetime-client`、`spacetime-module`。 - 测试支撑:`tests-support`。 @@ -76,11 +76,16 @@ npm run check:server-rs-ddd - `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId` 和 `x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。 - `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。 +- `platform-agent-harness` 只承载与具体业务无关的 JSON function-calling 协议、工具 schema 注入、memory、hook、typed tool、轮次保护和待用户确认终止语义;`platform-editor-agent` 在其上叠加画布专属 LLM profile、system prompt、跨工具路由规则、图片上下文与八类生成工具。公共 harness 不依赖画布 DTO、计费、OSS、Axum 或 external job,也不得复用已退役的旧 `platform-agent`。harness 失败契约固定为 `PromptRunError { error, partial_outputs }`;`partial_outputs` 显式携带失败前已产生的助手文本、成功工具和结构化失败工具输出。`ToolFailure` 的 `kind`、`retryable`、`fatal` 及工具原始 `output` 必须对 harness 调用方可见,不得在公共层压成字符串或擅自丢弃。 +- prompt runner 对每个调用通过 `AgentMemory::begin_staged` 创建行为等价、写入隔离的 `StagedAgentMemory` 事务:限长、摘要、脱敏或持久化 memory 的 append 语义必须在本轮模型请求前生效,不得统一降级成 `VecMemory`。成功结束或已发生工具活动时必须显式调用 staged `commit()`,直接 drop 表示回滚。无工具活动的 completion、hook、解析或 `max_turns` 失败丢弃 staged transaction;已有成功或失败工具活动时在末尾追加 terminal error closure 后提交。外部 future drop / abort 若发生在工具完成后,必须提交工具结果与取消闭环;若工具仍在执行,则提交“已启动、结果未知”事实与取消闭环,供后续 reconcile,不能假装工具没有发生。 +- 画布 handler 的 18 分钟总 deadline 通过 runtime 提供的 deadline future 下沉到公共 runner:completion await 可被 deadline 终止;effectful tool 在开始前检查 deadline,开始后不被中途 drop,返回后再携带结果收口为 `PromptRunError`。禁止用外层 timeout 直接 drop 整个 prompt future 并伪造空 `partial_outputs`。 - `spacetime-module` 的 `editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。 - 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或规划不可解析时,必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,面向用户的错误正文使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价错误;原始诊断只写后端结构化日志。后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。普通 JSON POST 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。 - 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 `max_tokens`。前端在 POST pending 120 秒后显示不入库的耐心等待提示;provider request future 明确返回 connect/timeout/HTTP/transport 错误时立即进入正式失败,尚未返回则继续等待。专用 provider 单 attempt hard timeout 为 8 分钟;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,显式配置 0 仍可关闭,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时只使用剩余预算;该 deadline 覆盖会话锁/上下文准备与最多 3 轮规划,并为错误持久化/HTTP 返回预留约 2 分钟,不允许多轮规划绕过前端 20 分钟 timeout。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,并使用该成功响应所属的真实 attempt 记录错误。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。 - 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。 +- planning prompt 注入的 `latestGeneratedImage.imageId` 只能映射到 `edit-image.object_image_id`;`source_image_id` 不是现役 `edit-image` schema 字段,prompt、tool args、确认执行和测试中都不得生成或兼容该字段。 - 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。 +- api-server 对 `PromptRunError` 的持久化顺序固定为:先按 `partial_outputs` 原顺序映射已成功工具,将其保存为 `status=not_completed` 且无 `externalJobId` 的待确认消息;再在同一会话增量末尾追加 `ERROR ` terminal system 消息并整体写入 OSS。后续规划失败不得吞掉失败前已执行的成功工具结果;结构化 `ToolFailed` 可用于调用方诊断与流程决策,但画布确认面不得把它伪装成成功待确认卡。 - `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。 - 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务。任务读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后仍保留 OSS 工具消息的 `not_completed + externalJobId`,由下次会话读取继续 reconcile;JSON 损坏、结果结构不兼容或 formatter 失败等确定性致命错误在重试耗尽后原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误,避免永久循环。 - 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource`、`editor_asset`、结构化画布表或 `external_generation_job` 的业务真相。未激活结构化存储的 canvas 才继续以 `editor_canvas.layers_json` 作为 legacy 布局真相。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 145305da9..c96c427c2 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -106,12 +106,16 @@ - 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册八类工具。 - 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。面向用户的规划错误使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价诊断;原始错误只记录在后端日志。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。普通 JSON POST 尚未结束只表示 provider request future 仍在等待,不能伪装成已持久化失败。 -- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。 +- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `imageId` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。 - 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。 -- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`。 -- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。 -- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。 +- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit-image`,并把 `latestGeneratedImage.imageId` 传入 `edit-image.object_image_id`;不得构造工具 schema 中不存在的 `source_image_id`。除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate-image`。 +- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate-image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`。 +- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;当前八类画布工具只做同步参数校验并返回待确认,因此不会延长正式生成链。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。 - function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。 +- runner 失败必须返回显式的 `PromptRunError { error, partial_outputs }`,不得只返回终态错误而丢弃本轮已产生的文本或工具事实。prompt 执行使用 `AgentMemory::begin_staged` 创建行为等价且写入隔离的 `StagedAgentMemory` 事务,限长、摘要、脱敏等 append 规则必须在本轮 completion 前生效;成功或已发生工具活动时必须显式调用 `commit()`,直接 drop staged transaction 表示回滚,不得统一复制成 `VecMemory` 或仅替换 box 冒充持久化提交。本轮无工具活动失败时回滚 staged 用户消息、助手文本和不可解析响应;已有工具活动时在末尾追加 terminal error closure 后提交。外部 drop / abort 若尚无工具活动则回滚并保持原 committed memory;若工具已完成则提交结果与取消闭环,若工具仍在执行则提交“已启动、结果未知”事实与取消闭环,后续必须先 reconcile 再决定是否重试。 +- `ToolFailure` 必须以结构化工具失败输出暴露给 harness 调用方:调用方能读取 `kind`、`retryable`、`fatal` 和工具返回的原始 `output`;不得把它们压成单一错误字符串。这些字段只提供流程决策与诊断事实,是否重试、如何展示或持久化仍由业务调用方决定。 +- api-server 收到带 `partial_outputs` 的终态失败时,必须先按原顺序把其中已成功工具转成 `status=not_completed` 待确认消息并写入同一会话增量,再追加 `ERROR <错误内容>` 终态 system 消息;不得因后续轮次、其它工具或 `max_turns` 失败而吞掉已经执行并返回的工具结果。 +- 通用 JSON function-calling 协议、工具 schema 注入、memory / hook、`max_turns` 和“全部工具待确认即结束回合”统一由现役 `platform-agent-harness` 承载;无工具时也必须输出同一 JSON 响应格式。画布角色 prompt、规范展板 / 已有图路由、模型与超时 profile、八类工具、计费、OSS 会话和 external job 编排继续留在 `platform-editor-agent` / `api-server`,不得回流已退役的旧 `platform-agent`。 - **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。 - 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。 @@ -152,7 +156,7 @@ - `GET/POST /api/editor/projects/{projectId}/agent-conversations`(列表/新建); - `GET/DELETE /api/editor/agent-conversations/{conversationId}`(详情/软删); - `POST /api/editor/agent-conversations/{conversationId}/messages`(JSON); - - Agent 编排(function-calling 循环、工具内部调既有生成执行链路)放 api-server 编排层,独立文件,不复用 `creative_agent.rs` 内存会话。 + - 通用 function-calling harness 放 `platform-agent-harness`;画布 Agent profile 与工具放 `platform-editor-agent`;会话、计费、OSS 和工具内部生成执行链路由 api-server 编排层承接,不复用 `creative_agent.rs` 内存会话。 - `shared-contracts` + `packages/shared`:`editorAgent` 会话、消息、工具确认展示与轻量媒体结果 DTO;消息响应返回 `conversation`、`deltaMessages` 和可选 `errorMessage`。 ## 实施顺序 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index c4381610e..d7227e064 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -4018,6 +4018,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "platform-agent-harness" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tokio", +] + [[package]] name = "platform-audio" version = "0.1.0" @@ -4056,6 +4065,7 @@ name = "platform-editor-agent" version = "0.1.0" dependencies = [ "hmac", + "platform-agent-harness", "platform-audio", "platform-image", "platform-llm", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 3c68a1d50..628d31afa 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -38,6 +38,7 @@ members = [ "crates/platform-oss", "crates/platform-auth", "crates/platform-audio", + "crates/platform-agent-harness", "crates/platform-hyper3d", "crates/platform-image", "crates/platform-llm", @@ -67,6 +68,7 @@ module-assets = { path = "crates/module-assets", default-features = false } module-auth = { path = "crates/module-auth", default-features = false } module-editor-agent = { path = "crates/module-editor-agent", default-features = false } module-runtime = { path = "crates/module-runtime", default-features = false } +platform-agent-harness = { path = "crates/platform-agent-harness", default-features = false } platform-editor-agent = { path = "crates/platform-editor-agent", default-features = false } platform-auth = { path = "crates/platform-auth", default-features = false } platform-audio = { path = "crates/platform-audio", default-features = false } diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index a1f0f089c..c09323825 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -1,4 +1,3 @@ -use std::future::IntoFuture; use std::time::Duration; use axum::extract::{Path, State}; @@ -10,7 +9,9 @@ use module_editor_agent::{ use platform_editor_agent::framework::agent_builder::AgentBuilder; use platform_editor_agent::framework::error::PromptError; use platform_editor_agent::framework::memory::VecMemory; -use platform_editor_agent::framework::run::{PromptOutput, format_tool_call_message}; +use platform_editor_agent::framework::run::{ + PromptOutput, PromptRunError, format_tool_call_message, +}; use platform_llm::LlmMessage; use serde::Serialize; use serde_json::{Value, json}; @@ -47,6 +48,7 @@ use crate::http_error::AppError; use crate::request_context::RequestContext; use crate::state::AppState; use platform_editor_agent::agent::agent::LlmChatAgentBuilder; +use platform_editor_agent::agent::prompt::{build_prompt_memory, editor_agent_system_prompt}; use platform_editor_agent::agent::tools::context::EditorToolContext; use platform_editor_agent::agent::tools::edit_image::EditImageTool; use platform_editor_agent::agent::tools::generate_background_music::GenerateBackgroundMusicTool; @@ -57,7 +59,7 @@ use platform_editor_agent::agent::tools::generate_sound_effect::GenerateSoundEff use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; use platform_editor_agent::agent::tools::generate_video::GenerateVideoTool; use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string}; -use tokio::time::{Instant, timeout}; +use tokio::time::Instant; const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128; const EDITOR_AGENT_PROMPT_TIMEOUT_MS: u64 = 18 * 60_000; @@ -190,18 +192,9 @@ pub async fn editor_agent_message( }; // The current user message is passed separately to prompt(), so memory stops before it. - let previous_messages: Vec = document.messages[..history_end] - .iter() - .map(|message| match message.role { - EditorAgentMessageRole::User => LlmMessage::user(&message.text), - EditorAgentMessageRole::Assistant => LlmMessage::assistant(&message.text), - EditorAgentMessageRole::System => LlmMessage::system(&message.text), - }) - // Tool calls and attachment bookkeeping are separate system messages. - .rev() - .take(18) - .rev() - .collect(); + // Tool calls and attachment bookkeeping are separate system messages. The prompt memory also + // appends one bounded latestGeneratedImage context entry for natural-language image references. + let previous_messages = build_prompt_memory(&document, history_end); // Build tool context from document let tool_context = context::build_tool_context(&document); @@ -272,16 +265,19 @@ pub async fn editor_agent_message( let remaining_prompt_duration = remaining_editor_agent_prompt_duration(message_started_at.elapsed()); - let agent_result = run_editor_agent_prompt_with_timeout( - agent.prompt(LlmMessage::user(user_message.text.clone())), - remaining_prompt_duration, - ) - .await; + let agent_result = agent + .prompt(LlmMessage::user(user_message.text.clone())) + .deadline( + tokio::time::sleep(remaining_prompt_duration), + editor_agent_prompt_deadline_error(), + ) + .await; let assistant_now = now_rfc3339(); + let (outputs, terminal_error) = split_prompt_result(agent_result); match build_delta_messages( - agent_result, + outputs, &assistant_now, document.messages.len(), &tool_context, @@ -293,11 +289,12 @@ pub async fn editor_agent_message( &conversation, &mut document, conversation_summary, - error.to_string(), + error.display_with_agent_label("美术 Agent").to_string(), ) .await } - Ok(delta_messages) => { + Ok(mut delta_messages) => { + append_terminal_error(&mut delta_messages, document.messages.len(), terminal_error); for msg in &delta_messages { document.messages.push(msg.clone()); } @@ -316,20 +313,33 @@ fn remaining_editor_agent_prompt_duration(elapsed: Duration) -> Duration { Duration::from_millis(EDITOR_AGENT_PROMPT_TIMEOUT_MS).saturating_sub(elapsed) } -async fn run_editor_agent_prompt_with_timeout( - future: F, - duration: Duration, -) -> Result, PromptError> -where - F: IntoFuture, PromptError>>, -{ - timeout(duration, future.into_future()) - .await - .unwrap_or_else(|_| { - Err(PromptError::CompletionError( - EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string(), - )) - }) +fn editor_agent_prompt_deadline_error() -> PromptError { + PromptError::CompletionError(EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string()) +} + +fn split_prompt_result( + result: Result, PromptRunError>, +) -> (Vec, Option) { + match result { + Ok(outputs) => (outputs, None), + Err(error) => { + let (terminal_error, partial_outputs) = error.into_parts(); + (partial_outputs, Some(terminal_error)) + } + } +} + +fn append_terminal_error( + delta_messages: &mut Vec, + messages_offset: usize, + terminal_error: Option, +) { + if let Some(error) = terminal_error { + delta_messages.push(build_editor_agent_error_message( + messages_offset + delta_messages.len(), + error.display_with_agent_label("美术 Agent"), + )); + } } fn build_editor_agent_error_message( @@ -564,14 +574,14 @@ mod tests { crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) .expect("default editor pricing should load"); let messages = build_delta_messages( - Ok(vec![PromptOutput::Tool(ToolCallOutput { + vec![PromptOutput::Tool(ToolCallOutput { tool_call: ToolCall { id: "tool-call-1".to_string(), name: tool_name.to_string(), args: json!({ "prompt": "轻快冒险音乐" }), }, output: output.clone(), - })]), + })], "2026-07-23T00:00:00Z", 0, &EditorToolContext::default(), @@ -598,7 +608,7 @@ mod tests { crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) .expect("default editor pricing should load"); let messages = build_delta_messages( - Ok(vec![PromptOutput::Tool(ToolCallOutput { + vec![PromptOutput::Tool(ToolCallOutput { tool_call: ToolCall { id: "tool-call-1".to_string(), name: GenerateVideoTool::NAME.to_string(), @@ -611,7 +621,7 @@ mod tests { }), }, output: json!({ "message": "runner pending output" }), - })]), + })], "2026-07-23T00:00:00Z", 0, &EditorToolContext::default(), @@ -642,15 +652,8 @@ mod tests { assert_eq!(display_value("sound"), Some("on")); } - #[tokio::test] - async fn prompt_timeout_applies_to_the_whole_agent_run() { - let error = run_editor_agent_prompt_with_timeout( - std::future::pending::, PromptError>>(), - Duration::from_millis(1), - ) - .await - .expect_err("pending agent run should hit the prompt deadline"); - + #[test] + fn prompt_deadline_applies_to_the_whole_agent_run() { assert_eq!(EDITOR_AGENT_PROMPT_TIMEOUT_MS, 1_080_000); assert_eq!( remaining_editor_agent_prompt_duration(Duration::from_secs(17 * 60)), @@ -661,37 +664,64 @@ mod tests { Duration::ZERO ); assert_eq!( - error.to_string(), + editor_agent_prompt_deadline_error() + .display_with_agent_label("美术 Agent") + .to_string(), "美术 Agent 规划失败:规划总时长已达到 18 分钟安全上限" ); } -} -fn editor_agent_system_prompt() -> &'static str { - r#" -* image_id str format is like: sha256:* -* when user referenced/uploaded image, a system message will notify you the image id(s). - YOU MUST USE THESE IMAGE IDs(or more from former context) IN YOUR TOOL CALLS.(or why user upload them?) -* to confirm a pending tool call, user should click a confirm button in their UI, instead of tell you "ok"/"confirm". - If in that case, you should tip the user to use the confirm button, instead of repeat that pending tool call. -* 用户所说的 规范图/参考图/生成的图/... 没有本质区别,all can be some image_id -* 实际生成工具由后端按模型定价扣泥点, 不能承诺免费生成 + #[test] + fn terminal_failure_keeps_partial_outputs_for_delta_persistence() { + let result = Err(PromptRunError::new( + PromptError::MaxTurnsReached { max_turns: 3 }, + vec![PromptOutput::Tool(ToolCallOutput { + tool_call: ToolCall { + id: "0".to_string(), + name: GenerateImageTool::NAME.to_string(), + args: json!({ + "prompt": "一座漂浮在云海上的城堡", + "reference_image_ids": [] + }), + }, + output: json!({ "message": "等待用户确认" }), + })], + )); -你是 Genarrative 图片画布 Agent,负责帮助用户理解、规划和触发画布生成工具. 对话回复要简短. -"# -} + let (outputs, terminal_error) = split_prompt_result(result); + let mut delta_messages = build_delta_messages( + outputs, + "2026-07-28T00:00:00Z", + 4, + &EditorToolContext::default(), + &EditorGenerationPricingConfig { + models: Default::default(), + }, + ) + .expect("successful partial tool output should still build a confirmation card"); + append_terminal_error(&mut delta_messages, 4, terminal_error); + assert_eq!(delta_messages.len(), 2); + assert_eq!(delta_messages[0].id, 4); + assert!(delta_messages[0].tool_call.is_some()); + assert_eq!(delta_messages[1].id, 5); + assert_eq!(delta_messages[1].role, EditorAgentMessageRole::System); + assert_eq!( + delta_messages[1].text, + "ERROR 美术 Agent 规划轮数已达上限:3" + ); + } +} fn build_delta_messages( - result: Result, PromptError>, + outputs: Vec, created_at: &str, messages_offset: usize, tool_context: &EditorToolContext, pricing: &EditorGenerationPricingConfig, ) -> Result, PromptError> { - let outputs = result?; let mut messages = Vec::with_capacity(outputs.len()); - for (i, out) in outputs.into_iter().enumerate() { - let absolute_idx = messages_offset + i; + for out in outputs { + let absolute_idx = messages_offset + messages.len(); match out { PromptOutput::Text(text) => { messages.push(EditorAgentMessage { @@ -741,6 +771,10 @@ fn build_delta_messages( created_at: created_at.to_string(), }); } + // Tool failures are retained by the shared harness for callers that need structured + // retry/diagnostic policy. The editor surface must not render them as confirmation + // cards; a terminal failure is appended below as the existing ERROR system message. + PromptOutput::ToolFailed(_) => {} } } diff --git a/server-rs/crates/platform-agent-harness/Cargo.toml b/server-rs/crates/platform-agent-harness/Cargo.toml new file mode 100644 index 000000000..be81ec120 --- /dev/null +++ b/server-rs/crates/platform-agent-harness/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "platform-agent-harness" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "time"] } diff --git a/server-rs/crates/platform-editor-agent/src/framework/agent.rs b/server-rs/crates/platform-agent-harness/src/agent.rs similarity index 86% rename from server-rs/crates/platform-editor-agent/src/framework/agent.rs rename to server-rs/crates/platform-agent-harness/src/agent.rs index 824123030..8cbd4105e 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/agent.rs +++ b/server-rs/crates/platform-agent-harness/src/agent.rs @@ -1,8 +1,8 @@ -use crate::framework::error::PromptError; -use crate::framework::hook::Hook; -use crate::framework::memory::AgentMemory; -use crate::framework::run::PromptRequest; -use crate::framework::tool::{Tool, ToolDyn}; +use crate::error::PromptError; +use crate::hook::Hook; +use crate::memory::AgentMemory; +use crate::run::PromptRequest; +use crate::tool::{Tool, ToolDyn}; pub struct Agent, Message> { pub model: M, @@ -76,4 +76,8 @@ pub trait LlmApiAdaptor: Send + Sync { fn tool_result_message(&self, tool_name: &str, output: &str) -> Message; fn build_assistant_message(&self, text: &str) -> Message; + + fn build_error_message(&self, error: &PromptError) -> Message { + self.tool_result_message("agent-error", &error.to_string()) + } } diff --git a/server-rs/crates/platform-editor-agent/src/framework/agent_builder.rs b/server-rs/crates/platform-agent-harness/src/agent_builder.rs similarity index 75% rename from server-rs/crates/platform-editor-agent/src/framework/agent_builder.rs rename to server-rs/crates/platform-agent-harness/src/agent_builder.rs index f2f90d8cc..eba53ceac 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/agent_builder.rs +++ b/server-rs/crates/platform-agent-harness/src/agent_builder.rs @@ -1,7 +1,7 @@ -use crate::framework::agent::{Agent, LlmApiAdaptor}; -use crate::framework::hook::Hook; -use crate::framework::memory::AgentMemory; -use crate::framework::tool::Tool; +use crate::agent::{Agent, LlmApiAdaptor}; +use crate::hook::Hook; +use crate::memory::AgentMemory; +use crate::tool::Tool; pub trait AgentBuilder> { type Client; diff --git a/server-rs/crates/platform-agent-harness/src/error.rs b/server-rs/crates/platform-agent-harness/src/error.rs new file mode 100644 index 000000000..e2713b847 --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/error.rs @@ -0,0 +1,100 @@ +#[derive(Debug, Clone)] +pub enum PromptError { + CompletionError(String), + ToolError(String), + InternalError(String), + MaxTurnsReached { max_turns: usize }, +} + +impl PromptError { + /// 使用业务入口自己的 Agent 名称展示错误,同时保持 harness 默认文案中性。 + pub fn display_with_agent_label<'a>(&'a self, agent_label: &'a str) -> PromptErrorDisplay<'a> { + PromptErrorDisplay { + error: self, + agent_label, + } + } + + fn fmt_with_agent_label( + &self, + formatter: &mut std::fmt::Formatter<'_>, + agent_label: &str, + ) -> std::fmt::Result { + match self { + Self::CompletionError(message) => { + write!(formatter, "{agent_label} 规划失败:{message}") + } + Self::ToolError(message) => { + write!(formatter, "{agent_label} 工具执行失败:{message}") + } + Self::InternalError(message) => { + write!(formatter, "{agent_label} 内部错误:{message}") + } + Self::MaxTurnsReached { max_turns } => { + write!(formatter, "{agent_label} 规划轮数已达上限:{max_turns}") + } + } + } +} + +pub struct PromptErrorDisplay<'a> { + error: &'a PromptError, + agent_label: &'a str, +} + +impl std::fmt::Display for PromptErrorDisplay<'_> { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt_with_agent_label(formatter, self.agent_label) + } +} + +impl std::fmt::Display for PromptError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt_with_agent_label(formatter, "Agent") + } +} + +impl std::error::Error for PromptError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_display_uses_neutral_agent_label() { + let error = PromptError::CompletionError("LLM 请求超时,累计尝试 2 次".to_string()); + + assert_eq!( + error.to_string(), + "Agent 规划失败:LLM 请求超时,累计尝试 2 次" + ); + } + + #[test] + fn caller_can_supply_a_surface_specific_agent_label() { + assert_eq!( + PromptError::CompletionError("LLM 请求超时".to_string()) + .display_with_agent_label("美术 Agent") + .to_string(), + "美术 Agent 规划失败:LLM 请求超时" + ); + assert_eq!( + PromptError::ToolError("参数无效".to_string()) + .display_with_agent_label("美术 Agent") + .to_string(), + "美术 Agent 工具执行失败:参数无效" + ); + assert_eq!( + PromptError::InternalError("序列化失败".to_string()) + .display_with_agent_label("美术 Agent") + .to_string(), + "美术 Agent 内部错误:序列化失败" + ); + assert_eq!( + PromptError::MaxTurnsReached { max_turns: 3 } + .display_with_agent_label("美术 Agent") + .to_string(), + "美术 Agent 规划轮数已达上限:3" + ); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/framework/hook.rs b/server-rs/crates/platform-agent-harness/src/hook.rs similarity index 80% rename from server-rs/crates/platform-editor-agent/src/framework/hook.rs rename to server-rs/crates/platform-agent-harness/src/hook.rs index c5fd255a3..e996854d2 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/hook.rs +++ b/server-rs/crates/platform-agent-harness/src/hook.rs @@ -1,5 +1,5 @@ -use crate::framework::run::{TextFlow, ToolCallFlow}; -use crate::framework::tool::ToolCall; +use crate::run::{TextFlow, ToolCallFlow}; +use crate::tool::ToolCall; pub trait Hook: Send + Sync { fn on_text_reply(&self, _text: &str) -> TextFlow { diff --git a/server-rs/crates/platform-agent-harness/src/lib.rs b/server-rs/crates/platform-agent-harness/src/lib.rs new file mode 100644 index 000000000..271292025 --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/lib.rs @@ -0,0 +1,10 @@ +//! 与具体业务 Agent、模型和界面无关的 function-calling harness。 + +pub mod agent; +pub mod agent_builder; +pub mod error; +pub mod hook; +pub mod memory; +pub mod prompt; +pub mod run; +pub mod tool; diff --git a/server-rs/crates/platform-agent-harness/src/memory.rs b/server-rs/crates/platform-agent-harness/src/memory.rs new file mode 100644 index 000000000..29c97266a --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/memory.rs @@ -0,0 +1,57 @@ +//! Agent 对话记忆的最小公共抽象。 + +use serde::{Deserialize, Serialize}; + +pub trait AgentMemoryBuffer: Send + Sync { + fn get_memory(&self) -> &[Message]; + fn append_message(&mut self, message: Message); +} + +pub trait AgentMemory: AgentMemoryBuffer { + /// 创建与当前 memory 行为等价、但写入彼此隔离的本轮事务副本。 + fn begin_staged(&self) -> Box>; +} + +pub trait StagedAgentMemory: AgentMemoryBuffer { + /// 显式提交本轮事务,并返回下一轮继续使用的 committed memory。 + /// + /// 持久化实现应在这里发布 staged 写入;直接 drop staged transaction 表示回滚。 + fn commit(self: Box) -> Box>; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VecMemory { + messages: Vec, +} + +impl VecMemory { + pub fn new(messages: Vec) -> Self { + Self { messages } + } + + pub fn into_inner(self) -> Vec { + self.messages + } +} + +impl AgentMemoryBuffer for VecMemory { + fn get_memory(&self) -> &[Message] { + &self.messages + } + + fn append_message(&mut self, message: Message) { + self.messages.push(message); + } +} + +impl AgentMemory for VecMemory { + fn begin_staged(&self) -> Box> { + Box::new(self.clone()) + } +} + +impl StagedAgentMemory for VecMemory { + fn commit(self: Box) -> Box> { + self + } +} diff --git a/server-rs/crates/platform-agent-harness/src/prompt.rs b/server-rs/crates/platform-agent-harness/src/prompt.rs new file mode 100644 index 000000000..593eeffde --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/prompt.rs @@ -0,0 +1,105 @@ +use serde_json::Value; + +pub const PENDING_USER_CONFIRMATION_MESSAGE: &str = + "tool call is pending user confirmation; end this turn when all tool calls are pending"; + +#[derive(Debug, Clone, PartialEq)] +pub struct ToolPromptSpec { + pub name: String, + pub description: String, + pub parameters: Value, +} + +/// 把业务 system prompt、统一 JSON function-calling 输出契约和工具 schema 组合起来。 +pub fn build_tools_system_prompt(base_prompt: &str, tool_specs: &[ToolPromptSpec]) -> String { + let mut prompt = String::new(); + prompt.push_str(base_prompt); + prompt.push_str("\n\nYou have access to the following tools.\n\n"); + prompt.push_str("## JSON Response Format\n"); + prompt.push_str("respond with valid JSON only (no markdown fences):\n"); + prompt.push_str("{\n"); + prompt.push_str(" \"reply_text\": \"your message to the user\",\n"); + prompt.push_str(" \"tool_calls\": [\n {\n"); + prompt.push_str(" \"tool_name\": \"tool_name_here\",\n"); + prompt.push_str(" \"args\": { \"argument_name\": \"argument_value\" }\n"); + prompt.push_str(" }\n ]\n"); + prompt.push_str("}\n\n"); + prompt.push_str("If you don't need to use a tool, respond with:\n"); + prompt.push_str("{\n"); + prompt.push_str(" \"reply_text\": \"your message\",\n"); + prompt.push_str(" \"tool_calls\": []\n"); + prompt.push_str("}\n\n"); + prompt.push_str("## Available Tools\n\n"); + + if tool_specs.is_empty() { + prompt.push_str("(No tools available.)\n"); + } else { + for tool in tool_specs { + prompt.push_str(&format!("- {}\n", tool.name)); + prompt.push_str(&format!(" Description: {}\n", tool.description)); + prompt.push_str(" Arguments JSON Schema:\n"); + let parameters = serde_json::to_string_pretty(&tool.parameters) + .unwrap_or_else(|_| tool.parameters.to_string()); + prompt.push_str(¶meters); + prompt.push('\n'); + } + + prompt.push_str("tool_calls can contain multiple calls in one turn. "); + prompt.push_str("Calls are executed sequentially in array order. "); + prompt.push_str("Valid tool calls are recorded as system messages. "); + prompt.push_str( + "Some tools require user confirmation. Do not retry the same tool call while it is pending. \ + If all tool calls are pending confirmation, end the turn and wait for the user's action.", + ); + } + + prompt +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_prompt_keeps_the_shared_json_and_confirmation_contract() { + let prompt = build_tools_system_prompt( + "业务提示词", + &[ToolPromptSpec { + name: "generate-image".to_string(), + description: "生成图片".to_string(), + parameters: json!({ + "type": "object", + "properties": { "prompt": { "type": "string" } }, + "required": ["prompt"] + }), + }], + ); + + assert!(prompt.starts_with("业务提示词")); + assert!(prompt.contains("\"reply_text\"")); + assert!(prompt.contains("\"tool_calls\"")); + assert!(prompt.contains("- generate-image")); + assert!(prompt.contains("\"required\": [")); + assert!(prompt.contains("Do not retry the same tool call")); + assert!(prompt.contains("all tool calls are pending confirmation")); + assert!(prompt.contains("executed sequentially in array order")); + assert!(!prompt.contains("concurrently")); + } + + #[test] + fn tool_prompt_preserves_the_no_tools_shape() { + let prompt = build_tools_system_prompt("基础提示词", &[]); + + assert!(prompt.starts_with("基础提示词")); + assert!(prompt.contains("## JSON Response Format")); + assert!(prompt.contains("\"tool_calls\": []")); + assert!(prompt.ends_with("## Available Tools\n\n(No tools available.)\n")); + } + + #[test] + fn pending_confirmation_message_is_shared_control_flow_copy() { + assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("pending user confirmation")); + assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("end this turn")); + } +} diff --git a/server-rs/crates/platform-agent-harness/src/run.rs b/server-rs/crates/platform-agent-harness/src/run.rs new file mode 100644 index 000000000..3c1dc5744 --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/run.rs @@ -0,0 +1,1581 @@ +use crate::agent::Agent; +use crate::agent::LlmApiAdaptor; +use crate::error::PromptError; +use crate::hook::Hook; +use crate::memory::{AgentMemory, StagedAgentMemory, VecMemory}; +use crate::run::PromptOutput::{Text, Tool}; +use crate::tool::{ToolCall, ToolExecutionResult, ToolFailure, ToolOutcome}; +use serde::Deserialize; +use serde_json::Value; +use std::future::{Future, poll_fn}; +use std::pin::Pin; +use std::task::Poll; + +pub type TextOutput = String; + +#[derive(Debug, Clone)] +pub struct ToolCallOutput { + pub tool_call: ToolCall, + pub output: Value, +} + +#[derive(Debug, Clone)] +pub struct ToolFailureOutput { + pub tool_call: ToolCall, + pub message: String, + pub output: Value, + pub failure: ToolFailure, +} + +#[derive(Debug, Clone)] +pub enum PromptOutput { + Text(TextOutput), + Tool(ToolCallOutput), + ToolFailed(ToolFailureOutput), +} + +#[derive(Debug, Clone)] +pub struct PromptRunError { + pub error: PromptError, + pub partial_outputs: Vec, +} + +impl PromptRunError { + pub fn new(error: PromptError, partial_outputs: Vec) -> Self { + Self { + error, + partial_outputs, + } + } + + pub fn has_tool_activity(&self) -> bool { + self.partial_outputs + .iter() + .any(|output| matches!(output, PromptOutput::Tool(_) | PromptOutput::ToolFailed(_))) + } + + pub fn into_parts(self) -> (PromptError, Vec) { + (self.error, self.partial_outputs) + } +} + +impl std::fmt::Display for PromptRunError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt(formatter) + } +} + +impl std::error::Error for PromptRunError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.error) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextFlow { + Continue, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCallFlow { + Continue, + Skip, + Stop, +} + +pub fn format_tool_call_message( + tool_call_id: impl std::fmt::Display, + args: &Value, + output: &Value, +) -> Result { + let arg_json = serde_json::to_string(args) + .map_err(|error| PromptError::InternalError(error.to_string()))?; + let output_json = serde_json::to_string(output) + .map_err(|error| PromptError::InternalError(error.to_string()))?; + Ok(format!( + "[tool_call:{tool_call_id}] args: {arg_json} output: {output_json}" + )) +} + +pub struct PromptRequest<'a, M: LlmApiAdaptor + 'a, Message: 'a> { + agent: &'a mut Agent, + message: Message, + system_prompt: Option, + hooks: Vec>, + max_turns: usize, + deadline: Option>, +} + +struct PromptDeadline<'a> { + future: Pin + Send + 'a>>, + error: PromptError, +} + +struct PromptMemoryTransaction<'a, Message: Send + Sync + 'static> { + committed: &'a mut Option>>, + staged: Option>>, + cancellation_message: Option, + completed_tool_activity: bool, + in_flight_tool_message: Option, + finalized: bool, +} + +impl<'a, Message: Send + Sync + 'static> PromptMemoryTransaction<'a, Message> { + fn new( + committed: &'a mut Option>>, + staged: Box>, + cancellation_message: Message, + ) -> Self { + Self { + committed, + staged: Some(staged), + cancellation_message: Some(cancellation_message), + completed_tool_activity: false, + in_flight_tool_message: None, + finalized: false, + } + } + + fn get_memory(&self) -> &[Message] { + self.staged + .as_ref() + .expect("staged memory exists") + .get_memory() + } + + fn append_message(&mut self, message: Message) { + self.staged + .as_mut() + .expect("staged memory exists") + .append_message(message); + } + + fn begin_tool(&mut self, started_message: Message) { + self.in_flight_tool_message = Some(started_message); + } + + fn finish_tool(&mut self) { + self.in_flight_tool_message = None; + self.completed_tool_activity = true; + } + + fn mark_tool_activity(&mut self) { + self.completed_tool_activity = true; + } + + fn has_tool_activity(&self) -> bool { + self.completed_tool_activity || self.in_flight_tool_message.is_some() + } + + fn commit_staged(&mut self) { + let staged = self.staged.take().expect("staged memory exists"); + *self.committed = Some(staged.commit()); + } + + fn finalize(mut self, commit: bool, terminal_message: Option) { + self.finalized = true; + if commit { + if let Some(message) = terminal_message { + self.append_message(message); + } + self.commit_staged(); + } + } +} + +impl Drop for PromptMemoryTransaction<'_, Message> { + fn drop(&mut self) { + if self.finalized || !self.has_tool_activity() { + return; + } + if let Some(message) = self.in_flight_tool_message.take() { + self.append_message(message); + } + if let Some(message) = self.cancellation_message.take() { + self.append_message(message); + } + self.commit_staged(); + } +} + +impl<'a, M, Message> PromptRequest<'a, M, Message> +where + M: LlmApiAdaptor + 'a, + Message: 'a, +{ + pub fn new(agent: &'a mut Agent, message: Message) -> Self { + let max_turns = agent.default_max_turns; + Self { + agent, + message, + system_prompt: None, + hooks: Vec::new(), + max_turns, + deadline: None, + } + } + + pub fn system_prompt(mut self, msg: Message) -> Self { + self.system_prompt = Some(msg); + self + } + + pub fn add_hook(mut self, hook: impl Hook + 'static) -> Self { + self.hooks.push(Box::new(hook)); + self + } + + pub fn max_turns(mut self, n: usize) -> Self { + self.max_turns = n; + self + } + + /// 在调用方提供的 deadline future 完成时,从 runner 内部正常收口当前执行进度。 + /// + /// 这与从外部 drop `PromptRequest` 不同:已完成工具会进入 `partial_outputs`,memory + /// 也会按正常失败事务边界提交或回滚。deadline future 由具体 runtime 提供,因此 + /// 公共 harness 不绑定 Tokio 或其它异步运行时。 + pub fn deadline( + mut self, + future: impl Future + Send + 'a, + error: PromptError, + ) -> Self { + self.deadline = Some(PromptDeadline { + future: Box::pin(future), + error, + }); + self + } +} + +async fn await_with_deadline( + future: F, + deadline: &mut Option>, +) -> Result +where + F: Future + Send, +{ + let Some(deadline) = deadline else { + return Ok(future.await); + }; + let mut future = Box::pin(future); + poll_fn(|context| { + if let Poll::Ready(()) = deadline.future.as_mut().poll(context) { + return Poll::Ready(Err(deadline.error.clone())); + } + if let Poll::Ready(output) = future.as_mut().poll(context) { + return Poll::Ready(Ok(output)); + } + Poll::Pending + }) + .await +} + +async fn ensure_deadline_not_elapsed( + deadline: &mut Option>, +) -> Result<(), PromptError> { + await_with_deadline(std::future::ready(()), deadline).await +} + +impl<'a, M, Message> IntoFuture for PromptRequest<'a, M, Message> +where + M: LlmApiAdaptor + Send + Sync + 'a, + Message: Send + Sync + Clone + 'a + 'static, +{ + type Output = Result, PromptRunError>; + type IntoFuture = Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + let agent = self.agent; + let message = self.message; + let request_system_prompt = self.system_prompt; + let extra_hooks = self.hooks; + let max_turns = self.max_turns; + let mut deadline = self.deadline; + + Box::pin(async move { + let Agent { + model, + tools, + hooks: agent_hooks, + system_prompt: agent_system_prompt, + memory: committed_memory, + .. + } = agent; + let staged_memory = committed_memory + .as_ref() + .map(|memory| memory.begin_staged()) + .unwrap_or_else(|| { + Box::new(VecMemory::new(Vec::new())) as Box> + }); + let cancellation_message = model.build_error_message(&PromptError::CompletionError( + "prompt future cancelled after tool activity; reconcile before retry".to_string(), + )); + let mut memory = + PromptMemoryTransaction::new(committed_memory, staged_memory, cancellation_message); + memory.append_message(message); + + let outcome: Result, PromptRunError> = async { + let mut prompt_result: Vec = Vec::new(); + + for _ in 0..max_turns { + let text = { + let messages = agent_system_prompt + .iter() + .chain(request_system_prompt.iter()) + .chain(memory.get_memory().iter()); + await_with_deadline(model.complete(messages), &mut deadline) + .await + .map_err(|error| { + PromptRunError::new(error, prompt_result.clone()) + })? + .map_err(|error| { + PromptRunError::new(error, prompt_result.clone()) + })? + }; + + // Try to parse the LLM reply as JSON (handle Markdown fences) + let cleaned = clean_json_response(&text); + match serde_json::from_str::(&cleaned) { + Ok(json_resp) => { + let clean_text = json_resp.reply_text.trim().to_string(); + + // Run on_text_reply hooks + for hook in agent_hooks.iter().chain(extra_hooks.iter()) { + match hook.on_text_reply(&clean_text) { + TextFlow::Stop => { + return Err(PromptRunError::new( + PromptError::ToolError( + "text reply rejected by hook".to_string(), + ), + prompt_result, + )); + } + TextFlow::Continue => {} + } + } + memory.append_message(model.build_assistant_message(&clean_text)); + prompt_result.push(Text(clean_text.clone())); + + let tool_calls: Vec = json_resp + .tool_calls + .into_iter() + .enumerate() + .map(|(idx, tc)| ToolCall { + id: format!("{idx}"), + name: tc.tool_name, + args: tc.args, + }) + .collect(); + + // no tool call, turn terminate. + if tool_calls.is_empty() { + return Ok(prompt_result); + } + + let mut all_tool_calls_await_user_confirmation = true; + for (tc_id, tc) in tool_calls.iter().enumerate() { + // inline run_hooks: before_tool_call hook + let mut should_skip = false; + for hook in agent_hooks.iter().chain(extra_hooks.iter()) { + match hook.before_tool_call(tc) { + ToolCallFlow::Stop => { + return Err(PromptRunError::new( + PromptError::ToolError( + "tool call rejected by hook".to_string(), + ), + prompt_result, + )); + } + ToolCallFlow::Skip => { + let msg = model.tool_result_message( + &tc.name, + "(skipped by hook)", + ); + memory.append_message(msg); + should_skip = true; + break; + } + ToolCallFlow::Continue => {} + } + } + if should_skip { + all_tool_calls_await_user_confirmation = false; + continue; + } + + ensure_deadline_not_elapsed(&mut deadline) + .await + .map_err(|error| { + PromptRunError::new(error, prompt_result.clone()) + })?; + + let matching_tool = + tools.iter().find(|tool| tool.tool_name() == tc.name); + let requires_user_confirmation = matching_tool + .is_some_and(|tool| tool.requires_user_confirmation()); + let result = match matching_tool { + Some(tool) => { + let arg_json = serde_json::to_string(&tc.args).map_err( + |error| { + PromptRunError::new( + PromptError::InternalError(error.to_string()), + prompt_result.clone(), + ) + }, + )?; + memory.begin_tool(model.tool_result_message( + &tc.name, + &format!( + "[tool_call:{tc_id}] started with args: {arg_json}; result unknown because prompt execution was cancelled" + ), + )); + let result = tool.call(tc.args.clone()).await; + memory.finish_tool(); + result + } + None => { + memory.mark_tool_activity(); + ToolExecutionResult::failed( + Value::Null, + ToolFailure::invalid_args(format!( + "unknown tool: {}", + tc.name + )), + ) + } + }; + if !requires_user_confirmation + || !matches!(&result.outcome, ToolOutcome::InternalOk) + { + all_tool_calls_await_user_confirmation = false; + } + + match result.outcome { + ToolOutcome::InternalOk => { + let mut json_output = result.output; + let mut hook_stop_error = None; + // Run after_tool_call hooks to allow output modification + for hook in agent_hooks.iter().chain(extra_hooks.iter()) { + match hook.after_tool_call(&tc.name, &mut json_output) { + ToolCallFlow::Stop => { + hook_stop_error = Some(PromptError::ToolError( + "tool call output caused this turn to stop by hook" + .to_string(), + )); + break; + } + ToolCallFlow::Skip => { + all_tool_calls_await_user_confirmation = false; + json_output = serde_json::json!({"message":"tool call is ignored by hook"}); + break; + } + ToolCallFlow::Continue => {} + } + } + let overall_message = + format_tool_call_message(tc_id, &tc.args, &json_output) + .map_err(|error| { + PromptRunError::new( + error, + prompt_result.clone(), + ) + })?; + let msg = model.tool_result_message(&tc.name, &overall_message); + memory.append_message(msg); + prompt_result.push(Tool(ToolCallOutput { + tool_call: tc.clone(), + output: json_output, + })); + if let Some(error) = hook_stop_error { + return Err(PromptRunError::new(error, prompt_result)); + } + } + ToolOutcome::InternalError(failure) => { + let failure_payload = serde_json::json!({ + "status": "failed", + "failure": &failure, + "output": &result.output, + }); + let failure_json = serde_json::to_string(&failure_payload) + .map_err(|error| { + PromptRunError::new( + PromptError::InternalError(error.to_string()), + prompt_result.clone(), + ) + })?; + let overall_message = format!( + "[tool_call:{tc_id}] failure: {failure_json}" + ); + let msg = model.tool_result_message(&tc.name, &overall_message); + memory.append_message(msg); + let fatal = failure.fatal; + let error_message = failure.message.clone(); + prompt_result.push(PromptOutput::ToolFailed( + ToolFailureOutput { + tool_call: tc.clone(), + message: overall_message, + output: result.output, + failure, + }, + )); + if fatal { + return Err(PromptRunError::new( + PromptError::ToolError(error_message), + prompt_result, + )); + } + } + } + + ensure_deadline_not_elapsed(&mut deadline) + .await + .map_err(|error| { + PromptRunError::new(error, prompt_result.clone()) + })?; + } + + if all_tool_calls_await_user_confirmation { + return Ok(prompt_result); + } + } + Err(_) => { + // Not valid JSON — retain only inside this staged turn until success. + memory.append_message(model.build_assistant_message(&text)); + continue; + } + } + } + + Err(PromptRunError::new( + PromptError::MaxTurnsReached { max_turns }, + prompt_result, + )) + } + .await; + + let commit_staged_memory = outcome.is_ok() || memory.has_tool_activity(); + let terminal_message = outcome + .as_ref() + .err() + .map(|error| model.build_error_message(&error.error)); + memory.finalize(commit_staged_memory, terminal_message); + outcome + }) + } +} + +#[derive(Deserialize)] +struct LlmJsonResponse { + reply_text: String, + #[serde(default)] + tool_calls: Vec, +} + +#[derive(Deserialize)] +struct LlmToolCallRequest { + tool_name: String, + #[serde(default)] + args: Value, +} + +pub fn clean_json_response(text: &str) -> String { + let text = text.trim(); + if text.starts_with("```") { + let lines: Vec<&str> = text.lines().collect(); + let mut cleaned = Vec::new(); + let mut in_code = false; + for line in lines { + if line.trim().starts_with("```") { + in_code = !in_code; + continue; + } + if in_code { + cleaned.push(line); + } + } + if !cleaned.is_empty() { + return cleaned.join("\n").trim().to_string(); + } + } + text.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::LlmApiAdaptor; + use crate::hook::Hook; + use crate::tool::{Tool, ToolDyn, ToolFailureKind}; + use serde_json::json; + use std::convert::Infallible; + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct RepeatingToolCallModel { + completion_count: Arc, + } + + impl LlmApiAdaptor for RepeatingToolCallModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + self.completion_count.fetch_add(1, Ordering::SeqCst); + Ok(json!({ + "reply_text": "请确认这次生成", + "tool_calls": [{ + "tool_name": "test-tool", + "args": { "prompt": "生成一张图" } + }] + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + struct TestTool { + requires_user_confirmation: bool, + } + + struct CapturingModel { + messages: Arc>>, + } + + struct OrderedBatchModel; + + struct FailingCompletionModel; + + struct PendingCompletionModel; + + struct ToolThenPendingModel { + completion_count: Arc, + } + + struct SlowToolCallModel; + + struct FailingToolCallModel { + include_successful_tool: bool, + } + + impl LlmApiAdaptor for OrderedBatchModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + Ok(json!({ + "reply_text": "请确认这批操作", + "tool_calls": [ + { "tool_name": "ordered-tool", "args": { "order": 2 } }, + { "tool_name": "ordered-tool", "args": { "order": 1 } } + ] + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for FailingCompletionModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + Err(PromptError::CompletionError( + "provider unavailable".to_string(), + )) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for PendingCompletionModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + std::future::pending().await + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for ToolThenPendingModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + if self.completion_count.fetch_add(1, Ordering::SeqCst) == 0 { + return Ok(json!({ + "reply_text": "先执行一个工具", + "tool_calls": [{ "tool_name": "test-tool", "args": {} }] + }) + .to_string()); + } + std::future::pending().await + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for SlowToolCallModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + Ok(json!({ + "reply_text": "执行慢工具", + "tool_calls": [{ "tool_name": "slow-effect-tool", "args": {} }] + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for FailingToolCallModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + let mut tool_calls = Vec::new(); + if self.include_successful_tool { + tool_calls.push(json!({ "tool_name": "test-tool", "args": {} })); + } + tool_calls.push(json!({ "tool_name": "failing-tool", "args": {} })); + Ok(json!({ + "reply_text": "执行工具", + "tool_calls": tool_calls, + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + struct OrderedTool { + execution_order: Arc>>, + } + + struct FailingToolDyn { + fatal: bool, + } + + struct SlowEffectTool { + started: Arc, + duration: std::time::Duration, + } + + #[derive(Clone)] + struct TailMemory { + messages: Vec, + max_messages: usize, + } + + impl crate::memory::AgentMemoryBuffer for TailMemory { + fn get_memory(&self) -> &[String] { + &self.messages + } + + fn append_message(&mut self, message: String) { + self.messages.push(message); + let overflow = self.messages.len().saturating_sub(self.max_messages); + if overflow > 0 { + self.messages.drain(..overflow); + } + } + } + + impl AgentMemory for TailMemory { + fn begin_staged(&self) -> Box> { + Box::new(self.clone()) + } + } + + impl StagedAgentMemory for TailMemory { + fn commit(self: Box) -> Box> { + self + } + } + + #[derive(Clone)] + struct CommitTrackingMemory { + messages: Vec, + commits: Arc, + } + + struct CommitTrackingStagedMemory { + messages: Vec, + commits: Arc, + } + + impl crate::memory::AgentMemoryBuffer for CommitTrackingMemory { + fn get_memory(&self) -> &[String] { + &self.messages + } + + fn append_message(&mut self, message: String) { + self.messages.push(message); + } + } + + impl AgentMemory for CommitTrackingMemory { + fn begin_staged(&self) -> Box> { + Box::new(CommitTrackingStagedMemory { + messages: self.messages.clone(), + commits: self.commits.clone(), + }) + } + } + + impl crate::memory::AgentMemoryBuffer for CommitTrackingStagedMemory { + fn get_memory(&self) -> &[String] { + &self.messages + } + + fn append_message(&mut self, message: String) { + self.messages.push(message); + } + } + + impl StagedAgentMemory for CommitTrackingStagedMemory { + fn commit(self: Box) -> Box> { + self.commits.fetch_add(1, Ordering::SeqCst); + Box::new(CommitTrackingMemory { + messages: self.messages, + commits: self.commits, + }) + } + } + + impl LlmApiAdaptor for CapturingModel { + async fn complete<'a>( + &self, + messages: impl Iterator + Send, + ) -> Result { + *self.messages.lock().expect("messages lock should succeed") = + messages.cloned().collect(); + Ok(json!({ + "reply_text": "完成", + "tool_calls": [] + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + struct SkipAfterToolCallHook; + + struct StopAfterToolCallHook; + + impl Hook for SkipAfterToolCallHook { + fn after_tool_call(&self, _tool_name: &str, _output: &mut Value) -> ToolCallFlow { + ToolCallFlow::Skip + } + } + + impl Hook for StopAfterToolCallHook { + fn after_tool_call(&self, _tool_name: &str, _output: &mut Value) -> ToolCallFlow { + ToolCallFlow::Stop + } + } + + impl Tool for TestTool { + const NAME: &'static str = "test-tool"; + type Error = Infallible; + type Args = Value; + type Output = Value; + + fn description(&self) -> String { + "test tool".to_string() + } + + fn parameters(&self) -> Value { + json!({ "type": "object" }) + } + + fn call( + &self, + _args: Self::Args, + ) -> impl Future> + Send { + async { Ok(json!({ "message": "pending user confirmation" })) } + } + + fn requires_user_confirmation(&self) -> bool { + self.requires_user_confirmation + } + } + + impl Tool for SlowEffectTool { + const NAME: &'static str = "slow-effect-tool"; + type Error = Infallible; + type Args = Value; + type Output = Value; + + fn description(&self) -> String { + "slow effect tool".to_string() + } + + fn parameters(&self) -> Value { + json!({ "type": "object" }) + } + + fn call( + &self, + _args: Self::Args, + ) -> impl Future> + Send { + async move { + self.started.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(self.duration).await; + Ok(json!({ "message": "effect completed" })) + } + } + } + + impl Tool for OrderedTool { + const NAME: &'static str = "ordered-tool"; + type Error = Infallible; + type Args = Value; + type Output = Value; + + fn description(&self) -> String { + "ordered test tool".to_string() + } + + fn parameters(&self) -> Value { + json!({ "type": "object" }) + } + + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + let order = args["order"] + .as_u64() + .expect("ordered tool should receive an order"); + self.execution_order + .lock() + .expect("execution order lock should succeed") + .push(order); + Ok(json!({ "message": "pending user confirmation" })) + } + } + + fn requires_user_confirmation(&self) -> bool { + true + } + } + + impl ToolDyn for FailingToolDyn { + fn tool_name(&self) -> &'static str { + "failing-tool" + } + + fn description(&self) -> String { + "failing test tool".to_string() + } + + fn parameters(&self) -> Value { + json!({ "type": "object" }) + } + + fn requires_user_confirmation(&self) -> bool { + false + } + + fn call( + &self, + _args: Value, + ) -> Pin + Send + '_>> { + Box::pin(async move { + ToolExecutionResult::failed( + json!({ "attempt": 1 }), + ToolFailure::new(ToolFailureKind::Network, "network failed") + .with_fatal(self.fatal), + ) + }) + } + } + + #[tokio::test] + async fn request_system_prompt_is_added_after_the_agent_system_prompt() { + let captured_messages = Arc::new(Mutex::new(Vec::new())); + let model = CapturingModel { + messages: captured_messages.clone(), + }; + let mut agent = Agent::new(model).system_prompt("Agent system".to_string()); + + let outputs = agent + .prompt("User message".to_string()) + .system_prompt("Request system".to_string()) + .await + .expect("request prompt should succeed"); + + assert_eq!(outputs.len(), 1); + assert_eq!( + *captured_messages + .lock() + .expect("messages lock should succeed"), + vec![ + "Agent system".to_string(), + "Request system".to_string(), + "User message".to_string(), + ] + ); + } + + #[tokio::test] + async fn completion_failure_rolls_back_the_staged_user_message() { + let mut agent = Agent::new(FailingCompletionModel) + .memory(VecMemory::new(vec!["prior message".to_string()])); + + let error = agent + .prompt("new user message".to_string()) + .await + .expect_err("completion should fail"); + + assert!(matches!(error.error, PromptError::CompletionError(_))); + assert!(error.partial_outputs.is_empty()); + assert_eq!( + agent + .memory + .as_ref() + .expect("existing memory should be restored") + .get_memory(), + &["prior message".to_string()] + ); + } + + #[tokio::test] + async fn dropping_a_pending_prompt_keeps_the_original_memory() { + let mut agent = Agent::new(PendingCompletionModel) + .memory(VecMemory::new(vec!["prior message".to_string()])); + + let timeout = tokio::time::timeout( + std::time::Duration::from_millis(1), + agent.prompt("new user message".to_string()), + ) + .await; + + assert!(timeout.is_err()); + assert_eq!( + agent + .memory + .as_ref() + .expect("external cancellation must keep committed memory") + .get_memory(), + &["prior message".to_string()] + ); + } + + #[tokio::test] + async fn dropping_after_a_completed_tool_commits_the_fact_and_cancellation_closure() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let commits = Arc::new(AtomicUsize::new(0)); + let mut agent = Agent::new(ToolThenPendingModel { + completion_count: completion_count.clone(), + }) + .tool(TestTool { + requires_user_confirmation: false, + }) + .memory(CommitTrackingMemory { + messages: vec!["prior message".to_string()], + commits: commits.clone(), + }); + + let timeout = tokio::time::timeout( + std::time::Duration::from_millis(10), + agent.prompt("execute then wait".to_string()), + ) + .await; + + assert!(timeout.is_err()); + assert_eq!(completion_count.load(Ordering::SeqCst), 2); + assert_eq!(commits.load(Ordering::SeqCst), 1); + let memory = agent + .memory + .as_ref() + .expect("completed tool cancellation should commit memory") + .get_memory(); + assert!( + memory + .iter() + .any(|message| message.starts_with("test-tool:")) + ); + assert!(memory.last().is_some_and(|message| { + message.contains("prompt future cancelled after tool activity") + })); + } + + #[tokio::test] + async fn dropping_an_in_flight_tool_commits_an_unknown_result_fact() { + let started = Arc::new(AtomicUsize::new(0)); + let mut agent = Agent::new(SlowToolCallModel) + .tool(SlowEffectTool { + started: started.clone(), + duration: std::time::Duration::from_secs(60), + }) + .memory(VecMemory::new(vec!["prior message".to_string()])); + + let timeout = tokio::time::timeout( + std::time::Duration::from_millis(10), + agent.prompt("start slow effect".to_string()), + ) + .await; + + assert!(timeout.is_err()); + assert_eq!(started.load(Ordering::SeqCst), 1); + let memory = agent + .memory + .as_ref() + .expect("in-flight cancellation should commit memory") + .get_memory(); + assert!(memory.iter().any(|message| { + message.contains("slow-effect-tool") && message.contains("result unknown") + })); + assert!(memory.last().is_some_and(|message| { + message.contains("prompt future cancelled after tool activity") + })); + } + + #[tokio::test] + async fn internal_deadline_returns_completed_tools_and_closes_memory() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let mut agent = Agent::new(ToolThenPendingModel { + completion_count: completion_count.clone(), + }) + .tool(TestTool { + requires_user_confirmation: false, + }); + + let error = agent + .prompt("执行后等待".to_string()) + .deadline( + tokio::time::sleep(std::time::Duration::from_millis(1)), + PromptError::CompletionError("total deadline reached".to_string()), + ) + .await + .expect_err("runner deadline should terminate the pending completion"); + + assert_eq!(completion_count.load(Ordering::SeqCst), 2); + assert!(matches!(error.error, PromptError::CompletionError(_))); + assert_eq!(error.partial_outputs.len(), 2); + assert!(matches!(error.partial_outputs[1], PromptOutput::Tool(_))); + assert_eq!( + agent + .memory + .as_ref() + .expect("completed tool activity should commit staged memory") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 规划失败:total deadline reached") + ); + } + + #[tokio::test] + async fn internal_deadline_does_not_cancel_an_in_flight_tool() { + let started = Arc::new(AtomicUsize::new(0)); + let mut agent = Agent::new(SlowToolCallModel).tool(SlowEffectTool { + started: started.clone(), + duration: std::time::Duration::from_millis(30), + }); + + let error = agent + .prompt("run effect safely".to_string()) + .deadline( + tokio::time::sleep(std::time::Duration::from_millis(10)), + PromptError::CompletionError("total deadline reached".to_string()), + ) + .await + .expect_err("deadline should close after the started tool returns"); + + assert_eq!(started.load(Ordering::SeqCst), 1); + assert!(matches!(error.error, PromptError::CompletionError(_))); + assert!(matches!(error.partial_outputs[1], PromptOutput::Tool(_))); + assert_eq!( + agent + .memory + .as_ref() + .expect("completed tool should commit before deadline closure") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 规划失败:total deadline reached") + ); + } + + #[tokio::test] + async fn elapsed_deadline_wins_before_polling_the_next_operation() { + let captured_messages = Arc::new(Mutex::new(Vec::new())); + let model = CapturingModel { + messages: captured_messages.clone(), + }; + let mut agent = Agent::new(model).memory(VecMemory::new(vec!["prior message".to_string()])); + + let error = agent + .prompt("new user message".to_string()) + .deadline( + std::future::ready(()), + PromptError::CompletionError("deadline already elapsed".to_string()), + ) + .await + .expect_err("elapsed deadline should win before completion is polled"); + + assert!(matches!(error.error, PromptError::CompletionError(_))); + assert!(error.partial_outputs.is_empty()); + assert!( + captured_messages + .lock() + .expect("messages lock should succeed") + .is_empty() + ); + assert_eq!( + agent + .memory + .as_ref() + .expect("no-tool deadline should retain committed memory") + .get_memory(), + &["prior message".to_string()] + ); + } + + #[tokio::test] + async fn staged_prompt_preserves_custom_memory_append_semantics() { + let captured_messages = Arc::new(Mutex::new(Vec::new())); + let model = CapturingModel { + messages: captured_messages.clone(), + }; + let mut agent = Agent::new(model).memory(TailMemory { + messages: vec!["older".to_string(), "latest".to_string()], + max_messages: 2, + }); + + agent + .prompt("current user".to_string()) + .await + .expect("bounded staged memory should complete"); + + assert_eq!( + *captured_messages + .lock() + .expect("messages lock should succeed"), + vec!["latest".to_string(), "current user".to_string()] + ); + assert_eq!( + agent + .memory + .as_ref() + .expect("successful staged memory should commit") + .get_memory(), + &["current user".to_string(), "完成".to_string()] + ); + } + + #[tokio::test] + async fn staged_memory_uses_explicit_commit_and_drop_as_rollback() { + let commits = Arc::new(AtomicUsize::new(0)); + let mut failing_agent = Agent::new(FailingCompletionModel).memory(CommitTrackingMemory { + messages: vec!["prior".to_string()], + commits: commits.clone(), + }); + + failing_agent + .prompt("failed turn".to_string()) + .await + .expect_err("completion failure without tools should roll back"); + assert_eq!(commits.load(Ordering::SeqCst), 0); + + let captured_messages = Arc::new(Mutex::new(Vec::new())); + let mut successful_agent = Agent::new(CapturingModel { + messages: captured_messages, + }) + .memory(CommitTrackingMemory { + messages: vec!["prior".to_string()], + commits: commits.clone(), + }); + + successful_agent + .prompt("successful turn".to_string()) + .await + .expect("successful turn should commit staged memory"); + assert_eq!(commits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn non_fatal_tool_failure_preserves_structured_failure_and_output() { + let mut agent = Agent::new(FailingToolCallModel { + include_successful_tool: false, + }) + .max_turns(1); + agent.tools.push(Box::new(FailingToolDyn { fatal: false })); + + let error = agent + .prompt("执行失败工具".to_string()) + .await + .expect_err("non-fatal failure should still respect max turns"); + + assert!(matches!( + error.error, + PromptError::MaxTurnsReached { max_turns: 1 } + )); + let PromptOutput::ToolFailed(failure_output) = &error.partial_outputs[1] else { + panic!("structured tool failure should reach the caller"); + }; + assert_eq!(failure_output.failure.kind, ToolFailureKind::Network); + assert!(failure_output.failure.retryable); + assert!(!failure_output.failure.fatal); + assert_eq!(failure_output.output, json!({ "attempt": 1 })); + assert!(failure_output.message.contains("\"retryable\":true")); + } + + #[tokio::test] + async fn fatal_tool_failure_keeps_earlier_success_and_closes_memory() { + let mut agent = Agent::new(FailingToolCallModel { + include_successful_tool: true, + }) + .tool(TestTool { + requires_user_confirmation: false, + }); + agent.tools.push(Box::new(FailingToolDyn { fatal: true })); + + let error = agent + .prompt("先成功再失败".to_string()) + .await + .expect_err("fatal tool should terminate the run"); + + assert!(matches!(error.error, PromptError::ToolError(_))); + assert!(matches!(error.partial_outputs[1], PromptOutput::Tool(_))); + assert!(matches!( + error.partial_outputs[2], + PromptOutput::ToolFailed(_) + )); + assert_eq!( + agent + .memory + .as_ref() + .expect("tool activity should commit memory") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 工具执行失败:network failed") + ); + } + + #[tokio::test] + async fn post_tool_hook_failure_keeps_executed_tool_fact_and_closes_memory() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let model = RepeatingToolCallModel { + completion_count: completion_count.clone(), + }; + let mut agent = Agent::new(model) + .tool(TestTool { + requires_user_confirmation: false, + }) + .hook(StopAfterToolCallHook); + + let error = agent + .prompt("执行后由 hook 终止".to_string()) + .await + .expect_err("post-tool hook should terminate the run"); + + assert!(matches!(error.error, PromptError::ToolError(_))); + assert!(matches!(error.partial_outputs[1], PromptOutput::Tool(_))); + assert_eq!( + agent + .memory + .as_ref() + .expect("executed tool should commit memory") + .get_memory() + .last() + .map(String::as_str), + Some( + "agent-error: Agent 工具执行失败:tool call output caused this turn to stop by hook" + ) + ); + } + + #[tokio::test] + async fn multiple_tool_calls_execute_sequentially_in_array_order() { + let execution_order = Arc::new(Mutex::new(Vec::new())); + let mut agent = Agent::new(OrderedBatchModel).tool(OrderedTool { + execution_order: execution_order.clone(), + }); + + let outputs = agent + .prompt("执行两项操作".to_string()) + .await + .expect("ordered pending tools should finish the turn"); + + assert_eq!(outputs.len(), 3); + assert_eq!( + *execution_order + .lock() + .expect("execution order lock should succeed"), + vec![2, 1] + ); + } + + #[tokio::test] + async fn pending_confirmation_tool_batch_finishes_without_another_completion() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let model = RepeatingToolCallModel { + completion_count: completion_count.clone(), + }; + let mut agent = Agent::new(model) + .tool(TestTool { + requires_user_confirmation: true, + }) + .max_turns(3); + + let outputs = agent + .prompt("生成一张图".to_string()) + .await + .expect("pending confirmation should finish the planning turn"); + + assert_eq!(completion_count.load(Ordering::SeqCst), 1); + assert_eq!(outputs.len(), 2); + assert!(matches!(outputs[0], PromptOutput::Text(_))); + assert!(matches!(outputs[1], PromptOutput::Tool(_))); + } + + #[tokio::test] + async fn non_confirmation_tool_keeps_the_existing_max_turn_guard() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let model = RepeatingToolCallModel { + completion_count: completion_count.clone(), + }; + let mut agent = Agent::new(model) + .tool(TestTool { + requires_user_confirmation: false, + }) + .max_turns(3); + + let error = agent + .prompt("生成一张图".to_string()) + .await + .expect_err("a continuing tool should still hit the max-turn guard"); + + assert_eq!(completion_count.load(Ordering::SeqCst), 3); + assert!(matches!( + error.error, + PromptError::MaxTurnsReached { max_turns: 3 } + )); + assert_eq!(error.partial_outputs.len(), 6); + assert_eq!( + agent + .memory + .as_ref() + .expect("tool activity should commit memory") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 规划轮数已达上限:3") + ); + } + + #[tokio::test] + async fn skipped_confirmation_result_keeps_the_existing_max_turn_guard() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let model = RepeatingToolCallModel { + completion_count: completion_count.clone(), + }; + let mut agent = Agent::new(model) + .tool(TestTool { + requires_user_confirmation: true, + }) + .hook(SkipAfterToolCallHook) + .max_turns(3); + + let error = agent + .prompt("生成一张图".to_string()) + .await + .expect_err("a skipped result must not finish as pending confirmation"); + + assert_eq!(completion_count.load(Ordering::SeqCst), 3); + assert!(matches!( + error.error, + PromptError::MaxTurnsReached { max_turns: 3 } + )); + assert_eq!(error.partial_outputs.len(), 6); + assert_eq!( + error + .partial_outputs + .iter() + .filter(|output| matches!(output, PromptOutput::Text(_))) + .count(), + 3 + ); + assert_eq!( + error + .partial_outputs + .iter() + .filter(|output| matches!(output, PromptOutput::Tool(_))) + .count(), + 3 + ); + assert_eq!( + agent + .memory + .as_ref() + .expect("tool activity should commit memory") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 规划轮数已达上限:3") + ); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/framework/tool.rs b/server-rs/crates/platform-agent-harness/src/tool.rs similarity index 99% rename from server-rs/crates/platform-editor-agent/src/framework/tool.rs rename to server-rs/crates/platform-agent-harness/src/tool.rs index c36e76186..22da48bfc 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/tool.rs +++ b/server-rs/crates/platform-agent-harness/src/tool.rs @@ -1,3 +1,5 @@ +//! Function-calling 工具及其结构化执行结果的公共契约。 + use serde::{Deserialize, Serialize}; use std::pin::Pin; diff --git a/server-rs/crates/platform-editor-agent/Cargo.toml b/server-rs/crates/platform-editor-agent/Cargo.toml index b60f96fec..8db3d7480 100644 --- a/server-rs/crates/platform-editor-agent/Cargo.toml +++ b/server-rs/crates/platform-editor-agent/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] hmac = { workspace = true } +platform-agent-harness = { workspace = true } platform-llm = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/server-rs/crates/platform-editor-agent/src/agent/agent.rs b/server-rs/crates/platform-editor-agent/src/agent/agent.rs index 226f8ef61..e0881e237 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/agent.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/agent.rs @@ -4,8 +4,8 @@ use crate::framework::error::PromptError; use crate::framework::hook::Hook; use crate::framework::memory::AgentMemory; use crate::framework::tool::{Tool, ToolDyn}; +use platform_agent_harness::prompt::{ToolPromptSpec, build_tools_system_prompt}; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmClient, LlmMessage, LlmTextRequest}; -use serde_json::Value; const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024; const EDITOR_AGENT_LLM_HARD_REQUEST_TIMEOUT_MS: u64 = 480_000; @@ -35,6 +35,13 @@ impl LlmApiAdaptor for LlmCompletionModel { fn build_assistant_message(&self, text: &str) -> LlmMessage { LlmMessage::assistant(text) } + + fn build_error_message(&self, error: &PromptError) -> LlmMessage { + LlmMessage::system(format!( + "ERROR {}", + error.display_with_agent_label("美术 Agent") + )) + } } fn build_editor_agent_llm_request(messages: Vec) -> LlmTextRequest { @@ -122,59 +129,6 @@ impl AgentBuilder for LlmChatAgentBuilder { } } -struct ToolPromptSpec { - name: String, - description: String, - parameters: Value, -} - -fn build_tools_system_prompt(base_prompt: &str, tool_specs: &[ToolPromptSpec]) -> String { - let mut prompt = String::new(); - prompt.push_str(base_prompt); - prompt.push_str("\n\nYou have access to the following tools.\n\n"); - - if tool_specs.is_empty() { - prompt.push_str("(No tools available.)\n"); - } else { - prompt.push_str("## JSON Response Format\n"); - prompt.push_str("respond with valid JSON only (no markdown fences):\n"); - prompt.push_str("{\n"); - prompt.push_str(" \"reply_text\": \"your message to the user\",\n"); - prompt.push_str(" \"tool_calls\": [\n {\n"); - prompt.push_str(" \"tool_name\": \"tool_name_here\",\n"); - prompt.push_str(" \"args\": { \"argument_name\": \"argument_value\" }\n"); - prompt.push_str(" }\n ]\n"); - prompt.push_str("}\n\n"); - prompt.push_str("If you don't need to use a tool, respond with:\n"); - prompt.push_str("{\n"); - prompt.push_str(" \"reply_text\": \"your message\",\n"); - prompt.push_str(" \"tool_calls\": []\n"); - prompt.push_str("}\n\n"); - prompt.push_str("## Available Tools\n\n"); - - for tool in tool_specs { - prompt.push_str(&format!("- {}\n", tool.name)); - prompt.push_str(&format!(" Description: {}\n", tool.description)); - prompt.push_str(" Arguments JSON Schema:\n"); - let parameters = serde_json::to_string_pretty(&tool.parameters) - .unwrap_or_else(|_| tool.parameters.to_string()); - prompt.push_str(¶meters); - prompt.push_str("\n"); - } - - prompt.push_str( - "as you see, tool_calls is an array, several tools calls can be executed in one turn concurrently. ", - ); - prompt.push_str("your valid tool call will be recorded as system message"); - prompt.push_str( - "some tools calls needs user's confirmation, you should not retry the same tool call in this case.\ - And if all tool calls are pending confirmation, you should just end the turn, as you cant do more before user's action" - ); - } - - prompt -} - #[cfg(test)] mod tests { use super::*; diff --git a/server-rs/crates/platform-editor-agent/src/agent/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/mod.rs index ab3846cdf..7711dff12 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/mod.rs @@ -1,3 +1,4 @@ pub mod agent; pub mod asset; +pub mod prompt; pub mod tools; diff --git a/server-rs/crates/platform-editor-agent/src/agent/prompt.rs b/server-rs/crates/platform-editor-agent/src/agent/prompt.rs new file mode 100644 index 000000000..5f04018a5 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/prompt.rs @@ -0,0 +1,238 @@ +use crate::agent::asset::ImageId; +pub use platform_agent_harness::prompt::PENDING_USER_CONFIRMATION_MESSAGE; +use platform_llm::LlmMessage; +use serde_json::json; +use shared_contracts::editor_agent::{ + EditorAgentConversationMessagesDocument, EditorAgentGeneratedImage, EditorAgentMessage, + EditorAgentMessageRole, EditorAgentToolCallStatus, +}; + +const EDITOR_AGENT_MAX_RECENT_PROMPT_MESSAGES: usize = 18; + +const EXISTING_IMAGE_EDIT_POLICY: &str = "已有图片的修改必须选择 edit-image,不得退回全新生成"; +const SPEC_BOARD_ROUTE_POLICY: &str = + "规范图、视觉规范图、风格规范图或素材规范展板必须选择 generate-image"; +const SPEC_BOARD_CONTENT_POLICY: &str = "规范展板 prompt 必须写明统一视角、线条粗细、描边、填充风格、材质、阴影、圆角、状态层级、色卡或色号、尺寸标注和排版层级"; + +pub fn editor_agent_system_prompt() -> &'static str { + r#" +* image_id 字符串格式为 sha256:*。 +* 用户引用或上传图片时,system message 会提供对应 image_id;规划相关工具调用时必须使用这些 image_id 或上下文中已有的 image_id。 +* 待确认工具必须由用户在界面点击确认按钮执行。用户只在对话中回复“确认”或“可以”时,应提示其点击确认按钮,不得重复提交同一待确认工具。 +* 用户所说的规范图、参考图和已生成图片都可以作为 image_id 图片上下文。 +* 实际生成工具由后端按模型定价扣泥点,不能承诺免费生成。 + +你是 Genarrative 图片画布 Agent,只负责帮助用户理解、规划和触发画布生成工具。对话回复要简短。 +"# +} + +pub fn build_prompt_memory( + document: &EditorAgentConversationMessagesDocument, + history_end: usize, +) -> Vec { + let history = &document.messages[..history_end.min(document.messages.len())]; + let mut messages = history + .iter() + .map(|message| match message.role { + EditorAgentMessageRole::User => LlmMessage::user(&message.text), + EditorAgentMessageRole::Assistant => LlmMessage::assistant(&message.text), + EditorAgentMessageRole::System => LlmMessage::system(&message.text), + }) + .rev() + .take(EDITOR_AGENT_MAX_RECENT_PROMPT_MESSAGES) + .rev() + .collect::>(); + + if let Some(latest_generated_image) = build_latest_generated_image_prompt_context(history) { + messages.push(LlmMessage::system(latest_generated_image)); + } + messages +} + +fn build_latest_generated_image_prompt_context(messages: &[EditorAgentMessage]) -> Option { + let (tool_name, image) = messages.iter().rev().find_map(|message| { + let tool_call = message.tool_call.as_ref()?; + if tool_call.status != EditorAgentToolCallStatus::Completed { + return None; + } + Some((tool_call.tool_name.as_str(), tool_call.images.first()?)) + })?; + let image_id = ImageId::from_data_key(generated_image_data_key(image)); + let context = json!({ + "toolName": tool_name, + "imageId": image_id, + "resourceId": image.resource_id.as_deref(), + "objectKey": image.object_key.as_deref(), + "assetObjectId": image.asset_object_id.as_deref(), + }); + Some(format!( + "latestGeneratedImage: {context}\n用户指代“这张”“刚才那个”或“上一张”时,使用 imageId 作为 edit-image 的 object_image_id。" + )) +} + +fn generated_image_data_key(image: &EditorAgentGeneratedImage) -> String { + image + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| image.image_src.clone()) +} + +pub fn edit_image_tool_description() -> String { + format!( + "仅用于修改已有图片:换衣服、改颜色、替换背景、局部重绘,或保持主体、构图、姿势不变的编辑。用户指代“这张”“刚才那个”“上一张”“改成”“换成”时优先使用本工具。{EXISTING_IMAGE_EDIT_POLICY};必须使用已有图片上下文,没有参考图时不要调用,应提示用户先选择参考图。" + ) +} + +pub fn generate_image_tool_description() -> String { + format!( + "用于从文字生成全新图片:新场景、新物体、新插画或新背景;也专用于规范图、视觉规范图、风格规范图、素材规范展板。不要用来修改已有图,{EXISTING_IMAGE_EDIT_POLICY}。{SPEC_BOARD_ROUTE_POLICY};{SPEC_BOARD_CONTENT_POLICY};角色规范展板还要含头身比例、标准立绘、动作帧样例、服饰配饰分层和专属色卡。完整 prompt 必须包含画面、主体、风格、构图和背景。" + ) +} + +pub fn generate_character_tool_description() -> String { + format!( + "仅用于生成新的角色形象、人物立绘或普通角色设定图。{EXISTING_IMAGE_EDIT_POLICY};角色规范图、角色美术视觉规范设定图或规范展板属于规范展板,{SPEC_BOARD_ROUTE_POLICY}。" + ) +} + +pub fn generate_icon_spritesheet_tool_description() -> String { + format!( + "仅用于生成多个图标成品、图标素材图集或 spritesheet。必须提供图标规范或风格参考图,并填写多个 icon_descriptions;没有参考图时不要调用,应提示用户先选择参考图。图标规范图或图标视觉规范展板属于规范展板,{SPEC_BOARD_ROUTE_POLICY}。" + ) +} + +pub fn generate_ui_design_tool_description() -> String { + format!( + "仅用于生成完整可用的 UI 设计图或界面稿,包括 HUD、弹窗、面板、按钮组合和整页界面。不要用于提取图标、拆素材;{EXISTING_IMAGE_EDIT_POLICY}。UI 规范图、组件规范展板或视觉规范展板属于规范展板,{SPEC_BOARD_ROUTE_POLICY}。" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared_contracts::editor_agent::{ + EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, EditorAgentToolCall, EditorAgentToolCallDisplayArgs, + }; + + fn message( + id: usize, + role: EditorAgentMessageRole, + text: &str, + tool_call: Option, + ) -> EditorAgentMessage { + EditorAgentMessage { + id, + client_message_id: None, + role, + text: text.to_string(), + attachments: Vec::new(), + tool_call, + created_at: "2026-07-28T00:00:00Z".to_string(), + } + } + + #[test] + fn shared_tool_policy_keeps_spec_and_edit_routes_consistent() { + let generate_image = generate_image_tool_description(); + let edit_image = edit_image_tool_description(); + let character = generate_character_tool_description(); + let icons = generate_icon_spritesheet_tool_description(); + let ui = generate_ui_design_tool_description(); + + assert!(generate_image.contains(SPEC_BOARD_CONTENT_POLICY)); + assert!(edit_image.contains("上一张")); + assert!(character.contains(SPEC_BOARD_ROUTE_POLICY)); + assert!(icons.contains(SPEC_BOARD_ROUTE_POLICY)); + assert!(ui.contains(SPEC_BOARD_ROUTE_POLICY)); + for description in [&generate_image, &edit_image, &character, &ui] { + assert!(description.contains(EXISTING_IMAGE_EDIT_POLICY)); + } + } + + #[test] + fn shared_pending_message_is_control_flow_not_tool_specific_copy() { + assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("pending user confirmation")); + assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("end this turn")); + } + + #[test] + fn prompt_memory_injects_the_latest_completed_generated_image() { + let generated = EditorAgentToolCall { + tool_name: "generate-image".to_string(), + status: EditorAgentToolCallStatus::Completed, + args: json!({ "prompt": "一只橙色小猫" }), + display_args: EditorAgentToolCallDisplayArgs::default(), + external_job_id: Some("job-1".to_string()), + images: vec![EditorAgentGeneratedImage { + resource_id: Some("resource-1".to_string()), + object_key: Some("generated/editor/cat.png".to_string()), + asset_object_id: Some("asset-object-1".to_string()), + image_src: "/generated/editor/cat.png".to_string(), + thumbnail_src: None, + width: Some(1024), + height: Some(1024), + }], + videos: Vec::new(), + audios: Vec::new(), + error: None, + }; + let document = EditorAgentConversationMessagesDocument { + version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + conversation_id: "conversation-1".to_string(), + messages: vec![ + message( + 0, + EditorAgentMessageRole::System, + "tool result", + Some(generated), + ), + message(1, EditorAgentMessageRole::User, "把上一张改成蓝色", None), + ], + }; + + let memory = build_prompt_memory(&document, 1); + let latest_context = &memory.last().expect("latest image context").content; + + assert!(latest_context.starts_with("latestGeneratedImage:")); + assert!(latest_context.contains("\"toolName\":\"generate-image\"")); + assert!(latest_context.contains("\"resourceId\":\"resource-1\"")); + assert!(latest_context.contains("\"objectKey\":\"generated/editor/cat.png\"")); + assert!(latest_context.contains("\"imageId\":\"sha256:")); + assert!(latest_context.contains("edit-image 的 object_image_id")); + assert!(!latest_context.contains("source_image_id")); + assert!(!latest_context.contains("https://")); + } + + #[test] + fn prompt_memory_ignores_unfinished_tool_results() { + let pending = EditorAgentToolCall { + tool_name: "generate-image".to_string(), + status: EditorAgentToolCallStatus::NotCompleted, + args: json!({}), + display_args: EditorAgentToolCallDisplayArgs::default(), + external_job_id: None, + images: Vec::new(), + videos: Vec::new(), + audios: Vec::new(), + error: None, + }; + let document = EditorAgentConversationMessagesDocument { + version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + conversation_id: "conversation-1".to_string(), + messages: vec![message( + 0, + EditorAgentMessageRole::System, + "pending", + Some(pending), + )], + }; + + let memory = build_prompt_memory(&document, document.messages.len()); + + assert_eq!(memory.len(), 1); + assert!(!memory[0].content.contains("latestGeneratedImage")); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/common.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/common.rs deleted file mode 100644 index 4d2920087..000000000 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/common.rs +++ /dev/null @@ -1 +0,0 @@ -pub const TOOL_CALL_PENDING_MESSAGE: &'static str = "this tool call is pending user confirmation. if all tool calls are pending, just end this turn"; diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs index e72210d94..52a5b22e7 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs @@ -1,5 +1,5 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{PENDING_USER_CONFIRMATION_MESSAGE, edit_image_tool_description}; use crate::agent::tools::context::EditorToolContext; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; use platform_image::GPT_IMAGE_2_MODEL; @@ -67,8 +67,7 @@ impl Tool for EditImageTool { type Output = EditImageToolOutput; fn description(&self) -> String { - "仅用于修改已有图片:换衣服、改颜色、替换背景、局部重绘,或保持主体、构图、姿势不变的编辑。用户指代“这张”“刚才那个”“上一张”“改成”“换成”时优先使用本工具。必须使用已有图片上下文;没有参考图时不要调用,应提示用户先选择参考图。" - .to_string() + edit_image_tool_description() } fn parameters(&self) -> Value { @@ -110,7 +109,7 @@ impl Tool for EditImageTool { return Err(error); } Ok(EditImageToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs index 8a1c12f47..71bf38128 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs @@ -1,4 +1,4 @@ -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::PENDING_USER_CONFIRMATION_MESSAGE; use crate::framework::tool::{Tool, ToolFailure}; use platform_audio::SUNO_DEFAULT_MODEL; use serde::{Deserialize, Serialize}; @@ -65,7 +65,7 @@ impl Tool for GenerateBackgroundMusicTool { async move { self.validate_args(&args)?; Ok(GenerateBackgroundMusicToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs index fd331d759..4d102c0bd 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs @@ -1,5 +1,7 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{ + PENDING_USER_CONFIRMATION_MESSAGE, generate_character_tool_description, +}; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::generate_image::{ EditorImageGenerationResult, GenerateImageError, GenerateImageToolOutput, @@ -39,8 +41,7 @@ impl Tool for GenerateCharacterTool { type Output = GenerateImageToolOutput; fn description(&self) -> String { - "仅用于生成新的角色形象、人物立绘或普通角色设定图。已有角色图的服装、颜色、表情、姿势或背景修改必须选择 edit-image。角色规范图、角色美术视觉规范设定图或规范展板必须选择 generate-image。" - .to_string() + generate_character_tool_description() } fn parameters(&self) -> Value { @@ -66,7 +67,7 @@ impl Tool for GenerateCharacterTool { async move { self.validate_args(&args)?; Ok(GenerateImageToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs index 684f39b35..96229eee0 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -1,5 +1,7 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{ + PENDING_USER_CONFIRMATION_MESSAGE, generate_icon_spritesheet_tool_description, +}; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::image_generation_options::{ ImageGenerationOptionsError, default_image_aspect_ratio, default_image_model, @@ -119,7 +121,7 @@ impl Tool for GenerateIconSpritesheetTool { type Output = GenerateIconSpritesheetToolOutput; fn description(&self) -> String { - "仅用于生成多个图标成品、图标素材图集或 spritesheet。必须提供图标规范或风格参考图,并填写多个 icon_descriptions;没有参考图时不要调用,应提示用户先选择参考图。图标规范图或图标视觉规范展板不是本工具,应使用 generate-image。".to_string() + generate_icon_spritesheet_tool_description() } fn parameters(&self) -> Value { @@ -146,7 +148,7 @@ impl Tool for GenerateIconSpritesheetTool { async move { self.validate_args(&args)?; Ok(GenerateIconSpritesheetToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs index 984cd20ad..03f774b5c 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs @@ -1,5 +1,5 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{PENDING_USER_CONFIRMATION_MESSAGE, generate_image_tool_description}; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::image_generation_options::{ ImageGenerationOptionsError, default_image_aspect_ratio, default_image_model, @@ -92,8 +92,7 @@ impl Tool for GenerateImageTool { type Output = GenerateImageToolOutput; fn description(&self) -> String { - "用于从文字生成全新图片:新场景、新物体、新插画或新背景;也专用于规范图、视觉规范图、风格规范图、素材规范展板。不要用来修改已有图,已有图修改应选 edit-image。规范展板 prompt 必须写明统一视角、线条粗细、描边、填充风格、材质、阴影、圆角、状态层级、色卡或色号、尺寸标注和排版层级;角色规范展板还要含头身比例、标准立绘、动作帧样例、服饰配饰分层和专属色卡。完整 prompt 必须包含画面、主体、风格、构图和背景。" - .to_string() + generate_image_tool_description() } fn parameters(&self) -> Value { @@ -126,7 +125,7 @@ impl Tool for GenerateImageTool { async move { self.validate_args(&args)?; Ok(GenerateImageToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs index 6c9c22a53..8831789e5 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs @@ -1,4 +1,4 @@ -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::PENDING_USER_CONFIRMATION_MESSAGE; use crate::framework::tool::{Tool, ToolFailure}; use platform_audio::VIDU_AUDIO_MODEL; use serde::{Deserialize, Serialize}; @@ -91,7 +91,7 @@ impl Tool for GenerateSoundEffectTool { async move { self.validate_args(&args)?; Ok(GenerateSoundEffectToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs index 73bdaea56..295f29ef0 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs @@ -1,5 +1,7 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{ + PENDING_USER_CONFIRMATION_MESSAGE, generate_ui_design_tool_description, +}; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::generate_image::{ EditorImageGenerationResult, GenerateImageError, GenerateImageToolOutput, @@ -42,7 +44,7 @@ impl Tool for GenerateUiDesignTool { type Output = GenerateImageToolOutput; fn description(&self) -> String { - "仅用于生成完整可用的 UI 设计图或界面稿,包括 HUD、弹窗、面板、按钮组合和整页界面。不要用于提取图标、拆素材或修改已有图;UI 规范图、组件规范展板或视觉规范展板应使用 generate-image。".to_string() + generate_ui_design_tool_description() } fn parameters(&self) -> Value { @@ -72,7 +74,7 @@ impl Tool for GenerateUiDesignTool { async move { self.validate_args(&args)?; Ok(GenerateImageToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs index 6d4d5b14d..461597417 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs @@ -1,5 +1,5 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::PENDING_USER_CONFIRMATION_MESSAGE; use crate::agent::tools::context::EditorToolContext; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; use serde::{Deserialize, Serialize}; @@ -153,7 +153,7 @@ impl Tool for GenerateVideoTool { async move { self.validate_args(&args)?; Ok(GenerateVideoToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs index ba4a6e188..1c26f27a7 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs @@ -1,4 +1,3 @@ -mod common; pub mod context; pub mod edit_image; pub mod generate_background_music; diff --git a/server-rs/crates/platform-editor-agent/src/framework/error.rs b/server-rs/crates/platform-editor-agent/src/framework/error.rs deleted file mode 100644 index 5d2e92581..000000000 --- a/server-rs/crates/platform-editor-agent/src/framework/error.rs +++ /dev/null @@ -1,53 +0,0 @@ -#[derive(Debug, Clone)] -pub enum PromptError { - CompletionError(String), - ToolError(String), - InternalError(String), - MaxTurnsReached { max_turns: usize }, -} - -impl std::fmt::Display for PromptError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::CompletionError(msg) => write!(f, "美术 Agent 规划失败:{msg}"), - Self::ToolError(msg) => write!(f, "美术 Agent 工具执行失败:{msg}"), - Self::InternalError(msg) => write!(f, "美术 Agent 内部错误:{msg}"), - Self::MaxTurnsReached { max_turns } => { - write!(f, "美术 Agent 规划轮数已达上限:{max_turns}") - } - } - } -} - -impl std::error::Error for PromptError {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn completion_error_uses_user_facing_chinese_copy() { - let error = PromptError::CompletionError("LLM 请求超时,累计尝试 2 次".to_string()); - - assert_eq!( - error.to_string(), - "美术 Agent 规划失败:LLM 请求超时,累计尝试 2 次" - ); - } - - #[test] - fn other_errors_do_not_expose_framework_prefixes() { - assert_eq!( - PromptError::ToolError("参数无效".to_string()).to_string(), - "美术 Agent 工具执行失败:参数无效" - ); - assert_eq!( - PromptError::InternalError("序列化失败".to_string()).to_string(), - "美术 Agent 内部错误:序列化失败" - ); - assert_eq!( - PromptError::MaxTurnsReached { max_turns: 3 }.to_string(), - "美术 Agent 规划轮数已达上限:3" - ); - } -} diff --git a/server-rs/crates/platform-editor-agent/src/framework/memory.rs b/server-rs/crates/platform-editor-agent/src/framework/memory.rs deleted file mode 100644 index 3157d7387..000000000 --- a/server-rs/crates/platform-editor-agent/src/framework/memory.rs +++ /dev/null @@ -1,31 +0,0 @@ -use serde::{Deserialize, Serialize}; - -pub trait AgentMemory: Send + Sync { - fn get_memory(&self) -> &[Message]; - fn append_message(&mut self, message: Message); -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VecMemory { - messages: Vec, -} - -impl VecMemory { - pub fn new(messages: Vec) -> Self { - Self { messages } - } - - pub fn into_inner(self) -> Vec { - self.messages - } -} - -impl AgentMemory for VecMemory { - fn get_memory(&self) -> &[Message] { - &self.messages - } - - fn append_message(&mut self, message: Message) { - self.messages.push(message); - } -} diff --git a/server-rs/crates/platform-editor-agent/src/framework/mod.rs b/server-rs/crates/platform-editor-agent/src/framework/mod.rs index 0f84bfb15..4491d4376 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/framework/mod.rs @@ -1,7 +1 @@ -pub mod agent; -pub mod agent_builder; -pub mod error; -pub mod hook; -pub mod memory; -pub mod run; -pub mod tool; +pub use platform_agent_harness::{agent, agent_builder, error, hook, memory, prompt, run, tool}; diff --git a/server-rs/crates/platform-editor-agent/src/framework/run.rs b/server-rs/crates/platform-editor-agent/src/framework/run.rs deleted file mode 100644 index 2acd2dc6e..000000000 --- a/server-rs/crates/platform-editor-agent/src/framework/run.rs +++ /dev/null @@ -1,458 +0,0 @@ -use crate::framework::agent::Agent; -use crate::framework::agent::LlmApiAdaptor; -use crate::framework::error::PromptError; -use crate::framework::hook::Hook; -use crate::framework::memory::VecMemory; -use crate::framework::run::PromptOutput::{Text, Tool}; -use crate::framework::tool::{ToolCall, ToolExecutionResult, ToolFailure, ToolOutcome}; -use serde::Deserialize; -use serde_json::Value; -use std::pin::Pin; - -pub type TextOutput = String; - -#[derive(Debug, Clone)] -pub struct ToolCallOutput { - pub tool_call: ToolCall, - pub output: Value, -} -#[derive(Debug, Clone)] -pub enum PromptOutput { - Text(TextOutput), - Tool(ToolCallOutput), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TextFlow { - Continue, - Stop, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolCallFlow { - Continue, - Skip, - Stop, -} - -pub fn format_tool_call_message( - tool_call_id: impl std::fmt::Display, - args: &Value, - output: &Value, -) -> Result { - let arg_json = serde_json::to_string(args) - .map_err(|error| PromptError::InternalError(error.to_string()))?; - let output_json = serde_json::to_string(output) - .map_err(|error| PromptError::InternalError(error.to_string()))?; - Ok(format!( - "[tool_call:{tool_call_id}] args: {arg_json} output: {output_json}" - )) -} - -pub struct PromptRequest<'a, M: LlmApiAdaptor + 'a, Message: 'a> { - agent: &'a mut Agent, - message: Message, - system_prompt: Option, - hooks: Vec>, - max_turns: usize, -} - -impl<'a, M, Message> PromptRequest<'a, M, Message> -where - M: LlmApiAdaptor + 'a, - Message: 'a, -{ - pub fn new(agent: &'a mut Agent, message: Message) -> Self { - let max_turns = agent.default_max_turns; - Self { - agent, - message, - system_prompt: None, - hooks: Vec::new(), - max_turns, - } - } - - pub fn system_prompt(mut self, msg: Message) -> Self { - self.system_prompt = Some(msg); - self - } - - pub fn add_hook(mut self, hook: impl Hook + 'static) -> Self { - self.hooks.push(Box::new(hook)); - self - } - - pub fn max_turns(mut self, n: usize) -> Self { - self.max_turns = n; - self - } -} - -impl<'a, M, Message> IntoFuture for PromptRequest<'a, M, Message> -where - M: LlmApiAdaptor + Send + Sync + 'a, - Message: Send + Sync + Clone + 'a + 'static, -{ - type Output = Result, PromptError>; - type IntoFuture = Pin + Send + 'a>>; - - fn into_future(self) -> Self::IntoFuture { - let agent = self.agent; - let message = self.message; - let extra_hooks = self.hooks; - let max_turns = self.max_turns; - - Box::pin(async move { - let memory = agent - .memory - .get_or_insert_with(|| Box::new(VecMemory::new(Vec::new()))); - memory.append_message(message); - let mut prompt_result: Vec = Vec::new(); - - for _ in 0..max_turns { - let text = { - let messages = agent.system_prompt.iter().chain(memory.get_memory().iter()); - agent.model.complete(messages).await? - }; - - // Try to parse the LLM reply as JSON (handle Markdown fences) - let cleaned = clean_json_response(&text); - match serde_json::from_str::(&cleaned) { - Ok(json_resp) => { - let clean_text = json_resp.reply_text.trim().to_string(); - - // Run on_text_reply hooks - for hook in agent.hooks.iter().chain(extra_hooks.iter()) { - match hook.on_text_reply(&clean_text) { - TextFlow::Stop => { - return Err(PromptError::ToolError( - "text reply rejected by hook".to_string(), - )); - } - TextFlow::Continue => {} - } - } - memory.append_message(agent.model.build_assistant_message(&clean_text)); - prompt_result.push(Text(clean_text.clone())); - - let tool_calls: Vec = json_resp - .tool_calls - .into_iter() - .enumerate() - .map(|(idx, tc)| ToolCall { - id: format!("{idx}"), - name: tc.tool_name, - args: tc.args, - }) - .collect(); - - // no tool call, turn terminate. - if tool_calls.is_empty() { - return Ok(prompt_result); - } - - let mut all_tool_calls_await_user_confirmation = true; - for (tc_id, tc) in tool_calls.iter().enumerate() { - // inline run_hooks: before_tool_call hook - let mut should_skip = false; - for hook in agent.hooks.iter().chain(extra_hooks.iter()) { - match hook.before_tool_call(tc) { - ToolCallFlow::Stop => { - return Err(PromptError::ToolError( - "tool call rejected by hook".to_string(), - )); - } - ToolCallFlow::Skip => { - let msg = agent - .model - .tool_result_message(&tc.name, "(skipped by hook)"); - memory.append_message(msg); - should_skip = true; - break; - } - ToolCallFlow::Continue => {} - } - } - if should_skip { - all_tool_calls_await_user_confirmation = false; - continue; - } - - let matching_tool = - agent.tools.iter().find(|tool| tool.tool_name() == tc.name); - let requires_user_confirmation = - matching_tool.is_some_and(|tool| tool.requires_user_confirmation()); - let result = match matching_tool { - Some(tool) => tool.call(tc.args.clone()).await, - None => ToolExecutionResult::failed( - Value::Null, - ToolFailure::invalid_args(format!("unknown tool: {}", tc.name)), - ), - }; - if !requires_user_confirmation - || !matches!(&result.outcome, ToolOutcome::InternalOk) - { - all_tool_calls_await_user_confirmation = false; - } - - match result.outcome { - ToolOutcome::InternalOk => { - let mut json_output = result.output; - // Run after_tool_call hooks to allow output modification - for hook in agent.hooks.iter().chain(extra_hooks.iter()) { - match hook.after_tool_call(&tc.name, &mut json_output) { - ToolCallFlow::Stop => { - return Err(PromptError::ToolError( - "tool call output caused this turn stopped by hook".to_string(), - )); - } - ToolCallFlow::Skip => { - all_tool_calls_await_user_confirmation = false; - json_output = serde_json::json!({"message":"tool call is ignored by hook"}); - break; - } - ToolCallFlow::Continue => {} - } - } - let overall_message = - format_tool_call_message(tc_id, &tc.args, &json_output)?; - let msg = - agent.model.tool_result_message(&tc.name, &overall_message); - memory.append_message(msg); - prompt_result.push(Tool(ToolCallOutput { - tool_call: tc.clone(), - output: json_output, - })) - } - ToolOutcome::InternalError(failure) if failure.fatal => { - return Err(PromptError::ToolError(failure.message)); - } - ToolOutcome::InternalError(failure) => { - let msg = agent.model.tool_result_message( - &tc.name, - &format!("error: {}", failure.message), - ); - memory.append_message(msg); - } - } - } - - if all_tool_calls_await_user_confirmation { - return Ok(prompt_result); - } - } - Err(_) => { - // Not valid JSON — push as assistant message and continue to next _turn - memory.append_message(agent.model.build_assistant_message(&text)); - continue; - } - } - } - - Err(PromptError::MaxTurnsReached { max_turns }) - }) - } -} - -#[derive(Deserialize)] -struct LlmJsonResponse { - reply_text: String, - #[serde(default)] - tool_calls: Vec, -} - -#[derive(Deserialize)] -struct LlmToolCallRequest { - tool_name: String, - #[serde(default)] - args: Value, -} - -pub fn clean_json_response(text: &str) -> String { - let text = text.trim(); - if text.starts_with("```") { - let lines: Vec<&str> = text.lines().collect(); - let mut cleaned = Vec::new(); - let mut in_code = false; - for line in lines { - if line.trim().starts_with("```") { - in_code = !in_code; - continue; - } - if in_code { - cleaned.push(line); - } - } - if !cleaned.is_empty() { - return cleaned.join("\n").trim().to_string(); - } - } - text.to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::framework::agent::LlmApiAdaptor; - use crate::framework::hook::Hook; - use crate::framework::tool::Tool; - use serde_json::json; - use std::convert::Infallible; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - struct RepeatingToolCallModel { - completion_count: Arc, - } - - impl LlmApiAdaptor for RepeatingToolCallModel { - async fn complete<'a>( - &self, - _messages: impl Iterator + Send, - ) -> Result { - self.completion_count.fetch_add(1, Ordering::SeqCst); - Ok(json!({ - "reply_text": "请确认这次生成", - "tool_calls": [{ - "tool_name": "test-tool", - "args": { "prompt": "生成一张图" } - }] - }) - .to_string()) - } - - fn tool_result_message(&self, tool_name: &str, output: &str) -> String { - format!("{tool_name}: {output}") - } - - fn build_assistant_message(&self, text: &str) -> String { - text.to_string() - } - } - - struct TestTool { - requires_user_confirmation: bool, - } - - struct SkipAfterToolCallHook; - - impl Hook for SkipAfterToolCallHook { - fn after_tool_call(&self, _tool_name: &str, _output: &mut Value) -> ToolCallFlow { - ToolCallFlow::Skip - } - } - - impl Tool for TestTool { - const NAME: &'static str = "test-tool"; - type Error = Infallible; - type Args = Value; - type Output = Value; - - fn description(&self) -> String { - "test tool".to_string() - } - - fn parameters(&self) -> Value { - json!({ "type": "object" }) - } - - fn call( - &self, - _args: Self::Args, - ) -> impl Future> + Send { - async { Ok(json!({ "message": "pending user confirmation" })) } - } - - fn requires_user_confirmation(&self) -> bool { - self.requires_user_confirmation - } - } - - #[tokio::test] - async fn pending_confirmation_tool_batch_finishes_without_another_completion() { - let completion_count = Arc::new(AtomicUsize::new(0)); - let model = RepeatingToolCallModel { - completion_count: completion_count.clone(), - }; - let mut agent = Agent::new(model) - .tool(TestTool { - requires_user_confirmation: true, - }) - .max_turns(3); - - let outputs = agent - .prompt("生成一张图".to_string()) - .await - .expect("pending confirmation should finish the planning turn"); - - assert_eq!(completion_count.load(Ordering::SeqCst), 1); - assert_eq!(outputs.len(), 2); - assert!(matches!(outputs[0], PromptOutput::Text(_))); - let PromptOutput::Tool(tool_output) = &outputs[1] else { - panic!("second output should be a tool call"); - }; - assert_eq!( - tool_output.output, - json!({ "message": "pending user confirmation" }) - ); - assert_eq!( - format_tool_call_message( - "test-tool", - &tool_output.tool_call.args, - &tool_output.output, - ) - .expect("tool call message should format"), - "[tool_call:test-tool] args: {\"prompt\":\"生成一张图\"} output: {\"message\":\"pending user confirmation\"}" - ); - } - - #[tokio::test] - async fn non_confirmation_tool_keeps_the_existing_max_turn_guard() { - let completion_count = Arc::new(AtomicUsize::new(0)); - let model = RepeatingToolCallModel { - completion_count: completion_count.clone(), - }; - let mut agent = Agent::new(model) - .tool(TestTool { - requires_user_confirmation: false, - }) - .max_turns(3); - - let error = agent - .prompt("生成一张图".to_string()) - .await - .expect_err("a continuing tool should still hit the max-turn guard"); - - assert_eq!(completion_count.load(Ordering::SeqCst), 3); - assert!(matches!( - error, - PromptError::MaxTurnsReached { max_turns: 3 } - )); - } - - #[tokio::test] - async fn skipped_confirmation_result_keeps_the_existing_max_turn_guard() { - let completion_count = Arc::new(AtomicUsize::new(0)); - let model = RepeatingToolCallModel { - completion_count: completion_count.clone(), - }; - let mut agent = Agent::new(model) - .tool(TestTool { - requires_user_confirmation: true, - }) - .hook(SkipAfterToolCallHook) - .max_turns(3); - - let error = agent - .prompt("生成一张图".to_string()) - .await - .expect_err("a skipped result must not finish as pending confirmation"); - - assert_eq!(completion_count.load(Ordering::SeqCst), 3); - assert!(matches!( - error, - PromptError::MaxTurnsReached { max_turns: 3 } - )); - } -}