Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e180368c48 | |||
| 0c6b62dc8a | |||
| 124e552e3a | |||
| 3ffd37236f | |||
| af9078cb97 | |||
| e522f651bc | |||
| c72f77e9ca | |||
| fe4c2da9b3 | |||
| aa74c8a9f9 | |||
| 483f636570 | |||
| 5aecadf700 | |||
| f42d8ee811 | |||
| 4b6df610af |
-16
@@ -172,20 +172,6 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
|
||||
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
||||
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
|
||||
## 项目开发对话(DirectProject)
|
||||
|
||||
**项目对话历史**:
|
||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||
|
||||
**运行态事件**:
|
||||
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
||||
_Avoid_: 进度通知、快照轮询、第二套历史
|
||||
|
||||
**聊天投影**:
|
||||
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
||||
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
||||
|
||||
## Relationships
|
||||
|
||||
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
||||
@@ -220,5 +206,3 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
||||
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
||||
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
||||
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
||||
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。
|
||||
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
|
||||
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
|
||||
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
|
||||
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
|
||||
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, waits for the accepted operation, downloads and registers the completed local asset, and preserves the operation for recovery when the remote result is not yet known.
|
||||
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
|
||||
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
|
||||
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
|
||||
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
|
||||
|
||||
+1
-3
@@ -16,6 +16,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
|
||||
|
||||
`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images.
|
||||
|
||||
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated request. Ordinary account mode maps the External v1 shaped route to `/api/editor/images/background-removals`; ExternalDeveloper mode uses `/api/external/v1/editor/images/background-removals`. Mode and colour are part of request identity. After acceptance, the client polls the authenticated generation status route, downloads the completed media, and commits it to the local manifest. If completion is unknown, it retains the same local operation for recovery; it never retries with a new identity or exposes internal worker details.
|
||||
|
||||
After an interrupted call, inspect `agc_list_registered_assets.pendingOperations`. Calling `agc_remove_background` again with the same source, name, mode, and colour resumes the matching pending operation. A submission marked `reconciliation-required` needs client-side reconciliation and cannot be automatically resumed. Do not change parameters to bypass a pending task. A queued receipt, fixed progress value, or absent local file does not establish that the background-removal provider is waiting in a queue; report only the observed state.
|
||||
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.24",
|
||||
"version": "2026-08-26.20",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -63,7 +63,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/platform-art-contract.md"
|
||||
],
|
||||
"sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c"
|
||||
"sha256": "c6329c6a3cbd17a237d042349d7fd8adcf240287ef56d23b49329923e976d534"
|
||||
},
|
||||
{
|
||||
"name": "agc-web-game-development",
|
||||
@@ -123,7 +123,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/projection-contract.md"
|
||||
],
|
||||
"sha256": "247787975944ce8b21d7c879c39c60ec13608056cff9426ac374c9299937d475"
|
||||
"sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,12 +19,6 @@ image, UI design image, or publication material; use `agc_edit_image` for an
|
||||
edit of an existing registered image; use `taonier_prepare_game_art` only for
|
||||
the complete game-art package and its canonical slices.
|
||||
|
||||
With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"`
|
||||
generate the subject on a solid-colour background and automatically matte it
|
||||
away afterwards, producing transparent-background results; write the prompt
|
||||
for the subject only, never for a scene. `kind="image"` keeps the rendered
|
||||
frame without extra processing.
|
||||
|
||||
When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
|
||||
required and has no default, so decide it explicitly:
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_thread_manager;
|
||||
mod direct_thread_wire;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_tools_mcp;
|
||||
@@ -41,8 +40,7 @@ use codex_app_server::*;
|
||||
pub(crate) use codex_app_server::{
|
||||
cancel_direct_codex_turn_at,
|
||||
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
|
||||
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
|
||||
direct_thread_id_for_project, DirectTurnCancelView,
|
||||
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView,
|
||||
};
|
||||
use codex_cli::*;
|
||||
pub(crate) use codex_cli::{
|
||||
@@ -57,7 +55,6 @@ pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_thread_manager::*;
|
||||
pub(crate) use direct_thread_wire::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tool_calls::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
-23
@@ -20,29 +20,6 @@ pub(crate) fn direct_codex_canonical_project_identity(
|
||||
))
|
||||
}
|
||||
|
||||
/// 项目根目录在 Thread Manager 里的线程身份。
|
||||
///
|
||||
/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到
|
||||
/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有
|
||||
/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。
|
||||
///
|
||||
/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见
|
||||
/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把
|
||||
/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个
|
||||
/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。
|
||||
pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String {
|
||||
let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else {
|
||||
return root.to_string_lossy().into_owned();
|
||||
};
|
||||
canonical_root
|
||||
.to_str()
|
||||
.and_then(|value| value.strip_prefix(r"\\?\"))
|
||||
.map(std::path::Path::new)
|
||||
.unwrap_or(canonical_root.as_path())
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -550,8 +550,6 @@ fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value {
|
||||
"agc_generate_image" => {
|
||||
copy_string(object, "kind", &mut out);
|
||||
copy_string(object, "sliceMode", &mut out);
|
||||
copy_number(object, "sliceCount", &mut out);
|
||||
copy_string(object, "screenColor", &mut out);
|
||||
copy_string(object, "aspectRatio", &mut out);
|
||||
copy_string(object, "imageSize", &mut out);
|
||||
copy_string(object, "assetName", &mut out);
|
||||
@@ -1196,13 +1194,7 @@ mod tests {
|
||||
"item": {
|
||||
"type": "mcpToolCall",
|
||||
"tool": "agc_generate_image",
|
||||
"arguments": {
|
||||
"prompt": prompt,
|
||||
"kind": "art-spritesheet",
|
||||
"sliceMode": "connected-components",
|
||||
"sliceCount": 8,
|
||||
"screenColor": "#CFEFFF"
|
||||
}
|
||||
"arguments": { "prompt": prompt, "kind": "icon-spec" }
|
||||
}
|
||||
}));
|
||||
audit.finish(true);
|
||||
@@ -1213,8 +1205,6 @@ mod tests {
|
||||
let stored = item["arguments"]["prompt"].as_str().expect("prompt");
|
||||
assert_eq!(stored.chars().count(), DIRECT_CODEX_AUDIT_BRIEF_CHARS);
|
||||
assert_eq!(item["arguments"]["promptChars"], json!(5000));
|
||||
assert_eq!(item["arguments"]["sliceCount"], json!(8));
|
||||
assert_eq!(item["arguments"]["screenColor"], json!("#CFEFFF"));
|
||||
assert_eq!(
|
||||
item["arguments"]["promptSha256"],
|
||||
json!(sha256_hex("收".repeat(5000).as_bytes()))
|
||||
|
||||
@@ -5,8 +5,8 @@ mod validation;
|
||||
mod wire;
|
||||
|
||||
pub(crate) use model::{
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
|
||||
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
|
||||
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
pub(crate) use validation::validate_direct_codex_user_item;
|
||||
pub(crate) use wire::{
|
||||
|
||||
@@ -61,6 +61,13 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
||||
pub(crate) resource_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) struct DirectCodexUserMessageEnvelope {
|
||||
pub(crate) item: DirectCodexUserItem,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3390,7 +3390,6 @@ async fn generate_direct_taonier_art_asset_at(
|
||||
slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()),
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
screen_color: None,
|
||||
};
|
||||
let runtime_context =
|
||||
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1294,10 +1294,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
.map(|asset| bridge_registered_resource(asset, include_sequence_frames))
|
||||
.collect::<Vec<_>>();
|
||||
let next_offset = (offset + resources.len() < total).then_some(offset + resources.len());
|
||||
let platform_session = (editor_api_mode() == EditorApiMode::PlatformAccount)
|
||||
.then(current_platform_session)
|
||||
.flatten();
|
||||
let pending = list_pending_local_project_resource_edits_for_session_at(
|
||||
let pending = list_pending_local_project_resource_edits_at(
|
||||
ListPendingLocalProjectResourceEditsInput {
|
||||
project_path: root
|
||||
.to_str()
|
||||
@@ -1305,7 +1302,6 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
.to_string(),
|
||||
expected_project_id: manifest.project_id,
|
||||
},
|
||||
platform_session.as_ref(),
|
||||
)?
|
||||
.into_iter()
|
||||
.map(|edit| {
|
||||
@@ -1315,8 +1311,6 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
"mode": edit.generation_mode,
|
||||
"sourceResourceId": edit.source_resource_id,
|
||||
"assetName": edit.asset_name,
|
||||
"backgroundMode": edit.background_mode,
|
||||
"screenColor": edit.screen_color,
|
||||
"phase": edit.phase,
|
||||
"createdAt": edit.created_at,
|
||||
})
|
||||
@@ -1807,8 +1801,8 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments:
|
||||
|
||||
fn bridge_completed_resource_result(
|
||||
root: &Path,
|
||||
kind: &str,
|
||||
mode: &str,
|
||||
kind: DirectResourceGenerationKind,
|
||||
mode: DirectResourceGenerationMode,
|
||||
result: DeriveLocalProjectResourceResult,
|
||||
) -> Result<Value, String> {
|
||||
let asset = result
|
||||
@@ -1820,8 +1814,8 @@ fn bridge_completed_resource_result(
|
||||
Ok(json!({
|
||||
"status": "completed",
|
||||
"operationId": result.operation_id,
|
||||
"kind": kind,
|
||||
"mode": mode,
|
||||
"kind": kind.as_str(),
|
||||
"mode": mode.as_str(),
|
||||
"sourceResourceId": result.source_resource_id,
|
||||
"committedProjectRevision": result.committed_project_revision,
|
||||
"resource": bridge_registered_resource(asset, true),
|
||||
@@ -1916,17 +1910,10 @@ async fn bridge_create_or_derive_resource(
|
||||
source_version_id: None,
|
||||
prompt: input.prompt.clone(),
|
||||
asset_name: input.asset_name.clone(),
|
||||
background_mode: None,
|
||||
screen_color: None,
|
||||
};
|
||||
with_direct_editor_api_credentials(derive_local_project_resource_at(request)).await?
|
||||
};
|
||||
bridge_completed_resource_result(
|
||||
&state.root,
|
||||
input.kind.as_str(),
|
||||
input.mode.as_str(),
|
||||
completed,
|
||||
)
|
||||
bridge_completed_resource_result(&state.root, input.kind, input.mode, completed)
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
@@ -1940,8 +1927,7 @@ async fn bridge_create_or_derive_resource(
|
||||
}
|
||||
|
||||
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||
let _generation_guard = state.resource_generation_gate.lock().await;
|
||||
let result = with_direct_editor_api_credentials(async {
|
||||
let result = async {
|
||||
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
|
||||
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&state.root, "asset.register")?;
|
||||
@@ -1962,72 +1948,74 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
if !source_asset.media_type.starts_with("image/") {
|
||||
return Err("抠图工具只接受当前项目已登记的图片资源".to_string());
|
||||
}
|
||||
let background_mode = background_mode.unwrap_or("complex").to_string();
|
||||
let source_resource_id = bridge_asset_canonical_resource_id(source_asset);
|
||||
let source_resource_id = source_asset
|
||||
.source
|
||||
.resource_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && !value.starts_with("local-asset:"))
|
||||
.ok_or_else(|| "图片资源缺少可供抠图服务使用的正式 resourceId".to_string())?
|
||||
.to_string();
|
||||
let (api_base_url, api_key, session) = resolve_canvas_sync_api_credentials(None, None)?;
|
||||
let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, session.as_ref())?;
|
||||
let client = crate::http_client::agc_main_site_client_builder()
|
||||
.build()
|
||||
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
||||
let context =
|
||||
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
||||
let fingerprint = background_removal_request_fingerprint(
|
||||
&source_asset_id,
|
||||
&asset_name,
|
||||
Some(background_mode.as_str()),
|
||||
background_mode,
|
||||
screen_color,
|
||||
);
|
||||
let (_, _, platform_session) = resolve_canvas_sync_api_credentials(None, None)?;
|
||||
let pending = list_pending_local_project_resource_edits_for_session_at(
|
||||
ListPendingLocalProjectResourceEditsInput {
|
||||
project_path: state.root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
},
|
||||
platform_session.as_ref(),
|
||||
)?;
|
||||
let matching_pending = pending
|
||||
.into_iter()
|
||||
.filter(|pending| {
|
||||
pending.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval
|
||||
&& (pending.source_asset_id.as_deref() == Some(source_asset_id.as_str())
|
||||
|| pending.source_resource_id == format!("local-asset:{source_asset_id}"))
|
||||
&& pending.asset_name == asset_name
|
||||
&& pending.background_mode.as_deref().unwrap_or("complex")
|
||||
== background_mode.as_str()
|
||||
&& pending.screen_color.as_deref() == screen_color
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if matching_pending.len() > 1 {
|
||||
return Err("存在多个相同抠图 operation,必须先在客户端完成对账".to_string());
|
||||
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||
let route = "/api/external/v1/editor/images/background-removals";
|
||||
let mut request_body = json!({
|
||||
"sourceImageSrc": source_resource_id,
|
||||
"projectId": manifest.project_id,
|
||||
"assetKind": source_asset.kind,
|
||||
"assetFolderId": context.asset_folder_id,
|
||||
"assetLabel": asset_name,
|
||||
"sourceResourceId": source_resource_id,
|
||||
});
|
||||
if background_mode == Some("flat") {
|
||||
request_body["backgroundMode"] = json!("flat");
|
||||
}
|
||||
let completed = if let Some(pending) = matching_pending.into_iter().next() {
|
||||
resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput {
|
||||
project_path: state.root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
operation_id: pending.operation_id,
|
||||
})
|
||||
.await?
|
||||
} else {
|
||||
let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||
let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision;
|
||||
let request = DeriveLocalProjectResourceInput {
|
||||
project_path: state.root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
expected_project_revision: revision,
|
||||
operation_id,
|
||||
idempotency_key,
|
||||
edit_kind: LocalProjectResourceEditKind::BackgroundRemoval,
|
||||
generation_mode: LocalProjectResourceGenerationMode::Derive,
|
||||
source_resource_id,
|
||||
source_asset_id: Some(source_asset_id.clone()),
|
||||
source_path: Some(source_asset.local_path.clone()),
|
||||
source_media_type: Some(source_asset.media_type.clone()),
|
||||
source_subtype: Some(source_asset.kind.clone()),
|
||||
producer_task_id: source_asset.source.task_id.clone(),
|
||||
source_version_id: None,
|
||||
prompt: "去除背景".to_string(),
|
||||
asset_name: asset_name.clone(),
|
||||
background_mode: Some(background_mode),
|
||||
screen_color: screen_color.map(str::to_string),
|
||||
};
|
||||
derive_local_project_resource_at(request).await?
|
||||
};
|
||||
emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal");
|
||||
bridge_completed_resource_result(&state.root, "background-removal", "derive", completed)
|
||||
})
|
||||
if let Some(color) = screen_color {
|
||||
request_body["screenColor"] = json!(color);
|
||||
}
|
||||
let response = crate::http_client::with_agc_main_site_marker(
|
||||
client
|
||||
.post(format!("{}{}", api_base_url, route))
|
||||
.bearer_auth(api_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.json(&request_body),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("抠图服务提交失败:{error}"))?;
|
||||
let status = response.status();
|
||||
let payload = response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|error| format!("抠图服务响应无法解析:{error}"))?;
|
||||
if !status.is_success() {
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string());
|
||||
}
|
||||
return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16()));
|
||||
}
|
||||
let queue_state = external_editor_response_data(&payload).clone();
|
||||
Ok::<_, String>(json!({
|
||||
"status": "queued",
|
||||
"sourceLocalAssetId": source_asset_id,
|
||||
"assetName": asset_name,
|
||||
"projectId": manifest.project_id,
|
||||
"assetFolderId": context.asset_folder_id,
|
||||
"queueState": bridge_safe_queue_state(queue_state),
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(value) => bridge_tool_result(value.to_string(), Vec::new(), false),
|
||||
@@ -2053,6 +2041,17 @@ fn background_removal_request_fingerprint(
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_safe_queue_state(value: Value) -> Value {
|
||||
let object = value.as_object();
|
||||
json!({
|
||||
"operationId": object.and_then(|value| value.get("operationId")).and_then(Value::as_str),
|
||||
"status": object.and_then(|value| value.get("status")).and_then(Value::as_str),
|
||||
"phaseLabel": object.and_then(|value| value.get("phaseLabel")).and_then(Value::as_str),
|
||||
"progress": object.and_then(|value| value.get("progress")).and_then(Value::as_u64),
|
||||
"updatedAtMicros": object.and_then(|value| value.get("updatedAtMicros")).and_then(Value::as_u64),
|
||||
})
|
||||
}
|
||||
|
||||
fn bridge_art_resources(
|
||||
root: &Path,
|
||||
asset_paths: &[String],
|
||||
@@ -2224,48 +2223,12 @@ fn validate_generate_image_slice_declaration(
|
||||
}
|
||||
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() {
|
||||
return Err(format!(
|
||||
"工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=art-spritesheet 生效,当前 kind={kind}"
|
||||
"工具参数 sliceMode/gridX/gridY 仅对 kind=art-spritesheet 生效,当前 kind={kind}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为
|
||||
/// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为
|
||||
/// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝,
|
||||
/// 避免服务端静默忽略造成“已生效”的误解。
|
||||
fn normalize_generate_image_screen_color(
|
||||
arguments: &Value,
|
||||
kind: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(value) = arguments.get("screenColor") else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !matches!(kind, "character" | "art-spritesheet") {
|
||||
return Err(format!(
|
||||
"工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}"
|
||||
));
|
||||
}
|
||||
let raw = value
|
||||
.as_str()
|
||||
.ok_or_else(|| "工具参数 screenColor 必须是 auto 或 #RRGGBB".to_string())?
|
||||
.trim();
|
||||
if raw.is_empty() || raw.eq_ignore_ascii_case("auto") {
|
||||
return Ok(None);
|
||||
}
|
||||
let normalized = raw.to_ascii_uppercase();
|
||||
let valid = normalized.len() == 7
|
||||
&& normalized.starts_with('#')
|
||||
&& normalized[1..].chars().all(|c| c.is_ascii_hexdigit());
|
||||
if !valid {
|
||||
return Err("工具参数 screenColor 必须是 auto 或 #RRGGBB".to_string());
|
||||
}
|
||||
Ok(Some(normalized))
|
||||
}
|
||||
|
||||
async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||
let result = async {
|
||||
bridge_reject_unknown_fields(
|
||||
@@ -2280,8 +2243,6 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
"sliceMode",
|
||||
"gridX",
|
||||
"gridY",
|
||||
"sliceCount",
|
||||
"screenColor",
|
||||
],
|
||||
)?;
|
||||
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
||||
@@ -2361,25 +2322,13 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
{
|
||||
return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string());
|
||||
}
|
||||
let slice_count = arguments
|
||||
.get("sliceCount")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.filter(|count| (1..=256).contains(count))
|
||||
.map(|count| count as usize)
|
||||
.ok_or_else(|| "工具参数 sliceCount 必须是 1 到 256 的整数".to_string())
|
||||
})
|
||||
.transpose()?;
|
||||
validate_generate_image_slice_declaration(
|
||||
kind.as_str(),
|
||||
slice_mode.as_deref(),
|
||||
grid_x,
|
||||
grid_y,
|
||||
slice_count,
|
||||
None,
|
||||
)?;
|
||||
let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?;
|
||||
let options = PlatformArtAssetGenerationOptions {
|
||||
output_path,
|
||||
aspect_ratio,
|
||||
@@ -2387,11 +2336,10 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
asset_kind: kind.clone(),
|
||||
asset_label: asset_name.clone(),
|
||||
replace_existing: false,
|
||||
slice_count,
|
||||
slice_count: None,
|
||||
slice_mode,
|
||||
grid_x,
|
||||
grid_y,
|
||||
screen_color,
|
||||
};
|
||||
let _generation_guard = state.image_generation_gate.lock().await;
|
||||
let generated = with_direct_editor_api_credentials(
|
||||
@@ -2915,71 +2863,9 @@ mod tests {
|
||||
wrong_kind.contains("仅对 kind=art-spritesheet 生效"),
|
||||
"{wrong_kind}"
|
||||
);
|
||||
let wrong_kind_count =
|
||||
validate_generate_image_slice_declaration("image", None, None, None, Some(8))
|
||||
.expect_err("sliceCount-only violation must be rejected");
|
||||
assert!(
|
||||
wrong_kind_count.contains("sliceCount"),
|
||||
"sliceCount-only violation must name sliceCount: {wrong_kind_count}"
|
||||
);
|
||||
assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_image_screen_color_is_normalized_and_kind_gated() {
|
||||
// 省略与显式 null 等价,且不触发 kind 门禁。
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": null}), "image")
|
||||
.expect("null"),
|
||||
None
|
||||
);
|
||||
// auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。
|
||||
for raw in ["auto", "AUTO", " auto ", ""] {
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character")
|
||||
.expect("auto variants"),
|
||||
None,
|
||||
"{raw}"
|
||||
);
|
||||
}
|
||||
// hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character")
|
||||
.expect("lowercase hex"),
|
||||
Some("#CFEFFF".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(
|
||||
&json!({"screenColor": " #A0BBA0 "}),
|
||||
"art-spritesheet"
|
||||
)
|
||||
.expect("padded hex"),
|
||||
Some("#A0BBA0".to_string())
|
||||
);
|
||||
// 非 auto/非 hex、非字符串一律拒绝。
|
||||
for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] {
|
||||
assert!(
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character")
|
||||
.is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
// 其它 kind 携带该字段直接拒绝,即使取值合法。
|
||||
let gated =
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image")
|
||||
.expect_err("screenColor must stay scoped to character/art-spritesheet");
|
||||
assert!(gated.contains("kind=character"), "{gated}");
|
||||
assert!(normalize_generate_image_screen_color(
|
||||
&json!({"screenColor": "auto"}),
|
||||
"ui-prototype"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_background_identity_preserves_default_and_distinguishes_options() {
|
||||
let legacy = "asset-1\0透明图";
|
||||
@@ -3958,4 +3844,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_background_removal_queue_projection_is_bounded() {
|
||||
let projection = bridge_safe_queue_state(json!({
|
||||
"operationId": "background-removal-1",
|
||||
"status": "queued",
|
||||
"phaseLabel": "排队中",
|
||||
"progress": 0,
|
||||
"updatedAtMicros": 1,
|
||||
"error": "private provider detail",
|
||||
"signedUrl": "https://private.invalid/result"
|
||||
}));
|
||||
assert_eq!(projection["operationId"], "background-removal-1");
|
||||
assert!(projection.get("error").is_none());
|
||||
assert!(projection.get("signedUrl").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! GameAgent 对话「工具调用卡片」的采集与持久化。
|
||||
//! GameAgent 对话「工具调用卡片」的采集、持久化与回读。
|
||||
//!
|
||||
//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`:
|
||||
//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更
|
||||
@@ -8,9 +8,11 @@
|
||||
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
|
||||
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
|
||||
|
||||
use super::direct_thread_wire::sanitize_detail_text;
|
||||
use crate::config::write_game_creator_private_file;
|
||||
use crate::agent::redact_secret_tokens;
|
||||
use crate::agent::sanitize_error_context;
|
||||
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
|
||||
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||
use crate::redact_absolute_path_tokens;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -22,7 +24,7 @@ use std::path::{Path, PathBuf};
|
||||
pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item";
|
||||
/// 条目 schema 版本。
|
||||
pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1";
|
||||
/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
|
||||
/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
|
||||
pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200;
|
||||
/// `detail.command` / `detail.output` 的字符上限。
|
||||
const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000;
|
||||
@@ -84,6 +86,134 @@ fn tool_calls_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/tool-calls.jsonl")
|
||||
}
|
||||
|
||||
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
|
||||
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
|
||||
let mut index = start;
|
||||
let mut relative = String::new();
|
||||
while index < value.len() {
|
||||
let character = value[index..].chars().next().unwrap_or_default();
|
||||
if matches!(character, '/' | '\\') {
|
||||
if !relative.is_empty() {
|
||||
relative.push('/');
|
||||
}
|
||||
index += character.len_utf8();
|
||||
continue;
|
||||
}
|
||||
if character.is_whitespace()
|
||||
|| matches!(
|
||||
character,
|
||||
'\'' | '"'
|
||||
| '`'
|
||||
| ','
|
||||
| ';'
|
||||
| '|'
|
||||
| '&'
|
||||
| '('
|
||||
| ')'
|
||||
| '['
|
||||
| ']'
|
||||
| '{'
|
||||
| '}'
|
||||
| '<'
|
||||
| '>'
|
||||
| ':'
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
relative.push(character);
|
||||
index += character.len_utf8();
|
||||
}
|
||||
while relative.ends_with('/') {
|
||||
relative.pop();
|
||||
}
|
||||
(index, relative)
|
||||
}
|
||||
|
||||
/// 把项目根目录前缀换成**项目相对路径**(`<root>/game/src/x.ts` → `game/src/x.ts`)。
|
||||
///
|
||||
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
|
||||
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
|
||||
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
|
||||
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
|
||||
let root_text = root.to_string_lossy();
|
||||
let root_text = root_text.trim_end_matches(['/', '\\']);
|
||||
if root_text.is_empty() {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut needles = [
|
||||
root_text.to_string(),
|
||||
root_text.replace('\\', "/"),
|
||||
root_text.replace('/', "\\"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|needle| needle.to_ascii_lowercase())
|
||||
.filter(|needle| !needle.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
needles.sort();
|
||||
needles.dedup();
|
||||
let lower = value.to_ascii_lowercase();
|
||||
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut cursor = 0usize;
|
||||
while cursor < value.len() {
|
||||
let mut hit: Option<(usize, usize)> = None;
|
||||
for needle in &needles {
|
||||
let mut search = cursor;
|
||||
while let Some(relative) = lower[search..].find(needle.as_str()) {
|
||||
let start = search + relative;
|
||||
let end = start + needle.len();
|
||||
let left_is_boundary = start == 0
|
||||
|| lower[..start].chars().next_back().is_some_and(|character| {
|
||||
!character.is_alphanumeric() && character != '_' && character != '-'
|
||||
});
|
||||
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
|
||||
if hit.is_none_or(|(best_start, _)| start < best_start) {
|
||||
hit = Some((start, end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
search = end;
|
||||
}
|
||||
}
|
||||
let Some((start, end)) = hit else {
|
||||
break;
|
||||
};
|
||||
output.push_str(&value[cursor..start]);
|
||||
let (consumed, relative) = project_relative_path_segment(value, end);
|
||||
if relative.is_empty() {
|
||||
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
|
||||
output.push_str("<absolute-path>");
|
||||
} else {
|
||||
output.push_str(&relative);
|
||||
}
|
||||
cursor = consumed;
|
||||
}
|
||||
output.push_str(&value[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
|
||||
/// 错误上下文脱敏。
|
||||
///
|
||||
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
|
||||
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
|
||||
///
|
||||
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
|
||||
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
|
||||
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
|
||||
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
|
||||
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
|
||||
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
|
||||
///
|
||||
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
|
||||
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
|
||||
let without_project_root = relativize_project_root_paths(root, value);
|
||||
let without_absolute = redact_absolute_path_tokens(&without_project_root);
|
||||
let without_secret = redact_secret_tokens(&without_absolute);
|
||||
sanitize_error_context(&without_secret)
|
||||
}
|
||||
|
||||
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
|
||||
fn bounded_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
@@ -397,6 +527,15 @@ fn normalize_tool_calls(calls: Vec<DirectToolCall>) -> Vec<DirectToolCall> {
|
||||
normalized
|
||||
}
|
||||
|
||||
/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。
|
||||
pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result<Vec<DirectToolCall>, String> {
|
||||
let path = tool_calls_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(normalize_tool_calls(read_tool_call_lines(&path)))
|
||||
}
|
||||
|
||||
/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。
|
||||
fn status_certainty(status: &str) -> u8 {
|
||||
match status {
|
||||
@@ -545,22 +684,10 @@ mod tests {
|
||||
use super::{
|
||||
direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status,
|
||||
direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at,
|
||||
read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path,
|
||||
DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT,
|
||||
DIRECT_TOOL_CALL_SCHEMA_VERSION,
|
||||
read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall,
|
||||
DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
/// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪,
|
||||
/// 断言因此落在「磁盘上到底写了什么」这一层。
|
||||
fn persisted_tool_calls(root: &Path) -> Vec<DirectToolCall> {
|
||||
std::fs::read_to_string(tool_calls_path(root))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(tool_call_from_line)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一行合法的落盘信封(回读用例的夹具)。
|
||||
fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String {
|
||||
@@ -685,7 +812,7 @@ mod tests {
|
||||
.expect("completed tool call");
|
||||
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed");
|
||||
|
||||
let calls = persisted_tool_calls(root.path());
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls");
|
||||
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
|
||||
assert_eq!(calls[0].status, "completed");
|
||||
assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖");
|
||||
@@ -785,6 +912,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。
|
||||
#[test]
|
||||
fn tool_call_read_skips_corrupted_lines() {
|
||||
let root = init_tool_call_project("tool-call-corrupt");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
let good = serde_json::to_string(&json!({
|
||||
"type": "tool_call_item",
|
||||
"payload": {
|
||||
"schemaVersion": "agc-tool-call.v1",
|
||||
"id": "item-good",
|
||||
"turnId": "turn-1",
|
||||
"kind": "command",
|
||||
"title": "执行命令",
|
||||
"summary": "npm run build",
|
||||
"status": "completed",
|
||||
"detail": {"command": "npm run build"},
|
||||
"startedAt": 1,
|
||||
"updatedAt": 2
|
||||
}
|
||||
}))
|
||||
.expect("serialize good row");
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"),
|
||||
)
|
||||
.expect("write fixture");
|
||||
|
||||
let missing = tempfile::tempdir().expect("missing dir");
|
||||
assert!(
|
||||
read_direct_tool_calls_at(missing.path())
|
||||
.expect("missing file is empty")
|
||||
.is_empty(),
|
||||
"历史文件缺失必须返回空数组"
|
||||
);
|
||||
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines");
|
||||
assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条");
|
||||
assert_eq!(calls[0].id, "item-good");
|
||||
}
|
||||
|
||||
/// 判据:回读按时间正序,且超出上限时保留最新。
|
||||
#[test]
|
||||
fn tool_call_read_is_ordered_and_capped() {
|
||||
let root = init_tool_call_project("tool-call-cap");
|
||||
let total = DIRECT_TOOL_CALL_LIMIT + 5;
|
||||
let calls = (0..total)
|
||||
.map(|index| {
|
||||
direct_tool_call_from_item(
|
||||
root.path(),
|
||||
&json!({
|
||||
"id": format!("item-{index:04}"),
|
||||
"type": "commandExecution",
|
||||
"command": format!("run {index}"),
|
||||
"startedAtMs": 1000 + index as u64,
|
||||
}),
|
||||
"turn-1",
|
||||
false,
|
||||
1000 + index as u64,
|
||||
)
|
||||
.expect("tool call")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch");
|
||||
|
||||
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
|
||||
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条");
|
||||
assert_eq!(
|
||||
read.first().expect("first").id,
|
||||
format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT),
|
||||
"最早被裁掉的是最旧的条目"
|
||||
);
|
||||
assert!(
|
||||
read.windows(2)
|
||||
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
|
||||
"回读必须按时间正序"
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。
|
||||
#[test]
|
||||
fn tool_call_file_change_title_counts_unique_paths() {
|
||||
@@ -983,7 +1189,7 @@ mod tests {
|
||||
|
||||
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first");
|
||||
persist_direct_tool_call_at(root.path(), &running).expect("persist stale running");
|
||||
let calls = persisted_tool_calls(root.path());
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write");
|
||||
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
@@ -995,7 +1201,7 @@ mod tests {
|
||||
// 回合末整批落盘那条路径同样不得回退。
|
||||
persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running))
|
||||
.expect("persist stale running batch");
|
||||
let calls = persisted_tool_calls(root.path());
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
"整批落盘路径同样不得把 completed 打回 running"
|
||||
@@ -1003,6 +1209,28 @@ mod tests {
|
||||
assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt");
|
||||
}
|
||||
|
||||
/// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。
|
||||
#[test]
|
||||
fn tool_call_read_merges_duplicate_rows_monotonically() {
|
||||
let root = init_tool_call_project("tool-call-read-monotonic");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
let completed = tool_call_row("item-1", 1000, 2000);
|
||||
let stale_running = tool_call_row("item-1", 1000, 1000)
|
||||
.replace("\"status\":\"completed\"", "\"status\":\"running\"");
|
||||
assert!(stale_running.contains("\"status\":\"running\""));
|
||||
std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture");
|
||||
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows");
|
||||
assert_eq!(calls.len(), 1, "同 id 归并成一条");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
"磁盘上更旧的快照不得把状态打回 running"
|
||||
);
|
||||
assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt");
|
||||
assert_eq!(calls[0].started_at, 1000);
|
||||
}
|
||||
|
||||
/// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。
|
||||
#[test]
|
||||
fn tool_call_paths_become_project_relative() {
|
||||
@@ -1055,10 +1283,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回,
|
||||
/// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。
|
||||
/// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回。
|
||||
#[test]
|
||||
fn tool_call_pre_read_skips_invalid_utf8_line() {
|
||||
fn tool_call_read_skips_invalid_utf8_line() {
|
||||
let root = init_tool_call_project("tool-call-invalid-utf8");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
@@ -1071,7 +1298,7 @@ mod tests {
|
||||
bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes());
|
||||
bytes.push(b'\n');
|
||||
std::fs::write(&path, &bytes).expect("write invalid utf8 fixture");
|
||||
let calls = read_tool_call_lines(&path);
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8");
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
@@ -1091,7 +1318,7 @@ mod tests {
|
||||
bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes());
|
||||
bytes.push(b'\n');
|
||||
std::fs::write(&path, &bytes).expect("write truncated utf8 fixture");
|
||||
let calls = read_tool_call_lines(&path);
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line");
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
@@ -1099,16 +1326,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(calls[0].id, "item-a");
|
||||
assert_eq!(calls[1].id, "item-c");
|
||||
|
||||
let missing = tempfile::tempdir().expect("missing dir");
|
||||
assert!(
|
||||
read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(),
|
||||
"历史文件缺失时写前读取必须返回空表"
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的
|
||||
/// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
|
||||
/// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃
|
||||
/// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
|
||||
#[test]
|
||||
fn tool_call_cap_drops_oldest_turn_cards() {
|
||||
let root = init_tool_call_project("tool-call-cap-oldest");
|
||||
@@ -1145,7 +1366,7 @@ mod tests {
|
||||
.expect("newest tool call");
|
||||
persist_direct_tool_call_at(root.path(), &newest).expect("persist newest");
|
||||
|
||||
let read = persisted_tool_calls(root.path());
|
||||
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
|
||||
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条");
|
||||
assert_eq!(
|
||||
read.last().expect("last").id,
|
||||
@@ -1160,7 +1381,7 @@ mod tests {
|
||||
assert!(
|
||||
read.windows(2)
|
||||
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
|
||||
"落盘顺序必须按时间正序"
|
||||
"回读必须按时间正序"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
}),
|
||||
json!({
|
||||
"name": "agc_generate_image",
|
||||
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用。",
|
||||
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -247,7 +247,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"type": "string",
|
||||
"enum": PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
"default": "image",
|
||||
"description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图"
|
||||
"description": "image=普通新图,character=角色图,spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(项目须已有 icon-spec 规范图),publication-material=发布宣传图"
|
||||
},
|
||||
"aspectRatio": {
|
||||
"type": "string",
|
||||
@@ -286,16 +286,6 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"minimum": 1,
|
||||
"maximum": 32,
|
||||
"description": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供"
|
||||
},
|
||||
"sliceCount": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 256,
|
||||
"description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别"
|
||||
},
|
||||
"screenColor": {
|
||||
"type": "string",
|
||||
"description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近"
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
@@ -1141,8 +1131,6 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
|
||||
"sliceMode",
|
||||
"gridX",
|
||||
"gridY",
|
||||
"sliceCount",
|
||||
"screenColor",
|
||||
],
|
||||
) {
|
||||
return mcp_tool_result(error, Vec::new(), true);
|
||||
@@ -1171,7 +1159,6 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
|
||||
("assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS),
|
||||
("outputPath", 512),
|
||||
("sliceMode", 32),
|
||||
("screenColor", 16),
|
||||
] {
|
||||
if arguments.get(field).is_some() {
|
||||
if let Err(error) = bounded_tool_string(arguments, field, max_chars) {
|
||||
@@ -2404,35 +2391,11 @@ mod tests {
|
||||
assert_eq!(image_tool["inputSchema"]["required"], json!(["prompt"]));
|
||||
assert!(image_tool["description"]
|
||||
.as_str()
|
||||
.is_some_and(|description| description.contains("仅在用户明确要求生成新图时调用")));
|
||||
assert!(
|
||||
image_tool["inputSchema"]["properties"]["kind"]["description"]
|
||||
.as_str()
|
||||
.is_some_and(|description| description.contains("自动抠图")
|
||||
&& description.contains("prompt 只描述角色主体")
|
||||
&& description.contains("不做额外处理")),
|
||||
"kind description must carry the auto-matting semantics"
|
||||
);
|
||||
.is_some_and(|description| description.contains("不是本工具的限制")));
|
||||
assert_eq!(
|
||||
image_tool["inputSchema"]["properties"]["sliceMode"]["enum"],
|
||||
json!(["connected-components", "grid"])
|
||||
);
|
||||
assert_eq!(
|
||||
image_tool["inputSchema"]["properties"]["sliceCount"]["minimum"],
|
||||
json!(1)
|
||||
);
|
||||
assert_eq!(
|
||||
image_tool["inputSchema"]["properties"]["sliceCount"]["maximum"],
|
||||
json!(256)
|
||||
);
|
||||
assert!(
|
||||
image_tool["inputSchema"]["properties"]["screenColor"]["description"]
|
||||
.as_str()
|
||||
.is_some_and(|description| description.contains("抠图纯色背景")
|
||||
&& description.contains("kind=character")
|
||||
&& description.contains("不要附带色名")),
|
||||
"screenColor description must carry the matting-background semantics"
|
||||
);
|
||||
assert!(
|
||||
image_tool["inputSchema"]["properties"]["sliceMode"]
|
||||
.get("default")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! GameAgent 对话「回合流」的采集与持久化。
|
||||
//! GameAgent 对话「回合流」的采集、持久化与回读。
|
||||
//!
|
||||
//! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序**
|
||||
//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文
|
||||
@@ -11,7 +11,7 @@
|
||||
//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。
|
||||
|
||||
use crate::agent::sanitize_detail_text;
|
||||
use crate::config::write_game_creator_private_file;
|
||||
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
|
||||
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -339,6 +339,15 @@ pub(crate) fn upsert_direct_turn_stream_item_at(
|
||||
})
|
||||
}
|
||||
|
||||
/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。
|
||||
pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result<Vec<DirectTurnStreamItem>, String> {
|
||||
let path = turn_stream_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(normalize_stream_items(read_stream_lines(&path)))
|
||||
}
|
||||
|
||||
/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。
|
||||
///
|
||||
/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾
|
||||
|
||||
@@ -420,9 +420,6 @@ pub(crate) struct PlatformArtAssetGenerationOptions {
|
||||
pub(crate) slice_mode: Option<String>,
|
||||
pub(crate) grid_x: Option<u32>,
|
||||
pub(crate) grid_y: Option<u32>,
|
||||
/// 抠图纯色背景(auto/省略已归一为 None;Some 时是规范化后的大写 #RRGGBB)。
|
||||
/// 仅 character 与 art-spritesheet 链路透传给服务端。
|
||||
pub(crate) screen_color: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PlatformArtAssetGenerationOptions {
|
||||
@@ -438,7 +435,6 @@ impl Default for PlatformArtAssetGenerationOptions {
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
screen_color: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2209,8 +2205,7 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
|
||||
/// 动作,必须各自独立成槽,才能在同一项目里同时在途。
|
||||
///
|
||||
/// 升级前遗留账本仍由旧材料函数定位;新请求把显式切分模式纳入身份,避免同一图集
|
||||
/// 请求在网格与连通域之间误复用。`slice_count` 与 `screen_color` 同样改变付费产出,
|
||||
/// 一并进入身份;None 时跳过序列化,未使用这些字段的请求身份与旧版逐字节一致。
|
||||
/// 请求在网格与连通域之间误复用。`slice_count` 继续保持历史兼容语义,不进身份。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StandalonePlatformArtGenerationFingerprintMaterial<'a> {
|
||||
@@ -2225,10 +2220,6 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> {
|
||||
slice_mode: Option<&'a str>,
|
||||
grid_x: Option<u32>,
|
||||
grid_y: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
slice_count: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
screen_color: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`,
|
||||
@@ -2268,8 +2259,6 @@ fn standalone_platform_art_generation_runtime_context(
|
||||
slice_mode: options.slice_mode.as_deref(),
|
||||
grid_x: options.grid_x,
|
||||
grid_y: options.grid_y,
|
||||
slice_count: options.slice_count,
|
||||
screen_color: options.screen_color.as_deref(),
|
||||
})
|
||||
.map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?;
|
||||
let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes));
|
||||
@@ -2844,7 +2833,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
"sliceMode": options.slice_mode,
|
||||
"gridX": options.grid_x,
|
||||
"gridY": options.grid_y,
|
||||
"screenColor": options.screen_color.as_deref().unwrap_or("auto"),
|
||||
"screenColor": "auto",
|
||||
"aspectRatio": options.aspect_ratio,
|
||||
"imageSize": options.image_size,
|
||||
"assetLabel": options.asset_label,
|
||||
@@ -2892,14 +2881,6 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
if options.asset_kind == "image" {
|
||||
object.remove("kind");
|
||||
}
|
||||
if options.asset_kind == "character" {
|
||||
if let Some(screen_color) = options.screen_color.as_deref() {
|
||||
object.insert(
|
||||
"screenColor".to_string(),
|
||||
serde_json::Value::String(screen_color.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
request_body
|
||||
} else {
|
||||
@@ -8448,7 +8429,6 @@ mod canvas_generation_tests {
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
screen_color: None,
|
||||
};
|
||||
let ordinary =
|
||||
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
|
||||
@@ -8515,12 +8495,6 @@ mod canvas_generation_tests {
|
||||
let mut changed = options.clone();
|
||||
changed.replace_existing = false;
|
||||
changed_options.push(changed);
|
||||
let mut changed = options.clone();
|
||||
changed.screen_color = Some("#CFEFFF".to_string());
|
||||
changed_options.push(changed);
|
||||
let mut changed = options.clone();
|
||||
changed.slice_count = Some(8);
|
||||
changed_options.push(changed);
|
||||
for changed in changed_options {
|
||||
let context = standalone_platform_art_generation_runtime_context(
|
||||
"完整生成提示词",
|
||||
@@ -10499,7 +10473,6 @@ mod canvas_generation_tests {
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
screen_color: None,
|
||||
};
|
||||
let prompt = "生成同一套整包美术";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
@@ -11404,7 +11377,6 @@ mod canvas_generation_tests {
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
screen_color: None,
|
||||
};
|
||||
let prompt = "保持同一个生成提示词";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
@@ -11869,7 +11841,6 @@ mod canvas_generation_tests {
|
||||
slice_mode: None,
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
screen_color: None,
|
||||
};
|
||||
let prompt = "恢复已受理视觉规范图";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
@@ -12479,7 +12450,6 @@ mod canvas_generation_tests {
|
||||
slice_mode: Some("connected-components".to_string()),
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
screen_color: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -416,6 +416,10 @@ pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_POLL_MS: u64 = 50;
|
||||
pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS: u64 = 10 * 60 * 1_000;
|
||||
pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR: &str =
|
||||
"agent-runtime-real-e2e-tool-plan-handoff-checkpoint-needs-reconciliation";
|
||||
pub(super) const AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 3;
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR: u32 = 12;
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 16;
|
||||
pub(crate) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT: u32 = 2;
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS: usize = 4;
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS: u32 = 2_000;
|
||||
pub(crate) const AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS: u32 = 2_600;
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(crate) use response_stream::{
|
||||
pub(crate) use run_configuration::{
|
||||
agent_runtime_run_profile_identity_at, bind_game_creator_agent_runtime_run_profile_at,
|
||||
game_creator_agent_runtime_project_revision_path,
|
||||
game_creator_agent_runtime_provider_transient_retry_policy_at,
|
||||
game_creator_agent_runtime_provider_transient_max_retries_at,
|
||||
game_creator_agent_runtime_run_profile_binding_path,
|
||||
read_game_creator_agent_runtime_run_profile_binding,
|
||||
};
|
||||
|
||||
@@ -717,14 +717,14 @@ where
|
||||
Fut: std::future::Future<Output = Result<platform_llm::LlmRunResponse, platform_llm::LlmError>>,
|
||||
H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse,
|
||||
{
|
||||
let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
root,
|
||||
&provider_snapshot.agent_id,
|
||||
&provider_snapshot.run_id,
|
||||
llm.max_retries,
|
||||
)?;
|
||||
let max_retries = retry_policy.max_retries;
|
||||
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
|
||||
let retry_autonomous_upstream_400 =
|
||||
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
|
||||
let identity = game_creator_agent_runtime_provider_retry_identity_for_mode(
|
||||
provider_snapshot,
|
||||
llm,
|
||||
@@ -1365,9 +1365,17 @@ where
|
||||
)?;
|
||||
return Err("Provider 瞬态错误编码损坏".to_string());
|
||||
};
|
||||
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
|
||||
let error_max_retries = effective_max_retries;
|
||||
if attempt >= error_max_retries {
|
||||
let error_max_retries = if error_kind == "upstream-400" {
|
||||
effective_max_retries
|
||||
.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT)
|
||||
} else {
|
||||
effective_max_retries
|
||||
};
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|record| record.max_retries != error_max_retries)
|
||||
|| attempt >= error_max_retries
|
||||
{
|
||||
crate::provider_retry::remove_at(
|
||||
root,
|
||||
&provider_snapshot.agent_id,
|
||||
@@ -1509,14 +1517,14 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
|
||||
operation: &str,
|
||||
request: &LlmRunRequest,
|
||||
) -> Result<Option<platform_llm::LlmRunResponse>, String> {
|
||||
let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
root,
|
||||
&provider_snapshot.agent_id,
|
||||
&provider_snapshot.run_id,
|
||||
llm.max_retries,
|
||||
)?;
|
||||
let max_retries = retry_policy.max_retries;
|
||||
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
|
||||
let retry_autonomous_upstream_400 =
|
||||
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
|
||||
for attempt in 0..=max_retries {
|
||||
let request_slot = if attempt == 0 {
|
||||
provider_snapshot.request_slot.clone()
|
||||
@@ -1577,8 +1585,11 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
|
||||
let Some((error_kind, public_error)) = encoded.split_once('\n') else {
|
||||
return Err("Provider 瞬态错误编码损坏".to_string());
|
||||
};
|
||||
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
|
||||
let error_max_retries = max_retries;
|
||||
let error_max_retries = if error_kind == "upstream-400" {
|
||||
max_retries.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT)
|
||||
} else {
|
||||
max_retries
|
||||
};
|
||||
if attempt >= error_max_retries {
|
||||
return Err(game_creator_agent_runtime_provider_retry_exhausted_error(
|
||||
public_error,
|
||||
|
||||
+8
-16
@@ -395,22 +395,12 @@ pub(crate) fn agent_runtime_run_profile_identity_at(
|
||||
Ok((profile, String::new()))
|
||||
}
|
||||
|
||||
/// 当前持久 run 的 Provider 瞬态重试策略。
|
||||
///
|
||||
/// 重试次数严格使用设置值:运行档位不再把 `maxRetries` 收进固定区间,
|
||||
/// 只决定上游 400 是否算瞬态错误。
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AgentRuntimeProviderTransientRetryPolicy {
|
||||
pub(crate) max_retries: u32,
|
||||
pub(crate) retry_upstream_400: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
configured_max_retries: u32,
|
||||
) -> Result<AgentRuntimeProviderTransientRetryPolicy, String> {
|
||||
) -> Result<u32, String> {
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
|
||||
let stored_identity =
|
||||
read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, run_id)?
|
||||
@@ -426,8 +416,10 @@ pub(crate) fn game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
stored_profile,
|
||||
stored_binding_fingerprint,
|
||||
)?;
|
||||
Ok(AgentRuntimeProviderTransientRetryPolicy {
|
||||
max_retries: configured_max_retries,
|
||||
retry_upstream_400: profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
|
||||
})
|
||||
if profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
|
||||
return Ok(configured_max_retries
|
||||
.max(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR)
|
||||
.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT));
|
||||
}
|
||||
Ok(configured_max_retries.min(AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT))
|
||||
}
|
||||
|
||||
@@ -577,7 +577,6 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
||||
slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()),
|
||||
grid_x,
|
||||
grid_y,
|
||||
screen_color: None,
|
||||
};
|
||||
if let Some(pending) = pending_action {
|
||||
match recover_persisted_visual_generation_options(
|
||||
@@ -629,7 +628,6 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
||||
.or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)),
|
||||
grid_x,
|
||||
grid_y,
|
||||
screen_color: requested_options.screen_color,
|
||||
}
|
||||
};
|
||||
options.replace_existing = replace_existing;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user