允许Agent创建项目任务

新增 task.create 工具,让后台 Agent 可把拆解结果追加到 manifest 任务图。

接入 task.create 权限策略、工具 prompt、审计记录和共享命令契约。

补充后台 Runtime 测试覆盖任务创建成功路径和确认策略拦截。

同步 Agent Runtime 技术方案和项目决策记录。
This commit is contained in:
AIGameCreator App
2026-07-10 06:45:02 +08:00
parent 8c9693a907
commit 1ccda076dc
8 changed files with 536 additions and 7 deletions
File diff suppressed because one or more lines are too long
@@ -1662,6 +1662,154 @@ pub(crate) fn update_manifest_task_status_at(
Ok(updated)
}
pub(crate) fn create_manifest_task_at(
root: &Path,
task_id: &str,
title: &str,
group: GameCreationAppAgentGroup,
role: &str,
status: GameCreationAppTaskStatus,
dependencies: Vec<String>,
artifacts: Vec<String>,
acceptance_criteria: Vec<String>,
) -> Result<GameCreationAppTaskState, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
let fallback_id = format!(
"agent-task-{}-{}",
unix_timestamp(),
manifest.tasks.len() + 1
);
let task_id = normalize_manifest_task_id(task_id, &fallback_id)?;
if manifest.tasks.iter().any(|task| task.id == task_id) {
return Err(format!("项目任务已存在:{task_id}"));
}
let title = normalize_manifest_task_text(title, "任务标题", 120)?;
let role = normalize_manifest_task_text(role, "任务角色", 64)?;
let known_task_ids = manifest
.tasks
.iter()
.map(|task| task.id.clone())
.collect::<Vec<_>>();
let dependencies = normalize_manifest_task_id_list(dependencies, "依赖任务", 8)?;
for dependency in &dependencies {
if dependency == &task_id {
return Err("任务不能依赖自己".to_string());
}
if !known_task_ids.iter().any(|known| known == dependency) {
return Err(format!("依赖任务不存在:{dependency}"));
}
}
let artifacts = normalize_manifest_task_text_list(artifacts, "任务产物", 8, 160)?;
let acceptance_criteria =
normalize_manifest_task_text_list(acceptance_criteria, "验收标准", 8, 180)?;
let task = GameCreationAppTaskState {
id: task_id,
title,
group,
role,
status,
dependencies,
artifacts,
acceptance_criteria,
};
manifest.tasks.push(task.clone());
write_manifest(&manifest_path, &manifest)?;
Ok(task)
}
fn normalize_manifest_task_id(value: &str, fallback: &str) -> Result<String, String> {
let value = value.trim();
let source = if value.is_empty() {
fallback.trim()
} else {
value
};
let normalized = source
.to_ascii_lowercase()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'-'
}
})
.collect::<String>();
let normalized = normalized
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if normalized.is_empty() {
return Err("任务 ID 只能包含 ASCII 字母、数字、短横线和下划线".to_string());
}
Ok(normalized.chars().take(96).collect())
}
fn normalize_manifest_task_text(
value: &str,
label: &str,
max_chars: usize,
) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
return Err(format!("{label}不能为空"));
}
if value.chars().any(char::is_control) {
return Err(format!("{label}不能包含控制字符"));
}
Ok(value.chars().take(max_chars).collect())
}
fn normalize_manifest_task_text_list(
values: Vec<String>,
label: &str,
max_items: usize,
max_chars: usize,
) -> Result<Vec<String>, String> {
let mut output = Vec::new();
for value in values {
let value = value.trim();
if value.is_empty() {
continue;
}
if value.chars().any(char::is_control) {
return Err(format!("{label}不能包含控制字符"));
}
let item = value.chars().take(max_chars).collect::<String>();
if !output.iter().any(|existing| existing == &item) {
output.push(item);
}
if output.len() > max_items {
return Err(format!("{label}最多支持 {max_items}"));
}
}
Ok(output)
}
fn normalize_manifest_task_id_list(
values: Vec<String>,
label: &str,
max_items: usize,
) -> Result<Vec<String>, String> {
let mut output = Vec::new();
for value in values {
let value = value.trim();
if value.is_empty() {
continue;
}
let item = normalize_manifest_task_id(value, "")?;
if !output.iter().any(|existing| existing == &item) {
output.push(item);
}
if output.len() > max_items {
return Err(format!("{label}最多支持 {max_items}"));
}
}
Ok(output)
}
pub(crate) fn read_or_create_manifest(
root: &Path,
) -> Result<(PathBuf, GameCreationAppManifest), String> {
@@ -3555,6 +3555,201 @@ async fn background_agent_runtime_task_list_respects_project_policy() {
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_can_create_manifest_task() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow task create");
let manifest_before: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert!(manifest_before["tasks"]
.as_array()
.expect("tasks")
.iter()
.all(|task| task["id"] != "design-extra-brief"));
let (sender, receiver) = mpsc::channel();
let plan_json = serde_json::json!({
"thinkingSummary": "需要拆出补充设定任务",
"plan": ["创建补充设定任务", "回报开发者"],
"actions": [
{
"tool": "task.create",
"reason": "让后续 Agent 有独立任务跟踪补充设定",
"input": {
"taskId": "design-extra-brief",
"title": "补充角色设定卡",
"group": "design",
"role": "Gameplay",
"dependencies": ["design-director"],
"artifacts": ["memory/character-sheet.md"],
"acceptanceCriteria": ["角色外观、性格、服装和世界观明确"],
"status": "pending"
}
}
],
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec![plan_json, "已创建补充角色设定卡任务。".to_string()],
Some(sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
&root,
"design-director",
"后台把角色规范拆成任务",
"design-task-create-run",
)
.expect("start background task");
let plan_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("plan llm request");
assert!(plan_request.contains("task.create"));
let final_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("final reply llm request");
assert!(final_request.contains("task.create"));
assert!(final_request.contains("已创建任务 design-extra-brief"));
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
assert_eq!(runtime.status, "idle");
assert!(runtime
.observations
.iter()
.any(|item| item.contains("task.createok · 已创建任务 design-extra-brief")));
let manifest_after: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
let created = manifest_after["tasks"]
.as_array()
.expect("tasks")
.iter()
.find(|task| task["id"] == "design-extra-brief")
.expect("created task");
assert_eq!(created["title"], "补充角色设定卡");
assert_eq!(created["group"], "design");
assert_eq!(created["dependencies"][0], "design-director");
assert_eq!(created["artifacts"][0], "memory/character-sheet.md");
assert_eq!(
created["acceptanceCriteria"][0],
"角色外观、性格、服装和世界观明确"
);
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"recordType\":\"agent.runtime.task.create\""));
assert!(agent_db.contains("\"agentId\":\"design-director\""));
assert!(agent_db.contains("\"taskId\":\"design-extra-brief\""));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_task_create_respects_project_policy() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["task.create".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
let (sender, receiver) = mpsc::channel();
let plan_json = serde_json::json!({
"thinkingSummary": "尝试创建任务",
"plan": ["创建补充设定任务"],
"actions": [
{
"tool": "task.create",
"reason": "测试策略拦截任务创建",
"input": {
"taskId": "design-blocked-task",
"title": "不应创建的任务",
"group": "design",
"dependencies": ["design-director"]
}
}
],
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], Some(sender));
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
&root,
"design-director",
"后台尝试创建任务",
"design-task-create-policy-run",
)
.expect("start background task");
receiver
.recv_timeout(Duration::from_secs(2))
.expect("plan llm request");
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
let runtime = wait_for_agent_runtime_confirmation(&root, "design-director");
assert_eq!(runtime.status, "waiting-for-confirmation");
assert_eq!(runtime.task_queue.waiting_for_confirmation, 1);
assert!(runtime.observations.iter().any(|item| item.contains(
"task.createwaiting-for-confirmation · 项目权限策略要求用户确认:task.create"
)));
assert!(runtime
.recent_tool_calls
.iter()
.any(|item| item.tool == "task.create" && item.status == "waiting-for-confirmation"));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert!(manifest["tasks"]
.as_array()
.expect("tasks")
.iter()
.all(|task| task["id"] != "design-blocked-task"));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"recordType\":\"agent.runtime.tool_confirmation_required\""));
assert!(!agent_db.contains("\"recordType\":\"agent.runtime.task.create\""));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_can_update_manifest_task_status() {
let root = unique_project_path();
+3 -2
View File
@@ -11079,6 +11079,7 @@ function isProjectPolicyConfirmableCommandId(value: string) {
'asset.register',
'asset.list',
'task.list',
'task.create',
'task.update',
'agent.run_status',
'agent.kill',
@@ -15308,7 +15309,7 @@ export function App() {
...current,
{
role: 'assistant',
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
},
]);
return;
@@ -15371,7 +15372,7 @@ export function App() {
...current,
{
role: 'assistant',
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
},
]);
return;
@@ -4067,6 +4067,7 @@
- 2026-07-10 调整:Agent Runtime 的 `memory.write scope=agent` 只能写当前 Agent 自己的私有记忆。若 action 指定其他 `agentId / targetAgentId`Runtime 返回 `blocked` observation,不写目标 Agent 私有记忆、不写 `agent.runtime.memory.write` 审计;跨 Agent 共享稳定结论必须走 `blackboard.write`,给单个 Agent 留上下文必须走 `agent.message`
- 2026-07-10 调整:Agent Runtime 和本地对话使用 append-only JSONL 作为事实源时,进程内必须按目标文件路径串行追加整行。`.agent/agent.db``.agent/conversations/**/*.jsonl``.agent/runtime/events/*.jsonl``.agent/runtime/tasks/*.jsonl``.agent/activity.jsonl``.agent/output.jsonl` 统一走共享追加 helper,避免多个后台 Agent 并行完成时 JSON record 与换行交错。
- 2026-07-10 调整:Agent Runtime 待确认工具动作支持确认后继续。开发者确认 `waiting-for-confirmation` run 时,Runtime 为新 run 写入一次性 `.agent/runtime/confirmations/<agentId>/<runId>/<commandId>.json` 票据并重新入队;工具权限 gate 在 deny 之后、confirm 阶段消费该票据,只放行对应 Agent/run/command 一次。原 waiting run 追加 `completed/confirmed` 任务记录,避免队列长期显示等待确认;审计记录写 `agent.runtime.tool_confirmation.approved`
- 2026-07-10 调整:Agent Runtime 工具箱新增 `task.create`,用于让 Agent 把目标拆成新的 manifest 任务,而不只能更新 seed task。该工具默认 `confirm` 权限,写入前要求 taskId 唯一、依赖指向已有任务、列表长度受限,并写 `agent.runtime.task.create` 审计;策略要求确认或拒绝时不修改 `.agent/manifest.json`
- 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、reason、summary、detail 和 updatedAt。状态面板展示最近动作时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥或任意未过滤输入。
- 2026-07-10 调整:Agent Runtime state 新增 `currentGoal``waitingOn``currentGoal` 固定表达本轮任务目标,`waitingOn` 表达当前等待 LLM、工具观察、开发者输入或失败处理;后台任务生命周期、`agent.run_status` observation、下一轮 planning prompt、开发单 Agent 对话页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都必须展示同一份目标 / 等待状态。
- 2026-07-10 调整:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,字段只做运行观测,不改变 loop 上限或权限 gate。
File diff suppressed because one or more lines are too long
@@ -25,6 +25,7 @@ export const GAME_CREATION_APP_COMMANDS = [
{ id: 'project.policy_read', permission: 'auto' },
{ id: 'project.policy_write', permission: 'confirm' },
{ id: 'task.list', permission: 'auto' },
{ id: 'task.create', permission: 'confirm' },
{ id: 'task.update', permission: 'confirm' },
{ id: 'agent.trace_read', permission: 'auto' },
{ id: 'agent.run_status', permission: 'auto' },
@@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor {
pub permission: GameCreationAppPermission,
}
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 46] = [
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 47] = [
command("help.show", GameCreationAppPermission::Auto),
command("project.create", GameCreationAppPermission::Confirm),
command("project.status", GameCreationAppPermission::Auto),
@@ -34,6 +34,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 46] = [
command("project.policy_read", GameCreationAppPermission::Auto),
command("project.policy_write", GameCreationAppPermission::Confirm),
command("task.list", GameCreationAppPermission::Auto),
command("task.create", GameCreationAppPermission::Confirm),
command("task.update", GameCreationAppPermission::Confirm),
command("agent.trace_read", GameCreationAppPermission::Auto),
command("agent.run_status", GameCreationAppPermission::Auto),
@@ -669,6 +670,12 @@ mod tests {
.expect("command should exist");
assert_eq!(task_list.permission, GameCreationAppPermission::Auto);
let task_create = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "task.create")
.expect("command should exist");
assert_eq!(task_create.permission, GameCreationAppPermission::Confirm);
let trace_read = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "agent.trace_read")