补齐Agent后台并行任务
新增单Agent后台任务命令,按Agent独立runtime锁并行运行。 开发单聊和项目Agent对话弹窗增加后台运行入口。 后台任务结果写回Agent对话、runtime事件和agent.db审计记录。 补充并行后台任务测试和实施计划决策文档。
This commit is contained in:
@@ -222,6 +222,149 @@ pub(crate) fn read_game_creator_agent_runtime_at(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn start_game_creator_agent_background_task_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
task: &str,
|
||||
run_id: &str,
|
||||
) -> Result<AgentRuntimeResult, String> {
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?.to_string();
|
||||
validate_project_root(root)?;
|
||||
let task = task.trim();
|
||||
if task.is_empty() {
|
||||
return Err("Agent 后台任务不能为空".to_string());
|
||||
}
|
||||
let _runtime_lock = acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?;
|
||||
let state = start_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
&agent_id,
|
||||
task,
|
||||
run_id,
|
||||
"agent-background-task",
|
||||
"后台任务已投递",
|
||||
vec![
|
||||
"记录开发者投递的后台任务".to_string(),
|
||||
"独立读取项目上下文和本 Agent 记忆".to_string(),
|
||||
"调用当前 Agent LLM 路由完成推理".to_string(),
|
||||
"把结果写回 Agent 对话、runtime 状态和事件流".to_string(),
|
||||
],
|
||||
)?;
|
||||
append_local_conversation_message_at(
|
||||
root,
|
||||
Some(&agent_id),
|
||||
LocalConversationMessage {
|
||||
role: "user".to_string(),
|
||||
content: task.to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
)?;
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.background_task",
|
||||
"agentId": state.agent_id,
|
||||
"taskId": state.task_id,
|
||||
"sessionId": state.session_id,
|
||||
"runId": state.run_id,
|
||||
"source": state.source,
|
||||
"status": state.status,
|
||||
"phase": state.phase,
|
||||
"task": state.current_task,
|
||||
}),
|
||||
)?;
|
||||
|
||||
let result = read_game_creator_agent_runtime_at(root, &agent_id)?;
|
||||
let root = root.to_path_buf();
|
||||
let background_agent_id = agent_id.clone();
|
||||
let background_task = task.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _runtime_lock = _runtime_lock;
|
||||
run_game_creator_agent_background_task(root, background_agent_id, background_task, state)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn run_game_creator_agent_background_task(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
task: String,
|
||||
state: AgentRuntimeState,
|
||||
) {
|
||||
let mut runtime = match advance_game_creator_agent_runtime_turn_at(
|
||||
&root,
|
||||
state,
|
||||
"llm",
|
||||
"后台请求 Agent LLM",
|
||||
"后台任务已开始执行,正在让 Agent 独立推理。",
|
||||
) {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
let fallback = default_game_creator_agent_runtime_state(&agent_id, "");
|
||||
let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match chat_with_game_creator_role_agent_at(&root, &agent_id, &task).await {
|
||||
Ok(reply) => {
|
||||
if let Ok(completed_runtime) =
|
||||
finish_game_creator_agent_runtime_turn_at(&root, runtime.clone(), &reply.reply_text)
|
||||
{
|
||||
runtime = completed_runtime;
|
||||
}
|
||||
let _ = append_local_conversation_message_at(
|
||||
&root,
|
||||
Some(&agent_id),
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: reply.reply_text.clone(),
|
||||
agent_id: None,
|
||||
},
|
||||
);
|
||||
let _ = append_agent_db_record(
|
||||
&root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.background_task.completed",
|
||||
"agentId": runtime.agent_id,
|
||||
"taskId": runtime.task_id,
|
||||
"sessionId": runtime.session_id,
|
||||
"runId": runtime.run_id,
|
||||
"source": runtime.source,
|
||||
"responsePreview": runtime.last_response,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
|
||||
let _ = append_local_conversation_message_at(
|
||||
&root,
|
||||
Some(&agent_id),
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("后台任务失败:{error}"),
|
||||
agent_id: None,
|
||||
},
|
||||
);
|
||||
if let Ok(runtime) = failed_runtime {
|
||||
let _ = append_agent_db_record(
|
||||
&root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.background_task.failed",
|
||||
"agentId": runtime.agent_id,
|
||||
"taskId": runtime.task_id,
|
||||
"sessionId": runtime.session_id,
|
||||
"runId": runtime.run_id,
|
||||
"source": runtime.source,
|
||||
"error": runtime.error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_game_creator_agent_runtime_turn_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -526,6 +669,79 @@ fn game_creator_agent_runtime_event_path(root: &Path, agent_id: &str) -> PathBuf
|
||||
.join(format!("{agent_id}.jsonl"))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AgentRuntimeTaskLock {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for AgentRuntimeTaskLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_game_creator_agent_runtime_task_lock(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
) -> Result<AgentRuntimeTaskLock, String> {
|
||||
let path = root
|
||||
.join(".agent")
|
||||
.join("runtime")
|
||||
.join("locks")
|
||||
.join(format!("{agent_id}.lock"));
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
format!(
|
||||
"创建 Agent Runtime 锁目录失败:{}: {error}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"agentId": agent_id,
|
||||
"pid": std::process::id(),
|
||||
"createdAt": unix_timestamp(),
|
||||
});
|
||||
let content = serde_json::to_string_pretty(&payload)
|
||||
.map_err(|error| format!("生成 Agent Runtime 锁失败:{error}"))?;
|
||||
match fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
file.write_all(content.as_bytes()).map_err(|error| {
|
||||
format!("写入 Agent Runtime 锁失败:{}: {error}", path.display())
|
||||
})?;
|
||||
Ok(AgentRuntimeTaskLock { path })
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if read_game_creator_agent_runtime_at(root, agent_id)
|
||||
.map(|result| result.state.status == "running")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(format!("Agent {agent_id} 已有后台任务运行中"));
|
||||
}
|
||||
let _ = fs::remove_file(&path);
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|error| {
|
||||
format!("创建 Agent Runtime 锁失败:{}: {error}", path.display())
|
||||
})?;
|
||||
file.write_all(content.as_bytes()).map_err(|error| {
|
||||
format!("写入 Agent Runtime 锁失败:{}: {error}", path.display())
|
||||
})?;
|
||||
Ok(AgentRuntimeTaskLock { path })
|
||||
}
|
||||
Err(error) => Err(format!(
|
||||
"创建 Agent Runtime 锁失败:{}: {error}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_game_creator_agent_runtime_state(
|
||||
root: &Path,
|
||||
state: &AgentRuntimeState,
|
||||
|
||||
@@ -378,6 +378,20 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn start_game_creator_agent_runtime_task(
|
||||
project_path: String,
|
||||
agent_id: String,
|
||||
task: String,
|
||||
run_id: String,
|
||||
) -> Result<AgentRuntimeResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
enforce_project_permission_policy(root, "agent.run_status")?;
|
||||
start_game_creator_agent_background_task_at(root, agent_id.trim(), task.trim(), run_id.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_game_creator_agent_runtime(
|
||||
project_path: String,
|
||||
|
||||
@@ -1023,6 +1023,7 @@ fn main() {
|
||||
chat_with_game_creator_agent,
|
||||
chat_with_game_creator_role_agent,
|
||||
chat_with_game_creator_role_agent_stream,
|
||||
start_game_creator_agent_runtime_task,
|
||||
read_game_creator_agent_runtime,
|
||||
check_game_creator_llm_config,
|
||||
read_game_creator_app_config,
|
||||
|
||||
@@ -90,9 +90,9 @@ pub(crate) fn append_agent_db_record(
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|error| format!("打开 Agent 本地索引失败:{}: {error}", path.display()))?;
|
||||
serde_json::to_writer(&mut file, &record)
|
||||
let line = serde_json::to_string(&record)
|
||||
.map_err(|error| format!("序列化 Agent 本地索引失败:{error}"))?;
|
||||
file.write_all(b"\n")
|
||||
file.write_all(format!("{line}\n").as_bytes())
|
||||
.map_err(|error| format!("写入 Agent 本地索引失败:{}: {error}", path.display()))
|
||||
}
|
||||
|
||||
|
||||
@@ -1256,6 +1256,138 @@ async fn role_agent_runtime_turn_persists_session_events_and_index() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
|
||||
let barrier = Arc::new((StdMutex::new(0_usize), Condvar::new()));
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let art_base_url = spawn_barrier_mock_llm_server(
|
||||
"美术后台任务完成:先给主角规范图。".to_string(),
|
||||
barrier.clone(),
|
||||
2,
|
||||
sender.clone(),
|
||||
);
|
||||
let design_base_url = spawn_barrier_mock_llm_server(
|
||||
"策划后台任务完成:先收敛核心循环。".to_string(),
|
||||
barrier,
|
||||
2,
|
||||
sender,
|
||||
);
|
||||
let _config_guard = write_test_local_config(format!(
|
||||
r#"{{
|
||||
"agentLlm": {{
|
||||
"art-director": {{
|
||||
"apiKey": "art-key",
|
||||
"baseUrl": {art_base_url:?},
|
||||
"model": "art-background-model",
|
||||
"apiKind": "openai_responses"
|
||||
}},
|
||||
"design-director": {{
|
||||
"apiKey": "design-key",
|
||||
"baseUrl": {design_base_url:?},
|
||||
"model": "design-background-model",
|
||||
"apiKind": "openai_responses"
|
||||
}}
|
||||
}}
|
||||
}}"#
|
||||
));
|
||||
|
||||
let art_started = start_game_creator_agent_background_task_at(
|
||||
&root,
|
||||
"art-director",
|
||||
"后台准备主角规范图",
|
||||
"art-background-run",
|
||||
)
|
||||
.expect("start art background task");
|
||||
let design_started = start_game_creator_agent_background_task_at(
|
||||
&root,
|
||||
"design-director",
|
||||
"后台整理玩法循环",
|
||||
"design-background-run",
|
||||
)
|
||||
.expect("start design background task");
|
||||
|
||||
assert_eq!(art_started.state.status, "running");
|
||||
assert_eq!(art_started.state.source, "agent-background-task");
|
||||
assert_eq!(design_started.state.status, "running");
|
||||
assert_eq!(design_started.state.source, "agent-background-task");
|
||||
|
||||
let first_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("first background llm request");
|
||||
let second_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("second background llm request");
|
||||
let combined_requests = format!("{first_request}\n{second_request}");
|
||||
assert!(combined_requests.contains("后台准备主角规范图"));
|
||||
assert!(combined_requests.contains("后台整理玩法循环"));
|
||||
|
||||
let mut art_runtime = read_game_creator_agent_runtime_at(&root, "art-director")
|
||||
.expect("read art runtime")
|
||||
.state;
|
||||
let mut design_runtime = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read design runtime")
|
||||
.state;
|
||||
for _ in 0..50 {
|
||||
if art_runtime.status == "idle" && design_runtime.status == "idle" {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
art_runtime = read_game_creator_agent_runtime_at(&root, "art-director")
|
||||
.expect("read art runtime")
|
||||
.state;
|
||||
design_runtime = read_game_creator_agent_runtime_at(&root, "design-director")
|
||||
.expect("read design runtime")
|
||||
.state;
|
||||
}
|
||||
|
||||
assert_eq!(art_runtime.status, "idle");
|
||||
assert_eq!(art_runtime.phase, "completed");
|
||||
assert_eq!(
|
||||
art_runtime.last_response.as_deref(),
|
||||
Some("美术后台任务完成:先给主角规范图。")
|
||||
);
|
||||
assert_eq!(design_runtime.status, "idle");
|
||||
assert_eq!(design_runtime.phase, "completed");
|
||||
assert_eq!(
|
||||
design_runtime.last_response.as_deref(),
|
||||
Some("策划后台任务完成:先收敛核心循环。")
|
||||
);
|
||||
|
||||
let art_conversation =
|
||||
read_local_conversation_at(&root, Some("art-director")).expect("art conversation");
|
||||
assert!(art_conversation
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == "user" && message.content == "后台准备主角规范图"));
|
||||
assert!(art_conversation.messages.iter().any(|message| {
|
||||
message.role == "assistant" && message.content.contains("美术后台任务完成")
|
||||
}));
|
||||
let design_conversation =
|
||||
read_local_conversation_at(&root, Some("design-director")).expect("design conversation");
|
||||
assert!(design_conversation
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == "user" && message.content == "后台整理玩法循环"));
|
||||
assert!(design_conversation.messages.iter().any(|message| {
|
||||
message.role == "assistant" && message.content.contains("策划后台任务完成")
|
||||
}));
|
||||
|
||||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||||
assert!(agent_db.contains("\"recordType\":\"agent.runtime.background_task\""));
|
||||
assert!(agent_db.contains("\"recordType\":\"agent.runtime.background_task.completed\""));
|
||||
assert!(agent_db.contains("\"agentId\":\"art-director\""));
|
||||
assert!(agent_db.contains("\"agentId\":\"design-director\""));
|
||||
assert!(!root.join(".agent/runtime/locks/art-director.lock").exists());
|
||||
assert!(!root
|
||||
.join(".agent/runtime/locks/design-director.lock")
|
||||
.exists());
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_loop_uses_per_agent_llm_overrides() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -2314,6 +2314,7 @@ export function WorkspaceLauncher({
|
||||
const [agentChatInput, setAgentChatInput] = useState('');
|
||||
const [agentChatStatus, setAgentChatStatus] = useState('请选择项目和 Agent');
|
||||
const [agentChatBusy, setAgentChatBusy] = useState(false);
|
||||
const [agentChatBackgroundBusy, setAgentChatBackgroundBusy] = useState(false);
|
||||
const [agentChatLlmConfigStatus, setAgentChatLlmConfigStatus] =
|
||||
useState<GameCreatorLlmConfigStatus | null>(null);
|
||||
const [agentChatLlmStatus, setAgentChatLlmStatus] =
|
||||
@@ -3288,6 +3289,68 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgentChatStartBackgroundTask() {
|
||||
const projectPathForChat = validateAgentChatProjectPath();
|
||||
const agent = selectedLauncherAgentChatAgent();
|
||||
const content = agentChatInput.trim();
|
||||
if (!projectPathForChat || !agent || !content || agentChatBackgroundBusy) {
|
||||
return;
|
||||
}
|
||||
const llmWarning = getCurrentAgentChatLlmWarning(agent);
|
||||
if (llmWarning) {
|
||||
setAgentChatStatus(llmWarning);
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setAgentChatStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
const saveVersion = agentChatLoadVersionRef.current + 1;
|
||||
agentChatLoadVersionRef.current = saveVersion;
|
||||
setAgentChatBackgroundBusy(true);
|
||||
setAgentChatInput('');
|
||||
setAgentChatStatus('正在启动 Agent 后台任务');
|
||||
try {
|
||||
const runtime = await invoke<AgentRuntimeResult>(
|
||||
'start_game_creator_agent_runtime_task',
|
||||
{
|
||||
projectPath: projectPathForChat,
|
||||
agentId: agent.id,
|
||||
task: content,
|
||||
runId: createAgentChatRunId('launcher-agent-task'),
|
||||
},
|
||||
);
|
||||
if (agentChatLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentChatRuntime(runtime.state);
|
||||
setAgentChatRuntimeError('');
|
||||
const conversation = await invoke<LocalConversationResult>(
|
||||
'read_local_conversation',
|
||||
{
|
||||
projectPath: projectPathForChat,
|
||||
agentId: agent.id,
|
||||
},
|
||||
);
|
||||
if (agentChatLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentChatMessages(conversation.messages);
|
||||
setAgentChatStatus(`已启动后台任务:${runtime.state.runId}`);
|
||||
} catch (error) {
|
||||
if (agentChatLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentChatInput(content);
|
||||
setAgentChatStatus(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (agentChatLoadVersionRef.current === saveVersion) {
|
||||
setAgentChatBackgroundBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const projectRows = recentWorkspaces.map((workspace) => {
|
||||
const directoryStatus = recentWorkspaceStatuses[workspace];
|
||||
const isPendingStatus = directoryStatus === undefined;
|
||||
@@ -3989,6 +4052,16 @@ export function WorkspaceLauncher({
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
agentChatBackgroundBusy ||
|
||||
currentAgentChatLlmWarning !== null
|
||||
}
|
||||
onClick={() => void handleAgentChatStartBackgroundTask()}
|
||||
>
|
||||
后台运行
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
@@ -11320,6 +11393,8 @@ export function App() {
|
||||
const [agentConversationVisibleCount, setAgentConversationVisibleCount] =
|
||||
useState(CONVERSATION_INITIAL_VISIBLE_COUNT);
|
||||
const [agentConversationSaving, setAgentConversationSaving] = useState(false);
|
||||
const [agentConversationBackgroundBusy, setAgentConversationBackgroundBusy] =
|
||||
useState(false);
|
||||
const [agentMemoryStatus, setAgentMemoryStatus] = useState('未读取');
|
||||
const [agentMemoryContent, setAgentMemoryContent] = useState('');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(
|
||||
@@ -11354,6 +11429,7 @@ export function App() {
|
||||
const latestMessagesRef = useRef<ChatMessage[]>([]);
|
||||
const conversationWriteInFlightRef = useRef(false);
|
||||
const agentConversationSavingRef = useRef(false);
|
||||
const agentConversationBackgroundBusyRef = useRef(false);
|
||||
const agentConversationLoadVersionRef = useRef(0);
|
||||
const agentRunHistoryLoadingMoreRef = useRef(false);
|
||||
const initialProjectOpenedRef = useRef(false);
|
||||
@@ -12438,6 +12514,7 @@ export function App() {
|
||||
function closeAgentConversation() {
|
||||
agentConversationLoadVersionRef.current += 1;
|
||||
agentConversationSavingRef.current = false;
|
||||
agentConversationBackgroundBusyRef.current = false;
|
||||
setSelectedAgent(null);
|
||||
setAgentConversationInput('');
|
||||
setAgentConversationMessages([]);
|
||||
@@ -12445,6 +12522,7 @@ export function App() {
|
||||
setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
|
||||
setAgentConversationStatus('未选择 agent');
|
||||
setAgentConversationSaving(false);
|
||||
setAgentConversationBackgroundBusy(false);
|
||||
setAgentMemoryStatus('未读取');
|
||||
setAgentMemoryContent('');
|
||||
}
|
||||
@@ -12762,6 +12840,100 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function startSelectedAgentBackgroundTask(
|
||||
agent: AgentStatusCard,
|
||||
content: string,
|
||||
skipPolicyConfirm = false,
|
||||
) {
|
||||
if (!agent || !content || agentConversationBackgroundBusyRef.current) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
const nextProjectPath = resolveChatProjectPath(localProject);
|
||||
if (!invoke) {
|
||||
setAgentConversationStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
if (!nextProjectPath) {
|
||||
setAgentConversationStatus('请先初始化本地项目');
|
||||
return;
|
||||
}
|
||||
const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent);
|
||||
if (llmWarning) {
|
||||
setAgentConversationStatus(llmWarning);
|
||||
return;
|
||||
}
|
||||
const saveVersion = agentConversationLoadVersionRef.current;
|
||||
try {
|
||||
if (
|
||||
!skipPolicyConfirm &&
|
||||
(await queueProjectPolicyConfirmationIfNeeded(
|
||||
invoke,
|
||||
'conversation.write',
|
||||
nextProjectPath,
|
||||
`启动 ${agent.title} 后台任务`,
|
||||
'准备启动 Agent 后台任务。',
|
||||
() => void startSelectedAgentBackgroundTask(agent, content, true),
|
||||
))
|
||||
) {
|
||||
setAgentConversationStatus('等待确认');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
setAgentConversationStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
agentConversationBackgroundBusyRef.current = true;
|
||||
setAgentConversationBackgroundBusy(true);
|
||||
setAgentConversationInput('');
|
||||
setAgentConversationStatus('正在启动 Agent 后台任务');
|
||||
try {
|
||||
const runtime = await invoke<AgentRuntimeResult>(
|
||||
'start_game_creator_agent_runtime_task',
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
agentId: agent.id,
|
||||
task: content,
|
||||
runId: createAgentChatRunId('agent-background-task'),
|
||||
},
|
||||
);
|
||||
if (agentConversationLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentConversationRuntime(runtime.state);
|
||||
setAgentConversationRuntimeError('');
|
||||
const conversation = await invoke<LocalConversationResult>(
|
||||
'read_local_conversation',
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
agentId: agent.id,
|
||||
},
|
||||
);
|
||||
if (agentConversationLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentConversationMessages(conversation.messages);
|
||||
setAgentConversationStatus(`已启动后台任务:${runtime.state.runId}`);
|
||||
setCommandLog((current) => [
|
||||
...current,
|
||||
'agent.runtime.background_task',
|
||||
]);
|
||||
} catch (error) {
|
||||
if (agentConversationLoadVersionRef.current !== saveVersion) {
|
||||
return;
|
||||
}
|
||||
setAgentConversationInput(content);
|
||||
setAgentConversationStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
agentConversationBackgroundBusyRef.current = false;
|
||||
setAgentConversationBackgroundBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelectedAgentPrivateMemory(
|
||||
agent: AgentStatusCard,
|
||||
content: string,
|
||||
@@ -12875,6 +13047,15 @@ export function App() {
|
||||
void saveSelectedAgentPrivateMemory(agent, content);
|
||||
}
|
||||
|
||||
function handleAgentBackgroundTaskSubmit() {
|
||||
const agent = selectedAgent;
|
||||
const content = agentConversationInput.trim();
|
||||
if (!agent || !content || agentConversationBackgroundBusyRef.current) {
|
||||
return;
|
||||
}
|
||||
void startSelectedAgentBackgroundTask(agent, content);
|
||||
}
|
||||
|
||||
function showChatHelp() {
|
||||
setCommandLog((current) => [...current, 'help.show']);
|
||||
setMessages((current) => [
|
||||
@@ -19992,6 +20173,16 @@ export function App() {
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
agentConversationBackgroundBusy ||
|
||||
selectedAgentLlmWarning !== null
|
||||
}
|
||||
onClick={handleAgentBackgroundTaskSubmit}
|
||||
>
|
||||
后台运行
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={agentConversationSaving}
|
||||
|
||||
@@ -1176,7 +1176,7 @@ textarea {
|
||||
|
||||
.launcher-agent-chat-composer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
@@ -2042,7 +2042,7 @@ h2 {
|
||||
}
|
||||
|
||||
.agent-conversation-panel .composer {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
}
|
||||
|
||||
.workspace-panel {
|
||||
|
||||
@@ -1284,6 +1284,141 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts a developer agent background task without blocking on chat reply', async () => {
|
||||
const persistedMessages: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
agentId: string | null;
|
||||
}> = [];
|
||||
const runningRuntimeState = {
|
||||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||||
agentId: 'design-director',
|
||||
taskId: 'design-director',
|
||||
sessionId: 'agent-session-design-director',
|
||||
runId: 'launcher-agent-task-test',
|
||||
source: 'agent-background-task',
|
||||
status: 'running',
|
||||
phase: 'planning',
|
||||
currentTask: '后台整理角色规范',
|
||||
currentAction: '后台任务已投递',
|
||||
plan: ['记录开发者投递的后台任务', '独立读取项目上下文'],
|
||||
observations: ['已创建本轮 Agent Runtime run。'],
|
||||
allowedTools: ['conversation.read', 'conversation.write'],
|
||||
lastResponse: null,
|
||||
error: null,
|
||||
updatedAt: 4000,
|
||||
};
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'check_game_creator_llm_config') {
|
||||
return {
|
||||
configured: true,
|
||||
apiKeyPresent: true,
|
||||
baseUrl: 'https://llm.example.test/v1',
|
||||
model: 'gpt-5.5',
|
||||
apiKind: 'openai_chat',
|
||||
stream: true,
|
||||
error: null,
|
||||
agents: [
|
||||
{
|
||||
agentId: 'design-director',
|
||||
label: '拆解创作方向',
|
||||
configured: true,
|
||||
apiKeyPresent: true,
|
||||
baseUrl: 'https://llm.example.test/v1',
|
||||
model: 'gpt-5.5',
|
||||
apiKind: 'openai_chat',
|
||||
stream: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||||
agentId: args?.agentId,
|
||||
messages: persistedMessages.map((message, index) => ({
|
||||
schemaVersion: '1',
|
||||
...message,
|
||||
updatedAt: 3000 + index,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtime') {
|
||||
return {
|
||||
state: runningRuntimeState,
|
||||
sessionPath:
|
||||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||||
eventPath:
|
||||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||||
recentEvents: [],
|
||||
};
|
||||
}
|
||||
if (command === 'start_game_creator_agent_runtime_task') {
|
||||
persistedMessages.push({
|
||||
role: 'user',
|
||||
content: String(args?.task ?? ''),
|
||||
agentId: null,
|
||||
});
|
||||
return {
|
||||
state: {
|
||||
...runningRuntimeState,
|
||||
runId: String(args?.runId ?? runningRuntimeState.runId),
|
||||
},
|
||||
sessionPath:
|
||||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||||
eventPath:
|
||||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||||
recentEvents: [],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAgentChatAt('/?agent-chat');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
|
||||
target: { value: '/tmp/authorized-game' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
|
||||
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
|
||||
target: { value: '后台整理角色规范' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '后台运行' }));
|
||||
|
||||
expect(await screen.findByText('后台整理角色规范')).not.toBeNull();
|
||||
expect(await screen.findByText('running / planning')).not.toBeNull();
|
||||
expect(screen.getByText(/agent-background-task/)).not.toBeNull();
|
||||
expect(screen.getByText(/已启动后台任务:launcher-agent-task-/)).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'start_game_creator_agent_runtime_task',
|
||||
expect.objectContaining({
|
||||
projectPath: '/tmp/authorized-game',
|
||||
agentId: 'design-director',
|
||||
task: '后台整理角色规范',
|
||||
}),
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_role_agent_stream',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_role_agent',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(persistedMessages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: '后台整理角色规范',
|
||||
agentId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows developer agent runtime read failures', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-09 AI 游戏创作 App Runtime V1 增加单 Agent 后台任务
|
||||
|
||||
- 背景:开发用单 Agent 聊天已经能真实调用各 Agent 的 LLM 路由并持久化对话,但 Agent 仍主要表现为同步问答,用户无法明确投递一个任务让某个 Agent 独立运行,也无法同时启动多个 Agent 的工作。
|
||||
- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event、追加用户任务到 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 调用该 Agent 的独立 LLM 路由;完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 同时只允许一个后台任务。该能力仍不是独立 OS 进程、持久队列或离线常驻 worker。
|
||||
- 影响范围:`apps/ai-game-creator-shell` 的 Tauri command、Agent Runtime state/event、开发窗口单 Agent 聊天、项目内 Agent 对话弹窗、`appSurface.test.ts` 和 AI 游戏创作 App 实施计划。
|
||||
- 验证方式:运行 Tauri Rust 后台 Agent 并行测试、壳前端 appSurface 测试、壳 typecheck、编码检查和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-07-08 AI 游戏创作 App v1 使用单窗口首页作为普通用户入口
|
||||
|
||||
- 背景:GameAgent V1 首页需求把登录后的入口定义为单窗口客户端首页,旧“先选项目再打开主窗口”的启动器概念会让普通用户流程割裂,也不符合首页先输入需求再选择目录创建项目的交互。
|
||||
|
||||
@@ -32,6 +32,7 @@ Agent Runtime 负责:
|
||||
- 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/<agentId>.jsonl`,通过 `agentLlm.<agentId>` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。
|
||||
- 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。
|
||||
- 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作;Runtime V1 会为单 Agent 对话和生成 loop 中的角色 brief 写入独立 runtime state / event,先解决“每个 Agent 正在做什么、跑到哪一步、最近一次 task/run 是什么”的可观测性。
|
||||
- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/<agentId>.json`、`.agent/runtime/events/<agentId>.jsonl` 和 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 调用该 Agent 的独立 LLM 路由并把 assistant 回复追加回对话。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 同时只允许一个后台任务。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程、持久队列或离线常驻 worker。
|
||||
- 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。
|
||||
- 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents/<group>/<role>.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。
|
||||
- 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/<agentId>.jsonl`,不把原始对话混进项目黑板或角色私有记忆。
|
||||
@@ -71,6 +72,8 @@ game-project/
|
||||
<agentId>.json
|
||||
events/
|
||||
<agentId>.jsonl
|
||||
locks/
|
||||
<agentId>.lock
|
||||
activity.jsonl
|
||||
output.jsonl
|
||||
context.bundle.json
|
||||
@@ -249,6 +252,7 @@ game-project/
|
||||
- Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。
|
||||
- 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。
|
||||
- v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。
|
||||
- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/<agentId>.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。
|
||||
- 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。
|
||||
- 单窗口首页和项目组页可选择、打开、新建或显示当前输入的项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。
|
||||
- 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。
|
||||
|
||||
Reference in New Issue
Block a user