接入 AGC 独立 Agent 试验模式

新增 agent_runtime 配置并通过 stdio App Server 启动独立 Agent

接通后台、交互和 DirectProject 文本回合,保留旧 Codex 回退

对未接入的 function tool 明确失败,避免静默丢失工具调用

补充 AGC 配置回归与实施边界文档
This commit is contained in:
2026-09-10 12:45:54 +08:00
parent c709b3d9b2
commit bd85694ee9
14 changed files with 330 additions and 13 deletions
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
mod codex_app_server;
mod codex_cli;
mod codex_provider_proxy;
mod agent_runtime_client;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_runtime;
@@ -41,6 +42,7 @@ pub(crate) use direct_codex_audit::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tools_mcp::*;
pub(crate) use agent_runtime_client::request_game_creator_agent_runtime;
pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
@@ -0,0 +1,241 @@
//! AGC 到独立 Agent Runtime 的最小外部客户端。
//!
//! 该模块只替换“推理回合”的进程连接:项目工具、AGC Runtime 和完成门仍由
//! AGC 掌控。协议是 agent 自有 JSON-RPC JSONL,不是 Codex App Server。
use super::*;
use serde_json::json;
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::time::Duration;
const AGENT_RUNTIME_PROGRAM_ENV: &str = "GENARRATIVE_AGENT_RUNTIME_PROGRAM";
const AGENT_RUNTIME_MAX_LINE_BYTES: usize = 4 * 1024 * 1024;
const AGENT_RUNTIME_TIMEOUT: Duration = Duration::from_secs(120);
struct ChildGuard(Option<std::process::Child>);
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(mut child) = self.0.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
pub(crate) async fn request_game_creator_agent_runtime(
llm: &GameCreatorLlmConfig,
request: platform_llm::LlmRunRequest,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
if !request.function_tools.is_empty() {
return Err(platform_llm::LlmError::InvalidRequest(
"agent_runtime 当前仅支持无工具文本回合;工具协议尚未接入".into(),
));
}
let llm = llm.clone();
tokio::task::spawn_blocking(move || request_game_creator_agent_runtime_blocking(&llm, request))
.await
.map_err(|_| platform_llm::LlmError::Transport("独立 Agent worker 线程异常退出".into()))?
}
fn request_game_creator_agent_runtime_blocking(
llm: &GameCreatorLlmConfig,
request: platform_llm::LlmRunRequest,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let program = std::env::var_os(AGENT_RUNTIME_PROGRAM_ENV).unwrap_or_else(|| "agent".into());
let temp = tempfile::Builder::new()
.prefix("genarrative-agent-runtime-")
.tempdir()
.map_err(|error| {
platform_llm::LlmError::Transport(format!("创建 Agent 临时目录失败:{error}"))
})?;
let db = temp.path().join("agent.db");
let config = temp.path().join("agent.toml");
std::fs::write(&config, "provider = \"openai\"\n").map_err(|error| {
platform_llm::LlmError::Transport(format!("创建 Agent 配置失败:{error}"))
})?;
let home = temp.path().join("home");
std::fs::create_dir_all(&home).map_err(|error| {
platform_llm::LlmError::Transport(format!("创建 Agent HOME 失败:{error}"))
})?;
let mut child = ChildGuard(Some(
Command::new(program)
.args(["app-server", "--stdio"])
.current_dir(temp.path())
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.env("HOME", &home)
.env("TMPDIR", temp.path())
.env("AGENT_CONFIG", &config)
.env("AGENT_DB", &db)
.env("AGENT_PROVIDER", "openai")
.env("AGENT_MODEL", &llm.model)
.env("OPENAI_BASE_URL", &llm.base_url)
.env("OPENAI_API_KEY", &llm.api_key)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|error| {
platform_llm::LlmError::Transport(format!("启动独立 Agent 失败:{error}"))
})?,
));
let mut stdin = child
.0
.as_mut()
.and_then(|child| child.stdin.take())
.ok_or_else(|| platform_llm::LlmError::Transport("独立 Agent stdin 不可用".into()))?;
let stdout = child
.0
.as_mut()
.and_then(|child| child.stdout.take())
.ok_or_else(|| platform_llm::LlmError::Transport("独立 Agent stdout 不可用".into()))?;
let mut reader = BufReader::new(stdout);
write_rpc(
&mut stdin,
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}),
)?;
read_response(&mut reader, 1)?;
let task = request
.messages
.iter()
.map(|message| message.content.as_str())
.collect::<Vec<_>>()
.join("\n\n");
if task.trim().is_empty() {
return Err(platform_llm::LlmError::InvalidRequest(
"独立 Agent 请求缺少文本消息".into(),
));
}
write_rpc(
&mut stdin,
json!({"jsonrpc":"2.0","id":2,"method":"run/start","params":{"task":task,"stream":false}}),
)?;
let accepted = read_response(&mut reader, 2)?;
let run_id = accepted
.get("result")
.and_then(|value| value.get("runId"))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| platform_llm::LlmError::Deserialize("独立 Agent 未返回 runId".into()))?
.to_owned();
let (line_sender, line_receiver) = std::sync::mpsc::sync_channel(8);
std::thread::spawn(move || {
while let Ok(line) = read_line_bounded(&mut reader) {
if line_sender.send(line).is_err() {
break;
}
}
});
let deadline = std::time::Instant::now() + AGENT_RUNTIME_TIMEOUT;
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Err(platform_llm::LlmError::Timeout { attempts: 1 });
}
let line = line_receiver
.recv_timeout(remaining)
.map_err(|error| match error {
std::sync::mpsc::RecvTimeoutError::Timeout => {
platform_llm::LlmError::Timeout { attempts: 1 }
}
std::sync::mpsc::RecvTimeoutError::Disconnected => {
platform_llm::LlmError::Transport("独立 Agent 连接提前关闭".into())
}
})?;
let value: serde_json::Value = serde_json::from_slice(&line)
.map_err(|_| platform_llm::LlmError::Deserialize("独立 Agent 返回无效 JSON".into()))?;
if value.get("method").and_then(serde_json::Value::as_str) == Some("run/completed")
&& value
.get("params")
.and_then(|params| params.get("runId"))
.and_then(serde_json::Value::as_str)
== Some(run_id.as_str())
{
let output = value
.get("params")
.and_then(|params| params.get("result"))
.and_then(|result| result.get("output"))
.ok_or_else(|| {
platform_llm::LlmError::Deserialize("独立 Agent 完成结果缺少 output".into())
})?;
let text = output
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned();
let _ = write_rpc(
&mut stdin,
json!({"jsonrpc":"2.0","id":3,"method":"shutdown","params":{}}),
);
if let Some(mut child) = child.0.take() {
let _ = child.wait();
}
return Ok(platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text,
finish_reason: Some("completed".into()),
response_id: Some(run_id),
usage: None,
tool_calls: Vec::new(),
});
}
if value.get("method").and_then(serde_json::Value::as_str) == Some("run/failed") {
return Err(platform_llm::LlmError::Transport(
"独立 Agent 执行失败".into(),
));
}
}
}
fn write_rpc(
stdin: &mut impl Write,
value: serde_json::Value,
) -> Result<(), platform_llm::LlmError> {
serde_json::to_writer(&mut *stdin, &value)
.map_err(|_| platform_llm::LlmError::Transport("写入独立 Agent 请求失败".into()))?;
stdin
.write_all(b"\n")
.and_then(|_| stdin.flush())
.map_err(|_| platform_llm::LlmError::Transport("刷新独立 Agent 请求失败".into()))
}
fn read_line_bounded(reader: &mut impl BufRead) -> Result<Vec<u8>, platform_llm::LlmError> {
let mut line = Vec::new();
reader
.read_until(b'\n', &mut line)
.map_err(|_| platform_llm::LlmError::Transport("读取独立 Agent 响应失败".into()))?;
if line.is_empty() {
return Err(platform_llm::LlmError::Transport(
"独立 Agent 连接提前关闭".into(),
));
}
if line.len() > AGENT_RUNTIME_MAX_LINE_BYTES {
return Err(platform_llm::LlmError::Deserialize(
"独立 Agent 响应超过大小限制".into(),
));
}
Ok(line)
}
fn read_response(
reader: &mut impl BufRead,
id: u64,
) -> Result<serde_json::Value, platform_llm::LlmError> {
let line = read_line_bounded(reader)?;
let value: serde_json::Value = serde_json::from_slice(&line)
.map_err(|_| platform_llm::LlmError::Deserialize("独立 Agent 返回无效 JSON".into()))?;
if value.get("id").and_then(serde_json::Value::as_u64) != Some(id) {
return Err(platform_llm::LlmError::Transport(
"独立 Agent 响应 ID 不匹配".into(),
));
}
if value.get("error").is_some() {
return Err(platform_llm::LlmError::Upstream {
status_code: 500,
message: "独立 Agent 请求失败".into(),
});
}
Ok(value)
}
@@ -2953,6 +2953,16 @@ pub(crate) async fn direct_game_creator_home_codex_chat(
user_prompt: String,
) -> Result<String, String> {
let config = load_game_creator_app_config()?;
if config.agent_mode == GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME {
let request = platform_llm::LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(parse_game_creator_llm_api_kind(&config.llm.api_kind)?)
.with_model(config.llm.model.clone())
.with_request_timeout_ms(config.llm.request_timeout_ms);
return request_game_creator_agent_runtime(&config.llm, request)
.await
.map(|response| response.text)
.map_err(|error| error.to_string());
}
game_creator_codex_app_server_validate_llm_config(&config.llm)
.map_err(|error| error.to_string())?;
let snapshot = AgentRuntimeProviderRequestSnapshot {
@@ -3832,7 +3832,25 @@ async fn run_direct_game_creator_turn_inner(
.map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
let reply = if let Some(emitter) = turn_emitter {
let config = load_game_creator_app_config().map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
let reply = if config.agent_mode == GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME {
let request = platform_llm::LlmRunRequest::single_turn(system_prompt.clone(), prompt.to_string())
.with_api_kind(parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?)
.with_model(config.llm.model.clone())
.with_request_timeout_ms(config.llm.request_timeout_ms);
let reply = request_game_creator_agent_runtime(&config.llm, request)
.await
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error.to_string()))?
.text;
if let Some(emitter) = turn_emitter {
emitter.emit("finalizing", Some("response-finalization"), Some(reply.clone()));
}
Ok(reply)
} else if let Some(emitter) = turn_emitter {
let emitter = emitter.clone();
let mut has_streamed = false;
let mut latest_accumulated_text = None;
@@ -232,6 +232,7 @@ pub(crate) async fn decide_game_creator_agent_runtime_steer_at(
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_AGENT_RUNTIME => request_game_creator_agent_runtime(&llm, request).await,
GAME_CREATOR_AGENT_MODE_PROVIDER => {
build_game_creator_agent_runtime_llm_client(&llm, &config_path)?
.run(request)
@@ -259,6 +259,12 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
response_observations,
);
}
if agent_mode == GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME {
return normalize_game_creator_agent_background_final_reply_response(
request_game_creator_agent_runtime(&llm_for_request, attempt_request).await?,
response_observations,
);
}
let client = build_game_creator_agent_runtime_llm_client(
&llm_for_request,
&config_path_for_request,
@@ -1706,6 +1706,9 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_persis
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
request_game_creator_agent_codex_cli(request).await
}
GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME => {
request_game_creator_agent_runtime(&llm_for_request, request).await
}
GAME_CREATOR_AGENT_MODE_PROVIDER => {
let client = build_game_creator_agent_runtime_llm_client(
&llm_for_request,
@@ -1782,6 +1785,9 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
GAME_CREATOR_AGENT_MODE_CODEX_CLI => {
request_game_creator_agent_codex_cli(request.clone()).await
}
GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME => {
request_game_creator_agent_runtime(llm, request.clone()).await
}
GAME_CREATOR_AGENT_MODE_PROVIDER => {
let client = client.expect("provider mode constructs an HTTP client");
request_game_creator_agent_runtime_provider_llm(&client, llm, request.clone())
@@ -317,6 +317,9 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
};
}
};
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME {
return check_game_creator_agent_runtime_config(&app_config);
}
if app_config.agent_mode != GAME_CREATOR_AGENT_MODE_PROVIDER {
return check_game_creator_codex_config(&app_config);
}
@@ -376,6 +379,16 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
status
}
fn check_game_creator_agent_runtime_config(
app_config: &GameCreatorAppConfig,
) -> GameCreatorLlmConfigStatus {
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
status.agent_mode = GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME.to_string();
// The standalone Agent process receives the already configured endpoint/key
// through its private child environment; no Codex binary is required.
status
}
fn check_game_creator_codex_config(
app_config: &GameCreatorAppConfig,
) -> GameCreatorLlmConfigStatus {
@@ -3750,9 +3763,10 @@ pub(crate) fn normalize_game_creator_agent_mode(value: &str) -> Result<String, S
Ok(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string())
}
GAME_CREATOR_AGENT_MODE_CODEX_CLI => Ok(GAME_CREATOR_AGENT_MODE_CODEX_CLI.to_string()),
GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME => Ok(GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME.to_string()),
GAME_CREATOR_AGENT_MODE_PROVIDER => Ok(GAME_CREATOR_AGENT_MODE_PROVIDER.to_string()),
value => Err(format!(
"配置项 agentMode 无效:{value},请使用 codex_app_server、codex_cli 或 provider"
"配置项 agentMode 无效:{value},请使用 agent_runtime、codex_app_server、codex_cli 或 provider"
)),
}
}
@@ -1290,6 +1290,7 @@ const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.jso
const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server";
const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
const GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME: &str = "agent_runtime";
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1";
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-5.6-sol";
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
@@ -160,6 +160,11 @@ fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() {
normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_CLI).expect("cli mode"),
GAME_CREATOR_AGENT_MODE_CODEX_CLI
);
assert_eq!(
normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME)
.expect("standalone runtime mode"),
GAME_CREATOR_AGENT_MODE_AGENT_RUNTIME
);
assert!(normalize_game_creator_agent_mode("unknown")
.expect_err("unknown mode")
.contains("agentMode"));
@@ -656,6 +656,7 @@ export type GameCreatorLlmApiKind =
| 'openai_chat'
| 'anthropic';
export type GameCreatorAgentMode =
| 'agent_runtime'
| 'codex_app_server'
| 'codex_cli'
| 'provider';
@@ -547,7 +547,7 @@ export function RuntimeConfigDialog({
const config = normalizeRuntimeConfigDraft(
{
...runtimeConfigDraft,
agentMode: 'codex_app_server',
agentMode: runtimeConfigDraft.agentMode,
editorApi: allowAdvancedExternalEditorConfig
? runtimeConfigDraft.editorApi
: defaultRuntimeConfigDraft.editorApi,
@@ -9018,6 +9018,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 验证:CLI App Server 黑盒子进程测试 4/4、Host 80/80 与实时流 2/2 通过;workspace
lock/check、Clippy、fmt、编码和 diff 门禁通过。
## 2026-09-10 AGC 接入独立 Agent 试验模式
- 决策:AGC 新增可选 `agent_runtime` 模式,通过 `GENARRATIVE_AGENT_RUNTIME_PROGRAM`
启动独立 `agent app-server --stdio`;不把独立 workspace 作为 AGC Rust 依赖,也不把
Codex App Server 作为中间协议。旧 `codex_app_server`/`codex_cli` 模式保留为回退路径。
- 当前范围:已接通后台 Provider、交互回合和 DirectProject/首页文本回合;消息与模型配置
经 JSONL 传给独立 Agent,完成结果映射回现有 LLM 响应。独立 Agent 自己拥有 Kernel、
Engine、Provider 和 RuntimeAGC 暂不接管其工具/审批/持久化。
- 未完成:AGC 工具动作的跨进程协议、流式事件回传、取消/审批映射和默认模式切换;本阶段
不修改 Codex 版本或删除现有接线。
## 2026-09-09 AGC 改用独立 Agent 内核的替换边界
- 决策:后续目标是用独立 `rust/` workspace 的 Kernel/Engine 替换 AGC 的 Codex 执行循环,
@@ -5,19 +5,20 @@
让 AGC 的 Codex 模式改由独立 `rust/` workspace 的 Agent Kernel/Engine 执行,
同时保留 AGC 宿主对项目、工具权限、审批、Runtime 持久化、预览和完成门的控制。
## 第一阶段:兼容桥(当前
## 第一阶段:独立进程试验(已完成
- 在 AGC 与独立 workspace 之间建立明确的 `AgentTurnAdapter` 边界,不让两套
`agent-runtime-core` 类型直接互相泄漏
- 输入:AGC 的消息历史、模型配置、工具定义、当前 Agent 身份和取消信号。
- 输出:文本增量、结构化 tool call、工具结果、终态错误和可审计事件
- AGC 继续执行工具动作并写入自己的 Runtime;新 Engine 不创建第二套项目状态机。
- 同步 Engine 与 AGC Tokio 的连接通过独立受控线程/有界 channel,禁止在同一 Tokio
worker 内直接 `block_on`
- `agent-cli app-server --stdio` 已提供独立 JSON-RPC JSONL 服务;AGC 通过
`agent_runtime_client.rs` 以外部子进程连接,不共享 Rust 类型、SQLite 或 Codex
- `agent_runtime` 模式已接入后台 Provider 回合、交互回合和 DirectProject/首页直聊入口;
文本消息会转换为新 Agent 的一次 `run/start`,结果再映射回现有 `LlmRunResponse`
- `GENARRATIVE_AGENT_RUNTIME_PROGRAM` 可指定 Agent 可执行文件,默认从 PATH 查找 `agent`
子进程使用独立临时目录、HOME、DB 和环境,旧 Codex 模式仍可回退。
- 当前只替换无 function tool 的文本推理;带工具的 AGC 回合会明确失败,不伪装迁移
AGC 工具调用、审批、项目 Runtime 或预览完成门。这些仍留在 AGC,后续按协议逐项接入。
## 第二阶段:实验模式
## 第二阶段:工具协议实验(下一步)
- 新增 `agent_runtime` 模式,仅作为可回滚实验开关,默认仍使用现有 Codex 模式。
- 保持 `agent_runtime` 为可回滚实验开关,默认仍使用现有 Codex 模式。
- 先覆盖文本回复、单工具调用、工具结果回传、拒绝/取消/超时和事件顺序。
- 使用现有 AGC fixture 对比旧 Codex 模式和新 Engine:消息、工具调用参数、Runtime
事件、错误分类和终态必须一致。