允许Agent创建项目任务
新增 task.create 工具,让后台 Agent 可把拆解结果追加到 manifest 任务图。 接入 task.create 权限策略、工具 prompt、审计记录和共享命令契约。 补充后台 Runtime 测试覆盖任务创建成功路径和确认策略拦截。 同步 Agent Runtime 技术方案和项目决策记录。
This commit is contained in:
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.create:ok · 已创建任务 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.create:waiting-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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user