合并最新master到M0A-2开发分支
Project CI / Repository checks (pull_request) Successful in 1m21s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Backend tests (pull_request) Successful in 4m10s
Project CI / Native shell tests (pull_request) Failing after 10m35s

同步 origin/master 至 3c457feb4c

保留 M0 固定 owner 验证、本人 smoke、试玩执行者与动态 Canvas 谱系合同

通过 114 项完成合同回归、动态 Canvas 精确回归及格式编码检查
This commit is contained in:
2026-08-11 09:12:10 +00:00
103 changed files with 7551 additions and 1455 deletions
@@ -32,6 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
- Use stable references such as `objectKey`, project resource ID, or asset ID in generation requests. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
## Documentation Navigation
@@ -96,7 +96,7 @@ A minimal `canvasCompletion` is:
}
```
`dialogId` is optional. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed.
`dialogId` is optional. The placeholder supplies canvas placement and completion coordinates; it is not a final media pixel-size constraint. For successful pixel-art snapping, the result layer uses the final logical-grid PNG dimensions even when they differ from the placeholder. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed.
Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
@@ -145,7 +145,7 @@ Carry the current art spec in `generationInputs.artSpec` and reflect important c
The top-level `style` field is not the art spec's visual-style prose. It appends a short server-side clause to the prompt sent to the provider and enables deterministic post-processing:
- Omitted, `null`, empty string, or `"none"`: no clause is appended and no post-processing runs, without warning.
- `"pixelArt"`: append one short pixel-art line to the end of the prompt sent to the provider, and enable pixel-art snapping, for ordinary image generation, `kind: "character"`, and icon spritesheet generation. The line is appended, not substituted — the rest of your prompt is unchanged. For the exact per-kind wording, read the `style` field description in the OpenAPI document; it is the contract, and this guide deliberately does not copy it.
- `"pixelArt"`: append one short pixel-art line to the end of the prompt sent to the provider, and enable pixel-art snapping, for ordinary image generation, `kind: "character"`, and icon spritesheet generation. On successful snapping, each detected grid cell becomes one output pixel and the logical-grid PNG is persisted directly; it is not resized back to `size`, `imageSize`, the provider image, or `canvasCompletion.placeholder`. The line is appended, not substituted — the rest of your prompt is unchanged. For the exact per-kind wording, read the `style` field description in the OpenAPI document; it is the contract, and this guide deliberately does not copy it.
- Unknown strings, or `"pixelArt"` on unsupported kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`: continue without style processing and return `warning.code: "unsupported-image-style"`.
- Non-string JSON values: malformed request, HTTP `400`.
@@ -196,7 +196,7 @@ Do not guess dimensions or pass a temporary signed read URL. See `authentication
The completed `result` may contain stable artifact fields such as:
- `objectKey`, media type, dimensions, or task ID.
- `objectKey`, media type, dimensions, or task ID. For successful `pixelArt`, image `width`/`height`, icon `spritesheetWidth`/`spritesheetHeight`, and nested resource/asset dimensions are the actual final logical-grid PNG dimensions rather than requested, provider, or placeholder dimensions.
- Sound-effect `durationSeconds` is the probed MP3 duration and `loop` is the frozen request boolean; neither is inferred from Prompt text.
- `resource`, `resourceId`, or equivalent canvas reference.
- `asset`, `assetId`, or equivalent library reference.
@@ -209,6 +209,10 @@ It deliberately excludes a complete project/canvas/library snapshot, Data URL, B
Interpret warnings only after the query reaches `status=completed`. The query-level `warning` is display-ready text. Compact `result.warning` and `result.sliceWarning` preserve structured artifact semantics.
### Delivery-size normalization result before pixel-art snapping
`result.warning.code: "dimension-restore-fallback"` records only the delivery-size normalization result established before any subsequent `pixelArt` snapping: the provider image could not be safely normalized, so its dimensions were preserved at that processing boundary. It does not describe or constrain the dimensions after `pixelArt`; if snapping succeeds, use the completed result's actual logical-grid dimensions as authoritative.
### Source-preserved post-processing failure
When `result.warning.code` is `postprocess-failed-source-preserved`:
+6
View File
@@ -26,6 +26,12 @@
- 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”:
1. 不写历史兼容代码。
2. 不写用于维持退役行为的防御性兼容测试。
3. 不写仅说明其曾存在或已删除的墓碑注释。
4. 不写仅记录其已删除的墓碑文档;直接将权威文档更新为当前状态。
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移不适用“四不写”;必要兼容应最小化、白名单化并配套契约或迁移测试,迁移完成后同步删除兼容实现与对应测试。
- UI 面板中不要默认写功能说明、规则描述或开发解释文案;移动端优先,同时保证网页端可正常显示和操作。
- 点击按钮弹出独立面板的设计,不要实现成在当前面板下面追加内容。
@@ -1,4 +1,5 @@
{
"agentMode": "codex_app_server",
"llm": {
"apiKey": "",
"baseUrl": "https://api.openai.com/v1",
+10
View File
@@ -11,6 +11,7 @@
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"lexical": "^0.47.0",
@@ -2208,6 +2209,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
+1
View File
@@ -37,6 +37,7 @@
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"lexical": "^0.47.0",
@@ -651,10 +651,12 @@ async function runConfigWizardRegressionChecks() {
apiKind: 'openai_responses',
},
);
assert.equal(updatedPrimary.agentMode, 'provider');
await writeGameCreatorWizardConfig(overlayState, updatedPrimary);
const reloadedState =
await readGameCreatorWizardConfigState(overlayConfigDir);
assert.equal(reloadedState.effectiveConfig.llm.apiKey, 'fixture-new-key');
assert.equal(reloadedState.effectiveConfig.agentMode, 'provider');
assert.equal(reloadedState.effectiveConfig.llm.model, 'new-model');
assert.equal(
reloadedState.effectiveConfig.llm.baseUrl,
@@ -1160,6 +1162,12 @@ if (defaultAppConfig.llm?.apiKey !== '') {
throw new Error('AI game creator shell default llm.apiKey must stay empty');
}
if (defaultAppConfig.agentMode !== 'codex_app_server') {
throw new Error(
'AI game creator shell default agentMode must be codex_app_server',
);
}
const allowedLlmReasoningEfforts = new Set([
'default',
'low',
@@ -1530,7 +1538,6 @@ for (const snippet of [
'$verified = $targetItem.GetAccessControl()',
'$rules.Count -ne 1',
'[System.Security.AccessControl.FileSystemRights]::FullControl',
"runChildCapture('powershell.exe'",
"'-NoProfile'",
"'-Command'",
'windowsPrivateAclScript',
@@ -1543,6 +1550,11 @@ for (const snippet of [
);
}
}
if (!/runChildCapture\(\s*['"]powershell\.exe['"]/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard guardrail drifted: runChildCapture(powershell.exe)',
);
}
if (/\bGet-Acl\b/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard must not rely on Get-Acl module auto-loading',
@@ -211,6 +211,7 @@ export function buildGameCreatorWizardConfig(existingConfig, llmInput) {
}
return {
...source,
agentMode: 'provider',
llm: {
...previousLlm,
apiKey,
@@ -290,21 +291,25 @@ export async function secureWindowsGameCreatorPathForCurrentUser(
targetPath,
{ isDirectory },
) {
const result = await runChildCapture('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
windowsPrivateAclScript,
], {
env: {
...process.env,
GENARRATIVE_AGC_PRIVATE_PATH: targetPath,
GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY: String(isDirectory),
const result = await runChildCapture(
'powershell.exe',
[
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
windowsPrivateAclScript,
],
{
env: {
...process.env,
GENARRATIVE_AGC_PRIVATE_PATH: targetPath,
GENARRATIVE_AGC_PRIVATE_IS_DIRECTORY: String(isDirectory),
},
},
});
);
if (result.code !== 0 || result.signal) {
const detail = result.stderr.trim() || result.stdout.trim();
throw new Error(
+163 -4
View File
@@ -702,10 +702,39 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -729,7 +758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
@@ -742,7 +771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -889,6 +918,12 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dbus"
version = "0.9.11"
@@ -1018,6 +1053,15 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -1131,6 +1175,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1616,6 +1669,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-opener",
"tempfile",
"tokio",
@@ -1835,6 +1889,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -1955,6 +2028,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1979,6 +2053,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -2015,9 +2090,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2488,6 +2565,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -3734,6 +3817,22 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -3987,8 +4086,12 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
@@ -3998,6 +4101,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -4021,6 +4125,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -4291,7 +4396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -4847,6 +4952,27 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4868,7 +4994,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -5103,6 +5229,30 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-http"
version = "2.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http",
"regex",
"reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"tokio",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -6176,6 +6326,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -41,6 +41,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = "2.5.9"
tauri-plugin-opener = "2"
tempfile = "3"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
@@ -9,6 +9,10 @@
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close",
{
"identifier": "http:default",
"allow": [{ "url": "https://dev.genarrative.world/api/*" }]
},
"opener:default"
]
}
@@ -9,6 +9,8 @@ use std::collections::BTreeSet;
use std::io::{Seek, SeekFrom};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
mod codex_app_server;
mod codex_cli;
mod generation;
mod interaction;
mod prompt;
@@ -18,6 +20,8 @@ mod runtime_driver;
mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
use codex_app_server::*;
use codex_cli::*;
pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
@@ -27,3 +31,7 @@ pub(crate) use runtime_driver::*;
pub(crate) use runtime_protocol::*;
pub(crate) use runtime_state::*;
pub(crate) use runtime_tools::*;
pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> {
shutdown_game_creator_codex_app_servers_impl()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ const AGENT_INTERACTION_RESUME_TOOL: &str = "runtime_resume";
const AGENT_INTERACTION_PROJECT_LOCATION_TOOL: &str = "project_location";
const AGENT_INTERACTION_MAX_OUTPUT_TOKENS: u32 = 1_200;
const AGENT_INTERACTION_PROVIDER_INSTANCE_ID: &str = "agc-interaction";
pub(crate) const AGENT_RUNTIME_STEER_DECISION_TOOL: &str = "runtime_steer_decision";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AgentInteractionToolKind {
@@ -80,6 +81,171 @@ enum AgentInteractionTextEnvelope {
ProjectLocation,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentRuntimeSteerDecisionArguments {
reply: String,
interrupt_current_provider: bool,
reason: String,
}
fn agent_runtime_steer_decision_function_tool() -> platform_llm::LlmFunctionTool {
platform_llm::LlmFunctionTool::new(
AGENT_RUNTIME_STEER_DECISION_TOOL,
"回复用户,并判断是否必须中断当前正在进行的 LLM Provider 请求。",
serde_json::json!({
"type": "object",
"required": ["reply", "interruptCurrentProvider", "reason"],
"additionalProperties": false,
"properties": {
"reply": {
"type": "string",
"minLength": 1,
"maxLength": 2000
},
"interruptCurrentProvider": { "type": "boolean" },
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
}
}),
)
.with_strict(true)
}
fn build_agent_runtime_steer_decision_request(
root: &Path,
state: &AgentRuntimeState,
instruction: &str,
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
let (llm, config_path, context) = build_game_creator_role_agent_context_for_session(
root,
&state.agent_id,
Some(&state.session_id),
)?;
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
let runtime_summary = serde_json::json!({
"status": state.status,
"phase": state.phase,
"currentAction": state.current_action,
"waitingOn": state.waiting_on,
"nextStep": state.next_step,
"plan": state.plan,
"appliedSteerCursor": state.applied_steer_cursor,
});
let system = format!(
"{}\n\n你现在是项目总控的非终态对话判定层。一个制作 Run 正在执行,你必须先自然回答用户,再判断是否需要中断当前正在进行的 LLM Provider 请求。不要因为收到新消息就默认中断:状态询问、解释、鼓励、确认以及不冲突的补充应保持 interruptCurrentProvider=false;只有用户明确停止/改向,或新要求会让当前 LLM 继续生成明显过期方案时才设为 true。宿主工具和已经开始的外部动作不会被此判定强杀,它们会在安全边界完成。你的回复不能宣称整个 Run 已完成,也不能把这次回复当作终态。必须调用且只调用 runtime_steer_decision。",
game_creator_project_supervisor_chat_system_prompt()
);
let user = format!(
"项目上下文如下,只作为背景,不要逐字复述:\n{context}\n\n当前 Runtime 摘要:\n{}\n\n用户在制作过程中发来的消息:\n{}",
serde_json::to_string_pretty(&runtime_summary)
.map_err(|error| format!("序列化 steer Runtime 摘要失败:{error}"))?,
instruction.trim(),
);
let request = LlmRunRequest::single_turn(system, user)
.with_api_kind(api_kind)
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
.with_function_tools(vec![agent_runtime_steer_decision_function_tool()])
.with_tool_choice(platform_llm::LlmToolChoice::Required);
let request = apply_game_creator_llm_reasoning_effort(request, &llm)?;
Ok((llm, config_path, request))
}
fn parse_agent_runtime_steer_decision_response(
response: &platform_llm::LlmRunResponse,
) -> Result<AgentRuntimeSteerDecision, String> {
if response.tool_calls.len() != 1 {
return Err("Supervisor steer decision 必须返回一个 runtime_steer_decision".to_string());
}
let call = &response.tool_calls[0];
if call.name != AGENT_RUNTIME_STEER_DECISION_TOOL {
return Err(format!(
"Supervisor steer decision 返回未知工具:{}",
call.name
));
}
let arguments = serde_json::from_str::<AgentRuntimeSteerDecisionArguments>(&call.arguments)
.map_err(|error| format!("runtime_steer_decision 参数无效:{error}"))?;
let reply = arguments.reply.trim();
let reason = arguments.reason.trim();
if reply.is_empty() || reason.is_empty() {
return Err("runtime_steer_decision reply/reason 不能为空".to_string());
}
Ok(AgentRuntimeSteerDecision {
reply: reply.to_string(),
interrupt_current_provider: arguments.interrupt_current_provider,
reason: reason.to_string(),
})
}
pub(crate) async fn decide_game_creator_agent_runtime_steer_at(
root: &Path,
state: &AgentRuntimeState,
steer_id: &str,
sequence: u64,
instruction: &str,
) -> Result<AgentRuntimeSteerDecision, String> {
if let Some(decision) = read_game_creator_agent_runtime_steer_decision_at(
root,
&state.agent_id,
&state.run_id,
steer_id,
)? {
return Ok(decision);
}
if state.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| state.parent_agent_id.is_some()
|| state.parent_run_id.is_some()
{
return Err("只有根 Project Supervisor 支持运行中非终态回复".to_string());
}
let (llm, config_path, request) =
build_agent_runtime_steer_decision_request(root, state, instruction)?;
let decision_snapshot = {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.steer_decision.snapshot",
)?;
let mut snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
root,
&state.agent_id,
&state.session_id,
&state.run_id,
"steer-decision",
&format!("steer-{sequence}"),
state.applied_steer_cursor,
)?;
// The main Runtime Provider request must keep running while this
// independent LLM turn decides whether it is stale. A distinct node
// identity gives Codex app-server a separate process/thread gate.
snapshot.agent_id = format!("{}-steer-decision", state.agent_id);
snapshot.task_id = snapshot.agent_id.clone();
snapshot
};
let agent_mode =
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
let response = match agent_mode.as_str() {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
request_game_creator_agent_codex_app_server(&decision_snapshot, &llm, request).await
}
GAME_CREATOR_AGENT_MODE_CODEX_CLI => request_game_creator_agent_codex_cli(request).await,
GAME_CREATOR_AGENT_MODE_PROVIDER => {
build_game_creator_agent_runtime_llm_client(&llm, &config_path)?
.run(request)
.await
}
_ => unreachable!("agent mode is normalized"),
}
.map_err(|error| format!("Supervisor steer decision 调用 LLM 失败:{error}"))?;
let decision = parse_agent_runtime_steer_decision_response(&response)?;
persist_game_creator_agent_runtime_steer_decision_and_reply_at(
root, state, steer_id, sequence, decision,
)
}
pub(crate) fn game_creator_agent_uses_interaction_kernel(agent_id: &str) -> bool {
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
return true;
@@ -407,6 +573,40 @@ mod tests {
assert!(error.contains("最多只能选择一个"));
}
#[test]
fn steer_decision_replies_without_interrupting_status_questions() {
let decision = parse_agent_runtime_steer_decision_response(&response(
"",
vec![tool_call(
AGENT_RUNTIME_STEER_DECISION_TOOL,
r#"{"reply":"我正在完成玩法实现,当前任务会继续。","interruptCurrentProvider":false,"reason":"这是状态询问,不会使当前方案过期。"}"#,
)],
))
.expect("parse non-interrupting steer decision");
assert_eq!(
decision,
AgentRuntimeSteerDecision {
reply: "我正在完成玩法实现,当前任务会继续。".to_string(),
interrupt_current_provider: false,
reason: "这是状态询问,不会使当前方案过期。".to_string(),
}
);
}
#[test]
fn steer_decision_can_request_safe_interrupt_for_conflicting_change() {
let decision = parse_agent_runtime_steer_decision_response(&response(
"",
vec![tool_call(
AGENT_RUNTIME_STEER_DECISION_TOOL,
r#"{"reply":"明白,我会停止旧方向并改成回合制。","interruptCurrentProvider":true,"reason":"用户明确改向,旧 Provider 仍在生成过期方案。"}"#,
)],
))
.expect("parse interrupting steer decision");
assert!(decision.interrupt_current_provider);
assert!(decision.reply.contains("改成回合制"));
}
#[test]
fn interaction_registry_derives_unique_strict_function_tools() {
let registry = agent_interaction_tool_registry().expect("interaction registry");
@@ -135,6 +135,11 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
let stream_response = llm.stream;
let request_stream_snapshot = stream_snapshot.clone();
let response_observations = observations;
let agent_mode =
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
let llm_for_request = llm.clone();
let snapshot_for_request = provider_snapshot.clone();
let config_path_for_request = config_path.clone();
let response_result =
request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using(
root,
@@ -143,9 +148,71 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
&config_path,
"后台 Agent 最终回复",
&request,
move |client, attempt_request| {
agent_mode.clone(),
move |attempt_request| {
let stream_snapshot = request_stream_snapshot.clone();
async move {
if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
if stream_response {
let mut publisher = AgentRuntimeResponseStreamPublisher::start(
root,
&stream_snapshot,
response_revision,
);
return match stream_game_creator_agent_codex_app_server(
&snapshot_for_request,
&llm_for_request,
attempt_request,
|delta| {
if !suppress_private_process_output {
publisher.push(delta);
}
},
)
.await
{
Ok(response) => {
match normalize_game_creator_agent_background_final_reply_response(
response,
response_observations,
) {
Ok(response) => {
publisher.handoff();
Ok(response)
}
Err(error) => {
publisher.failed();
Err(error)
}
}
}
Err(error) => {
publisher.failed();
Err(error)
}
};
}
return normalize_game_creator_agent_background_final_reply_response(
request_game_creator_agent_codex_app_server(
&snapshot_for_request,
&llm_for_request,
attempt_request,
)
.await?,
response_observations,
);
}
if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_CLI {
return normalize_game_creator_agent_background_final_reply_response(
request_game_creator_agent_codex_cli(attempt_request).await?,
response_observations,
);
}
let client = build_game_creator_agent_runtime_llm_client(
&llm_for_request,
&config_path_for_request,
)
.map_err(platform_llm::LlmError::InvalidConfig)?;
if stream_response {
let mut publisher = AgentRuntimeResponseStreamPublisher::start(
root,
@@ -1,5 +1,18 @@
use super::*;
fn provider_retry_identity_for_response_stream_test(
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: &LlmRunRequest,
) -> Result<AgentRuntimeProviderRetryIdentity, String> {
game_creator_agent_runtime_provider_retry_identity_for_mode(
snapshot,
llm,
request,
GAME_CREATOR_AGENT_MODE_PROVIDER,
)
}
fn response_stream_fixture(
run_id: &str,
) -> (
@@ -75,10 +88,10 @@ fn persist_tool_plan_handoff_repair_chain(
LlmMessage::user("修复格式"),
]);
let base_identity =
game_creator_agent_runtime_provider_retry_identity(&base_snapshot, llm, &base_request)
provider_retry_identity_for_response_stream_test(&base_snapshot, llm, &base_request)
.expect("build cleanup base identity");
let repair_identity =
game_creator_agent_runtime_provider_retry_identity(&repair_snapshot, llm, &repair_request)
provider_retry_identity_for_response_stream_test(&repair_snapshot, llm, &repair_request)
.expect("build cleanup repair identity");
let base_request_id = game_creator_agent_runtime_provider_request_id(&base_snapshot);
let repair_request_id = game_creator_agent_runtime_provider_request_id(&repair_snapshot);
@@ -972,7 +985,7 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
let mut new_llm = old_llm.clone();
new_llm.model = "new-provider-model".to_string();
let old_identity =
game_creator_agent_runtime_provider_retry_identity(&snapshot, &old_llm, &request)
provider_retry_identity_for_response_stream_test(&snapshot, &old_llm, &request)
.expect("build old handoff identity");
let provider_request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
append_game_creator_agent_runtime_provider_request_lifecycle(
@@ -1008,7 +1021,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
"agentLlm.design-director",
"测试 Provider handoff 身份漂移",
&request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"identity drift must not call Provider".to_string(),
))
@@ -1074,9 +1088,9 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
let mut new_llm = old_llm.clone();
new_llm.model = "new-tool-plan-model".to_string();
let base_identity =
game_creator_agent_runtime_provider_retry_identity(&base_snapshot, &old_llm, &base_request)
provider_retry_identity_for_response_stream_test(&base_snapshot, &old_llm, &base_request)
.expect("build old base identity");
let repair_identity = game_creator_agent_runtime_provider_retry_identity(
let repair_identity = provider_retry_identity_for_response_stream_test(
&repair_snapshot,
&old_llm,
&repair_request,
@@ -1135,7 +1149,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
"agentLlm.project-supervisor",
"测试 tool-plan handoff 身份漂移",
&base_request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"identity drift must not call Provider".to_string(),
))
@@ -1191,7 +1206,7 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
persist_tool_plan_handoff_repair_chain(root, &snapshot, &old_llm);
let request = LlmRunRequest::new(vec![LlmMessage::user("验证通用 retry 身份漂移")]);
let old_identity =
game_creator_agent_runtime_provider_retry_identity(&snapshot, &old_llm, &request)
provider_retry_identity_for_response_stream_test(&snapshot, &old_llm, &request)
.expect("build old generic retry identity");
provider_retry::write_next_at(
root,
@@ -1214,7 +1229,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
"agentLlm.project-supervisor",
"测试通用 retry 身份漂移",
&request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"generic retry drift must not call Provider".to_string(),
))
@@ -1280,7 +1296,7 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
"capacity request {loop_iteration}"
))]);
let identity =
game_creator_agent_runtime_provider_retry_identity(&entry_snapshot, &llm, &request)
provider_retry_identity_for_response_stream_test(&entry_snapshot, &llm, &request)
.expect("build capacity entry identity");
let request_id = game_creator_agent_runtime_provider_request_id(&entry_snapshot);
let response = platform_llm::LlmRunResponse {
@@ -1326,7 +1342,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
"agentLlm.project-supervisor",
"测试 tool-plan 请求前容量门禁",
&request,
move |_client, _request| async move {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
move |_request| async move {
provider_called_for_request.store(true, Ordering::Release);
Err(platform_llm::LlmError::Transport(
"capacity gate must run before Provider".to_string(),
@@ -1392,10 +1409,10 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
retry_backoff_ms: 1,
};
let base_identity =
game_creator_agent_runtime_provider_retry_identity(&base_snapshot, &llm, &base_request)
provider_retry_identity_for_response_stream_test(&base_snapshot, &llm, &base_request)
.expect("build durable control base identity");
let repair_identity =
game_creator_agent_runtime_provider_retry_identity(&repair_snapshot, &llm, &repair_request)
provider_retry_identity_for_response_stream_test(&repair_snapshot, &llm, &repair_request)
.expect("build durable control repair identity");
let base_request_id = game_creator_agent_runtime_provider_request_id(&base_snapshot);
let repair_request_id = game_creator_agent_runtime_provider_request_id(&repair_snapshot);
@@ -1458,7 +1475,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
"agentLlm.project-supervisor",
"测试 durable control 清理 tool-plan handoff",
&base_request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"durable control must not call Provider".to_string(),
))
@@ -1513,7 +1531,7 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
max_retries: 0,
retry_backoff_ms: 1,
};
let identity = game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request)
let identity = provider_retry_identity_for_response_stream_test(&snapshot, &llm, &request)
.expect("build cleanup handoff identity");
let request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
append_game_creator_agent_runtime_provider_request_lifecycle(
@@ -1583,7 +1601,7 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
max_retries: 0,
retry_backoff_ms: 1,
};
let identity = game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request)
let identity = provider_retry_identity_for_response_stream_test(&snapshot, &llm, &request)
.expect("build terminal handoff identity");
let request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
append_game_creator_agent_runtime_provider_request_lifecycle(
@@ -1677,7 +1695,7 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
max_retries: 1,
retry_backoff_ms: 1,
};
let identity = game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request)
let identity = provider_retry_identity_for_response_stream_test(&snapshot, &llm, &request)
.expect("build Provider recovery identity");
let provider_request_id = game_creator_agent_runtime_provider_request_id(&snapshot);
append_game_creator_agent_runtime_provider_request_lifecycle(
@@ -1723,7 +1741,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
"agentLlm.design-director",
"测试 Provider recovery 冲突",
&request,
|_client, _request| async {
GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|_request| async {
Err(platform_llm::LlmError::Transport(
"reconciliation must not call Provider".to_string(),
))
@@ -83,14 +83,20 @@ pub(crate) use run_configuration::{
};
pub(crate) use steering::{
acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait,
append_game_creator_agent_runtime_steer_decision_failure_reply_at,
consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_accepts_steer,
game_creator_agent_runtime_provider_request_count_for_roots,
game_creator_agent_runtime_steer_ledger_path,
interrupt_game_creator_agent_runtime_provider_for_decided_steer_at,
interrupt_game_creator_agent_runtime_provider_request_at,
interrupt_game_creator_agent_runtime_provider_requests_for_roots,
persist_game_creator_agent_runtime_steer_decision_and_reply_at,
read_game_creator_agent_runtime_steer_decision_at,
register_game_creator_agent_runtime_provider_request,
render_game_creator_agent_runtime_steers_for_prompt, steer_game_creator_agent_runtime_task_at,
steer_game_creator_agent_runtime_task_for_profile_at,
validate_game_creator_agent_runtime_steer_notification_at,
unregister_game_creator_agent_runtime_provider_request,
validate_game_creator_agent_runtime_steer_notification_at, AgentRuntimeSteerDecision,
};
pub(crate) use verification::{
game_creator_agent_runtime_verification_gate_path,
@@ -480,9 +480,10 @@ pub(in crate::agent) struct AgentRuntimeSteerLedgerSnapshot {
}
#[derive(Default)]
pub(in crate::agent) struct AgentRuntimeProviderInterrupt {
pub(in crate::agent) interrupted: AtomicBool,
pub(in crate::agent) notify: tokio::sync::Notify,
pub(crate) struct AgentRuntimeProviderInterrupt {
pub(crate) applied_steer_cursor: u64,
pub(crate) interrupted: AtomicBool,
pub(crate) notify: tokio::sync::Notify,
}
pub(in crate::agent) static GAME_CREATOR_AGENT_PROVIDER_INTERRUPTS: OnceLock<
@@ -71,6 +71,15 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_error_with_transient
operation: &str,
retry_autonomous_upstream_400: bool,
) -> String {
if matches!(
error,
platform_llm::LlmError::Transport(message)
if message.starts_with(GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX)
) {
return format!(
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: Codex app-server turn 终态未知"
);
}
let public_error = format!(
"{config_path} {operation}调用 LLM 失败:{}",
game_creator_agent_llm_error_public_summary(error)
@@ -284,7 +293,76 @@ pub(in crate::agent) fn game_creator_agent_runtime_llm_request_fingerprint(
pub(in crate::agent) fn game_creator_agent_runtime_provider_config_fingerprint(
llm: &GameCreatorLlmConfig,
) -> Result<String, String> {
let app_config = load_game_creator_app_config()?;
let agent_mode = normalize_game_creator_agent_mode(&app_config.agent_mode)?;
let codex_cli_version = if matches!(
agent_mode.as_str(),
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI
) {
Some(game_creator_codex_cli_version_identity()?)
} else {
None
};
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
&agent_mode,
codex_cli_version.as_deref(),
llm,
)
}
fn game_creator_agent_runtime_provider_config_fingerprint_for_mode(
agent_mode: &str,
codex_cli_version: Option<&str>,
llm: &GameCreatorLlmConfig,
) -> Result<String, String> {
let agent_mode = normalize_game_creator_agent_mode(agent_mode)?;
let codex_cli = if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_CLI {
Some(serde_json::json!({
"executable": "codex",
"version": codex_cli_version.ok_or_else(|| {
"Codex CLI 模式缺少 CLI 版本身份".to_string()
})?,
"protocol": "genarrative-codex-cli-agent.v1",
"sandbox": "read-only",
"shellTool": false,
"ephemeral": true,
"ignoreUserConfig": true,
"ignoreRules": true,
"approvalPolicy": "never",
}))
} else {
None
};
let codex_app_server = if agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
Some(serde_json::json!({
"executable": "codex",
"version": codex_cli_version.ok_or_else(|| {
"Codex app-server 模式缺少 CLI 版本身份".to_string()
})?,
"protocol": game_creator_codex_app_server_protocol_identity(),
"providerRoute": if llm.api_key.trim().is_empty() {
"codex-login"
} else {
"agc-openai-responses"
},
"sandbox": "read-only",
"networkAccess": false,
"shellTool": false,
"webSearch": false,
"multiAgent": false,
"isolatedOsHome": true,
"mcpServers": false,
"ephemeralThread": true,
"approvalPolicy": "never",
"outputSchema": "per-turn",
}))
} else {
None
};
let stable = serde_json::json!({
"agentMode": agent_mode,
"codexAppServer": codex_app_server,
"codexCli": codex_cli,
"apiKeySha256": format!("{:x}", Sha256::digest(llm.api_key.as_bytes())),
"baseUrl": llm.base_url,
"model": llm.model,
@@ -306,6 +384,30 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity(
llm: &GameCreatorLlmConfig,
request: &LlmRunRequest,
) -> Result<AgentRuntimeProviderRetryIdentity, String> {
let app_config = load_game_creator_app_config()?;
game_creator_agent_runtime_provider_retry_identity_for_mode(
snapshot,
llm,
request,
&app_config.agent_mode,
)
}
pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_mode(
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: &LlmRunRequest,
agent_mode: &str,
) -> Result<AgentRuntimeProviderRetryIdentity, String> {
let agent_mode = normalize_game_creator_agent_mode(agent_mode)?;
let codex_cli_version = if matches!(
agent_mode.as_str(),
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER | GAME_CREATOR_AGENT_MODE_CODEX_CLI
) {
Some(game_creator_codex_cli_version_identity()?)
} else {
None
};
Ok(AgentRuntimeProviderRetryIdentity {
project_id: snapshot.project_id.clone(),
agent_id: snapshot.agent_id.clone(),
@@ -320,7 +422,12 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity(
request_kind: snapshot.request_kind.clone(),
base_request_slot: snapshot.request_slot.clone(),
request_fingerprint: game_creator_agent_runtime_llm_request_fingerprint(request)?,
provider_config_fingerprint: game_creator_agent_runtime_provider_config_fingerprint(llm)?,
provider_config_fingerprint:
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
&agent_mode,
codex_cli_version.as_deref(),
llm,
)?,
web_search_enabled: snapshot.web_search_enabled,
allow_idle_context_compaction: snapshot.allow_idle_context_compaction,
})
@@ -566,11 +673,12 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
config_path: &str,
operation: &str,
request: &LlmRunRequest,
agent_mode: String,
execute: F,
canonicalize_handoff_response: H,
) -> Result<AgentRuntimePersistedProviderRequestOutcome, String>
where
F: FnOnce(LlmClient, LlmRunRequest) -> Fut,
F: FnOnce(LlmRunRequest) -> Fut,
Fut: std::future::Future<Output = Result<platform_llm::LlmRunResponse, platform_llm::LlmError>>,
H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse,
{
@@ -582,8 +690,12 @@ where
)?;
let retry_autonomous_upstream_400 =
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
let identity =
game_creator_agent_runtime_provider_retry_identity(provider_snapshot, llm, request)?;
let identity = game_creator_agent_runtime_provider_retry_identity_for_mode(
provider_snapshot,
llm,
request,
&agent_mode,
)?;
let tool_plan_handoff_missing = if identity.request_kind == "tool-plan" {
match tool_plan_handoff::lookup_at(
root,
@@ -1008,19 +1120,11 @@ where
.map_err(|error| redact_agent_runtime_error(root, &error, 500))?;
}
let attempt_snapshot = provider_snapshot.with_request_slot(request_slot);
let client = match build_game_creator_agent_runtime_llm_client(llm, config_path) {
Ok(client) => client,
Err(error) => {
crate::provider_retry::remove_at(
root,
&provider_snapshot.agent_id,
&provider_snapshot.run_id,
)?;
return Err(error);
}
};
let provider_request = async {
execute(client, request.clone()).await.map_err(|error| {
// 模式分发的 execute future 会同时携带 Provider、Codex CLI 和 Codex app-server 分支。
// 在进入下层通用 control/lifecycle helper 前装箱完整请求,保证 debug 构建的队列恢复
// 仍可安全运行在 Tokio 默认 worker 栈上。
let provider_request = Box::pin(async {
execute(request.clone()).await.map_err(|error| {
game_creator_agent_runtime_provider_error_with_transient_kind(
&error,
config_path,
@@ -1028,7 +1132,7 @@ where
retry_autonomous_upstream_400,
)
})
};
});
let handoff_identity = identity.clone();
let handoff_request_slot = attempt_snapshot.request_slot.clone();
let persist_handoff = provider_handoff::supports_request_kind(&identity.request_kind);
@@ -1293,6 +1397,11 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
operation: &str,
request: &LlmRunRequest,
) -> Result<AgentRuntimePersistedProviderRequestOutcome, String> {
let agent_mode =
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
let llm_for_request = llm.clone();
let snapshot_for_request = provider_snapshot.clone();
let config_path_for_request = config_path.to_string();
request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using(
root,
provider_snapshot,
@@ -1300,7 +1409,31 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
config_path,
operation,
request,
|client, request| async move { client.run(request).await },
agent_mode.clone(),
move |request| async move {
match agent_mode.as_str() {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
request_game_creator_agent_codex_app_server(
&snapshot_for_request,
&llm_for_request,
request,
)
.await
}
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
request_game_creator_agent_codex_cli(request).await
}
GAME_CREATOR_AGENT_MODE_PROVIDER => {
let client = build_game_creator_agent_runtime_llm_client(
&llm_for_request,
&config_path_for_request,
)
.map_err(platform_llm::LlmError::InvalidConfig)?;
client.run(request).await
}
_ => unreachable!("agent mode is normalized"),
}
},
|response| response.clone(),
)
.await
@@ -1329,9 +1462,35 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
format!("{}-transient-{attempt}", provider_snapshot.request_slot)
};
let attempt_snapshot = provider_snapshot.with_request_slot(request_slot.clone());
let client = build_game_creator_agent_runtime_llm_client(llm, config_path)?;
let provider_request = async {
client.run(request.clone()).await.map_err(|error| {
let agent_mode =
normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?;
let client = (agent_mode == GAME_CREATOR_AGENT_MODE_PROVIDER)
.then(|| build_game_creator_agent_runtime_llm_client(llm, config_path))
.transpose()?;
// 不让组合后的 Provider/Codex 分发 future 留在调用方 async frame 中;否则 debug
// 构建在把委派子任务回执送回父 Runtime 时会超过 Tokio 默认 worker 栈。
let provider_request = Box::pin(async {
let response = match agent_mode.as_str() {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
request_game_creator_agent_codex_app_server(
&attempt_snapshot,
llm,
request.clone(),
)
.await
}
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
request_game_creator_agent_codex_cli(request.clone()).await
}
GAME_CREATOR_AGENT_MODE_PROVIDER => {
client
.expect("provider mode constructs an HTTP client")
.run(request.clone())
.await
}
_ => unreachable!("agent mode is normalized"),
};
response.map_err(|error| {
game_creator_agent_runtime_provider_error_with_transient_kind(
&error,
config_path,
@@ -1339,7 +1498,7 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
retry_autonomous_upstream_400,
)
})
};
});
match await_game_creator_agent_runtime_provider_request_with_snapshot(
root,
attempt_snapshot.clone(),
@@ -1471,6 +1630,66 @@ pub(crate) fn append_game_creator_agent_runtime_provider_lifecycle_for_test(
mod tests {
use super::*;
#[test]
fn provider_config_fingerprint_separates_all_agent_modes() {
let llm = GameCreatorLlmConfig::default();
let app_server = game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
Some("codex-cli test-version"),
&llm,
)
.expect("Codex app-server config fingerprint");
let codex = game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_CODEX_CLI,
Some("codex-cli test-version"),
&llm,
)
.expect("Codex CLI config fingerprint");
let provider = game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_PROVIDER,
None,
&llm,
)
.expect("Provider config fingerprint");
assert_ne!(app_server, codex);
assert_ne!(app_server, provider);
assert_ne!(codex, provider);
assert_ne!(
app_server,
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
Some("codex-cli changed-version"),
&llm,
)
.expect("changed Codex app-server config fingerprint")
);
assert_ne!(
codex,
game_creator_agent_runtime_provider_config_fingerprint_for_mode(
GAME_CREATOR_AGENT_MODE_CODEX_CLI,
Some("codex-cli changed-version"),
&llm,
)
.expect("changed Codex CLI config fingerprint")
);
}
#[test]
fn codex_app_server_unknown_terminal_enters_reconciliation_without_retry() {
let error = platform_llm::LlmError::Transport(format!(
"{GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX} eof"
));
let encoded = game_creator_agent_runtime_provider_error_with_transient_kind(
&error,
"agentLlm.project-supervisor",
"规划",
false,
);
assert!(encoded.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX));
assert!(!encoded.starts_with(AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX));
assert!(!encoded.contains("eof"));
}
#[test]
fn provider_retry_error_classification_accepts_durable_failures_and_rejects_client_faults() {
let retryable = vec![
@@ -1075,6 +1075,7 @@ where
root,
&snapshot.agent_id,
&snapshot.run_id,
snapshot.applied_steer_cursor,
)
.map_err(|error| redact_agent_runtime_error(root, &error, 500))?;
before_control_recheck();
@@ -1246,6 +1247,25 @@ where
}
}
unregister_game_creator_agent_runtime_provider_request(&key, &active);
if result
.as_ref()
.is_err_and(|error| error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX))
{
let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.provider_request.unknown_terminal_reconciliation",
);
if control_lock.is_ok() {
let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked(
root,
&snapshot,
&request_id,
);
}
return Err(format!(
"{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · Codex app-server turn 终态未知"
));
}
let status = match &result {
Ok(Some(_)) => "completed",
Ok(None) => "interrupted",
@@ -100,6 +100,8 @@ pub(in crate::agent) fn normalize_agent_runtime_steer_id(steer_id: &str) -> Resu
Ok(steer_id.to_string())
}
pub(in crate::agent) const AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX: &str = "agent-steer-";
pub(in crate::agent) fn agent_runtime_steer_message_id(
project_id: &str,
agent_id: &str,
@@ -112,7 +114,7 @@ pub(in crate::agent) fn agent_runtime_steer_message_id(
format!("{project_id}\n{agent_id}\n{task_id}\n{session_id}\n{run_id}\n{steer_id}");
let fingerprint = format!("{:x}", Sha256::digest(payload.as_bytes()));
format!(
"agent-steer-{}",
"{AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX}{}",
fingerprint.chars().take(32).collect::<String>()
)
}
@@ -429,6 +431,202 @@ pub(in crate::agent) fn ensure_game_creator_agent_runtime_steer_audit(
)
}
const AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE: &str = "agent.runtime.steer_decision";
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct AgentRuntimeSteerDecision {
pub(crate) reply: String,
pub(crate) interrupt_current_provider: bool,
pub(crate) reason: String,
}
fn parse_game_creator_agent_runtime_steer_decision_record(
record: &serde_json::Value,
) -> Result<AgentRuntimeSteerDecision, String> {
let reply = record
.get("reply")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "Supervisor steer decision 缺少 reply".to_string())?;
let reply_sha256 = format!("{:x}", Sha256::digest(reply.as_bytes()));
if record
.get("replySha256")
.and_then(serde_json::Value::as_str)
!= Some(reply_sha256.as_str())
{
return Err("Supervisor steer decision reply 指纹冲突".to_string());
}
let interrupt_current_provider = record
.get("interruptCurrentProvider")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| "Supervisor steer decision 缺少 interruptCurrentProvider".to_string())?;
let reason = record
.get("reason")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
Ok(AgentRuntimeSteerDecision {
reply: reply.to_string(),
interrupt_current_provider,
reason: reason.to_string(),
})
}
pub(crate) fn read_game_creator_agent_runtime_steer_decision_at(
root: &Path,
agent_id: &str,
run_id: &str,
steer_id: &str,
) -> Result<Option<AgentRuntimeSteerDecision>, String> {
let (records, _) = read_agent_db_records_bounded(root, 16 * 1024 * 1024)?;
let mut decision = None;
for record in records.iter().filter(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some(AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE)
&& record.get("agentId").and_then(serde_json::Value::as_str) == Some(agent_id)
&& record.get("runId").and_then(serde_json::Value::as_str) == Some(run_id)
&& record.get("steerId").and_then(serde_json::Value::as_str) == Some(steer_id)
}) {
let parsed = parse_game_creator_agent_runtime_steer_decision_record(record)?;
if decision
.as_ref()
.is_some_and(|existing| existing != &parsed)
{
return Err("Supervisor steer decision 出现冲突终态".to_string());
}
decision = Some(parsed);
}
Ok(decision)
}
pub(crate) fn persist_game_creator_agent_runtime_steer_decision_and_reply_at(
root: &Path,
state: &AgentRuntimeState,
steer_id: &str,
sequence: u64,
decision: AgentRuntimeSteerDecision,
) -> Result<AgentRuntimeSteerDecision, String> {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.steer_decision.persist",
)?;
let snapshot =
read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?;
let entry = snapshot
.entries
.get(steer_id)
.ok_or_else(|| "Supervisor steer decision 未命中持久 steer".to_string())?;
if entry.identity.sequence != sequence
|| entry.identity.session_id != state.session_id
|| entry.identity.source != state.source
{
return Err("Supervisor steer decision 与持久 steer 身份冲突".to_string());
}
let reply = sanitize_agent_runtime_text(decision.reply.trim(), 2_000);
if reply.is_empty() {
return Err("Supervisor steer decision reply 不能为空".to_string());
}
let reason = redact_agent_runtime_project_paths(
root,
&sanitize_agent_runtime_text(decision.reason.trim(), 500),
500,
);
let decision = AgentRuntimeSteerDecision {
reply,
interrupt_current_provider: decision.interrupt_current_provider,
reason,
};
let persisted = if let Some(existing) = read_game_creator_agent_runtime_steer_decision_at(
root,
&state.agent_id,
&state.run_id,
steer_id,
)? {
existing
} else {
append_agent_db_record(
root,
serde_json::json!({
"recordType": AGENT_RUNTIME_STEER_DECISION_RECORD_TYPE,
"agentId": state.agent_id,
"taskId": state.task_id,
"sessionId": state.session_id,
"runId": state.run_id,
"source": state.source,
"steerId": steer_id,
"sequence": sequence,
"messageId": entry.identity.message_id,
"instructionSha256": entry.identity.instruction_sha256,
"reply": decision.reply,
"replySha256": format!("{:x}", Sha256::digest(decision.reply.as_bytes())),
"interruptCurrentProvider": decision.interrupt_current_provider,
"reason": decision.reason,
"decidedAt": unix_timestamp(),
}),
)?;
decision
};
let message_id = entry.identity.message_id.as_deref().unwrap_or_default();
let correlation_id = message_id
.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX)
.ok_or_else(|| "Supervisor steer decision 缺少消息关联身份".to_string())?;
append_game_creator_agent_runtime_public_status_message_for_correlation_at(
root,
correlation_id,
"steer-reply",
&persisted.reply,
)?;
Ok(persisted)
}
pub(crate) fn append_game_creator_agent_runtime_steer_decision_failure_reply_at(
root: &Path,
state: &AgentRuntimeState,
steer_id: &str,
error: &str,
) -> Result<String, String> {
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"runtime.steer_decision.failure",
)?;
let snapshot =
read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?;
let entry = snapshot
.entries
.get(steer_id)
.ok_or_else(|| "Supervisor steer decision 失败记录未命中持久 steer".to_string())?;
let correlation_id = entry
.identity
.message_id
.as_deref()
.and_then(|message_id| message_id.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX))
.ok_or_else(|| "Supervisor steer decision 失败记录缺少消息关联身份".to_string())?;
let reply = "我会继续当前任务。这次没有完成中断判断,你的消息仍会在下一个安全边界进入规划。";
append_game_creator_agent_runtime_public_status_message_for_correlation_at(
root,
correlation_id,
"steer-decision-failed",
reply,
)?;
let safe_error = redact_agent_runtime_error(root, error, 500);
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.steer_decision.failed",
"agentId": state.agent_id,
"taskId": state.task_id,
"sessionId": state.session_id,
"runId": state.run_id,
"source": state.source,
"steerId": steer_id,
"sequence": entry.identity.sequence,
"errorSha256": format!("{:x}", Sha256::digest(safe_error.as_bytes())),
"errorChars": safe_error.chars().count(),
"failedAt": unix_timestamp(),
}),
)?;
Ok(reply.to_string())
}
pub(crate) fn game_creator_agent_runtime_accepts_steer(state: &AgentRuntimeState) -> bool {
!state.agent_id.starts_with("child-")
&& state.source != AGENT_RUNTIME_ISOLATED_CHILD_SOURCE
@@ -658,6 +856,20 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
latest_status = status.to_string();
}
}
if state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& state.parent_agent_id.is_none()
&& state.parent_run_id.is_none()
{
let correlation_id = message_id
.strip_prefix(AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX)
.ok_or_else(|| "运行中追加指令消息缺少公开状态关联身份".to_string())?;
append_game_creator_agent_runtime_public_status_message_for_correlation_at(
root,
correlation_id,
"steer-queued",
"收到。我正在判断这条消息是否需要调整当前任务;现有任务会继续运行。",
)?;
}
let was_waiting_for_provider_retry = state.phase == "waiting-for-provider-retry";
if was_waiting_for_provider_retry {
remove_game_creator_agent_runtime_provider_recovery_at(root, &agent_id, run_id)?;
@@ -682,8 +894,7 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
Some(&format!("steerId={steer_id}")),
)?;
}
let provider_interrupted =
interrupt_game_creator_agent_runtime_provider_request_at(root, &agent_id, run_id)?;
let provider_interrupted = false;
ensure_game_creator_agent_runtime_steer_audit(
root,
&state,
@@ -722,6 +933,9 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at(
sequence: entry.identity.sequence,
status: latest_status,
provider_interrupted,
assistant_reply: None,
interrupt_decision: None,
decision_reason: None,
})
}
@@ -772,6 +986,44 @@ pub(crate) fn interrupt_game_creator_agent_runtime_provider_request_at(
Ok(first)
}
pub(crate) fn interrupt_game_creator_agent_runtime_provider_for_decided_steer_at(
root: &Path,
agent_id: &str,
run_id: &str,
steer_id: &str,
) -> Result<bool, String> {
let decision =
read_game_creator_agent_runtime_steer_decision_at(root, agent_id, run_id, steer_id)?
.ok_or_else(|| "Supervisor steer 尚无 LLM 中断判定".to_string())?;
if !decision.interrupt_current_provider {
return Ok(false);
}
let snapshot = read_game_creator_agent_runtime_steer_ledger(root, agent_id, run_id)?;
let entry = snapshot
.entries
.get(steer_id)
.ok_or_else(|| "LLM 中断判定未命中持久 steer".to_string())?;
let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?;
let active = {
let registry = game_creator_agent_provider_interrupts()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
registry.get(&key).cloned()
};
let Some(active) = active else {
return Ok(false);
};
if active.applied_steer_cursor >= entry.identity.sequence {
// The provider request was created after this steer was already
// applied. Interrupting it would cancel the new plan, not the stale
// request the LLM evaluated.
return Ok(false);
}
let first = !active.interrupted.swap(true, Ordering::AcqRel);
active.notify.notify_one();
Ok(first)
}
pub(crate) fn interrupt_game_creator_agent_runtime_provider_requests_for_roots(
roots: &[PathBuf],
) -> usize {
@@ -833,13 +1085,17 @@ pub(crate) fn validate_game_creator_agent_runtime_steer_notification_at(
Ok(())
}
pub(in crate::agent) fn register_game_creator_agent_runtime_provider_request(
pub(crate) fn register_game_creator_agent_runtime_provider_request(
root: &Path,
agent_id: &str,
run_id: &str,
applied_steer_cursor: u64,
) -> Result<(String, Arc<AgentRuntimeProviderInterrupt>), String> {
let key = agent_runtime_provider_interrupt_key(root, agent_id, run_id)?;
let active = Arc::new(AgentRuntimeProviderInterrupt::default());
let active = Arc::new(AgentRuntimeProviderInterrupt {
applied_steer_cursor,
..AgentRuntimeProviderInterrupt::default()
});
let mut registry = game_creator_agent_provider_interrupts()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
@@ -849,7 +1105,7 @@ pub(in crate::agent) fn register_game_creator_agent_runtime_provider_request(
Ok((key, active))
}
pub(in crate::agent) fn unregister_game_creator_agent_runtime_provider_request(
pub(crate) fn unregister_game_creator_agent_runtime_provider_request(
key: &str,
active: &Arc<AgentRuntimeProviderInterrupt>,
) {
@@ -1216,12 +1472,14 @@ mod shutdown_tests {
&known_root,
"code-prototype",
"known-run",
0,
)
.expect("register known Provider request");
let (other_key, other_request) = register_game_creator_agent_runtime_provider_request(
&other_root,
"code-prototype",
"other-run",
0,
)
.expect("register other Provider request");
@@ -13,6 +13,13 @@ fn game_creator_agent_runtime_public_status_message_id(
) -> String {
let correlation_id =
game_creator_agent_runtime_message_correlation_id(agent_id, session_id, run_id);
game_creator_agent_runtime_public_status_message_id_for_correlation(&correlation_id, status)
}
fn game_creator_agent_runtime_public_status_message_id_for_correlation(
correlation_id: &str,
status: &str,
) -> String {
let status_fingerprint = format!("{:x}", Sha256::digest(status.as_bytes()));
format!(
"{AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX}{correlation_id}-{}",
@@ -62,6 +69,28 @@ pub(crate) fn append_game_creator_agent_runtime_public_status_message_at(
.map(|_| ())
}
pub(crate) fn append_game_creator_agent_runtime_public_status_message_for_correlation_at(
root: &Path,
correlation_id: &str,
status: &str,
content: &str,
) -> Result<(), String> {
let message_id =
game_creator_agent_runtime_public_status_message_id_for_correlation(correlation_id, status);
append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: content.to_string(),
agent_id: None,
},
&message_id,
)
.map(|_| ())
}
pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at(
root: &Path,
state: &AgentRuntimeState,
@@ -1262,7 +1262,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"missingAssetSlots": {
"type": "array",
"maxItems": 2,
"uniqueItems": true,
"items": { "type": "string", "enum": ["art-spec", "core-spritesheet"] }
}
}
@@ -1386,7 +1385,7 @@ mod tests {
let Some(object) = schema.as_object() else {
return;
};
for keyword in ["oneOf", "anyOf", "allOf", "not"] {
for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] {
if object.contains_key(keyword) {
issues.push(format!(
"strict schema contains unsupported {keyword} at {path}"
@@ -289,6 +289,7 @@ pub(crate) fn take_cli_runtime_config_dir(
pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec<String> {
let mut lines = vec![
format!("agent.mode={}", status.agent_mode),
format!("llm.configured={}", status.configured),
format!("llm.apiKeyPresent={}", status.api_key_present),
format!(

Some files were not shown because too many files have changed in this diff Show More