Compare commits

...

15 Commits

Author SHA1 Message Date
kdletters 36ae9425e9 完善 AGC 独立 Agent 试验验证
支持使用 Fake Provider 验证跨进程 Agent 回合

修正独立 Agent 与排队模型的匹配和子进程超时回收

补充 AGC 到独立 App Server 的真实子进程 smoke
2026-09-10 13:07:09 +08:00
kdletters bd85694ee9 接入 AGC 独立 Agent 试验模式
新增 agent_runtime 配置并通过 stdio App Server 启动独立 Agent

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

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

补充 AGC 配置回归与实施边界文档
2026-09-10 12:45:54 +08:00
kdletters c709b3d9b2 新增独立 Agent App Server
为 agent-cli 增加 stdio JSON-RPC 2.0 服务入口与运行控制协议

补充 Host 实时流监听和流式取消入口

新增 App Server 子进程闭环测试及协议文档

更新 README、架构、TODO 与共享决策记录
2026-09-10 00:05:02 +08:00
kdletters fc05617ecc 记录 AGC 使用独立 Agent 内核的边界
明确替换 Codex 执行循环而非替换二进制

保留 AGC 工具权限、Runtime 持久化和完成门

规定兼容桥、实验模式与新旧 fixture 对比顺序
2026-09-09 21:28:04 +08:00
kdletters d85557cf6d 制定 AGC 接入独立 Agent 内核计划
明确以新 Kernel/Engine 替换 Codex 执行循环

保留 AGC 工具权限、Runtime 持久化、预览和完成门

先实现兼容桥与可回滚 agent_runtime 实验模式
2026-09-09 21:27:11 +08:00
kdletters ce8425e671 明确 AGC 使用新 Agent 内核的替换目标
将替换对象从 Codex 适配器修正为 Agent Kernel 与 Engine 执行循环

保留 AGC 宿主的工具、权限、持久化、预览和完成门

规划 agent_runtime 实验模式与可回滚接线
2026-09-09 21:09:36 +08:00
kdletters 9eb1abfda5 评估 AGC 与独立 Codex 替换边界
对比 AGC 0.147.0、独立适配器 0.152.1 与本机 Codex 0.153.4

记录协议、隔离、工具桥与 Runtime 职责差异

明确先做 0.153.4 typed adapter 和可切换接线,不直接替换生产路径
2026-09-09 20:51:00 +08:00
kdletters 0c41cb6758 更新 Host 执行拆分计划状态
记录 execution.rs 已承接运行入口与 lease 恢复编排

保留 Engine worker 主体和终态 glue 的后续边界
2026-09-09 20:14:48 +08:00
kdletters 662ecd3e57 补录 Agent Runtime 架构决策
记录独立 Rust workspace、Runtime 控制面与 Host 模块拆分决策

补充执行入口、checkpoint、Codex 和外部桥接的职责边界与验证口径
2026-09-09 20:13:20 +08:00
kdletters c64d98a18f 拆分 Host 执行入口
将 run、run_existing、streaming 与 cancellation 入口移入 execution.rs

保持 AgentHost 公开 API、Runtime/SQLite 合同和 Engine 接口不变

同步 README、架构说明与 TODO,保留跨职责终态 glue 在根模块
2026-09-09 20:12:02 +08:00
kdletters c7d6eade5e 拆分 Host 检查点与 Codex 桥接
将 checkpoint listener 和 runtime trace 投影移入 checkpoint 模块

将 Codex server-request handler 与 durable tool-call helper 移入 codex 模块

保持根路径公开类型、Engine 执行和 Runtime 边界不变
2026-09-09 19:58:16 +08:00
kdletters 3ba935367a 拆分 Host 检查点与事件投影
将 DurableCheckpoints、RuntimeTraceProgress 和 runtime trace 投影移入 checkpoint 模块

保持 Engine 执行、Runtime reducer 和公开 Host API 边界不变

补齐拆分后的 workspace、Clippy、Rustdoc、编码与差异验证
2026-09-09 19:49:29 +08:00
kdletters 69fc3d8ca0 拆分 Agent Host 运行职责
将 durable 取消、失败收口、审批决议和外部结果对账迁入 RuntimeService

把 Host 工具、MCP、Skill 与外部 backend 桥接拆为私有模块并保持公开 API

补充 Runtime 控制测试、依赖反向门禁和架构验收文档
2026-09-09 19:20:54 +08:00
kdletters 202279c6d9 新增独立 Agent Runtime Rust 工作区
新增 Core、Engine、Runtime、SQLite、Provider、MCP、Skill、Codex、CLI 与 DAG crate

补齐 OpenAI endpoint 配置、Provider 实例/协议路由和统一工具权限边界

加入持久化、lease、checkpoint、reconciliation、审批恢复与消息历史回归

加入独立 workspace CI、依赖边界、能力集和 Fake Agent 测试脚本

同步建设计划、TODO、架构、测试与验收文档
2026-09-06 17:44:54 +08:00
kdletters 3e8591040d 忽略所有 Rust target 目录
将 target 忽略规则扩展到仓库任意层级
2026-09-01 15:23:50 +08:00
120 changed files with 82795 additions and 4 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ temp*build*/
/.app/
/.jenkins-source-commit
/.jenkins-spacetime-schema-base
/target/
target/
/logs
/.claude/settings.local.json
/.codegraph/
@@ -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,283 @@
//! 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_PROVIDER_ENV: &str = "GENARRATIVE_AGENT_RUNTIME_PROVIDER";
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 provider = std::env::var(AGENT_RUNTIME_PROVIDER_ENV)
.ok()
.filter(|value| matches!(value.as_str(), "fake" | "openai"))
.unwrap_or_else(|| "openai".to_string());
let config = temp.path().join("agent.toml");
std::fs::write(&config, format!("provider = \"{provider}\"\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", &provider)
.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(),
));
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn standalone_agent_fake_process_completes_text_turn() {
let _guard = ENV_LOCK.lock().expect("env lock");
let program = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../rust/target/debug/agent");
assert!(
program.is_file(),
"先构建 rust agent-cli: {}",
program.display()
);
let previous_program = std::env::var_os(AGENT_RUNTIME_PROGRAM_ENV);
let previous_provider = std::env::var_os(AGENT_RUNTIME_PROVIDER_ENV);
std::env::set_var(AGENT_RUNTIME_PROGRAM_ENV, &program);
std::env::set_var(AGENT_RUNTIME_PROVIDER_ENV, "fake");
let llm = GameCreatorLlmConfig::default();
let request = platform_llm::LlmRunRequest::single_turn("系统", "请简短回答:你好");
let response = request_game_creator_agent_runtime_blocking(&llm, request)
.expect("独立 Agent 应完成文本回合");
assert_eq!(response.text, "fake provider complete");
match previous_program {
Some(value) => std::env::set_var(AGENT_RUNTIME_PROGRAM_ENV, value),
None => std::env::remove_var(AGENT_RUNTIME_PROGRAM_ENV),
}
match previous_provider {
Some(value) => std::env::set_var(AGENT_RUNTIME_PROVIDER_ENV, value),
None => std::env::remove_var(AGENT_RUNTIME_PROVIDER_ENV),
}
}
}
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,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
# AGC 接入独立 Agent 内核实施计划
## 目标
让 AGC 的 Codex 模式改由独立 `rust/` workspace 的 Agent Kernel/Engine 执行,
同时保留 AGC 宿主对项目、工具权限、审批、Runtime 持久化、预览和完成门的控制。
## 第一阶段:独立进程试验(已完成)
- `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 模式。
- 先覆盖文本回复、单工具调用、工具结果回传、拒绝/取消/超时和事件顺序。
- 使用现有 AGC fixture 对比旧 Codex 模式和新 Engine:消息、工具调用参数、Runtime
事件、错误分类和终态必须一致。
## 第三阶段:真实接线
- 通过 AGC 已有项目 MCP/工具桥接入真实工具,不把项目绝对路径、凭据或 AGC 私有
状态塞进新 Kernel 的公共契约。
- 配置、`CODEX_HOME`、网络和 workspace 隔离仍由 AGC 宿主提供;独立 Engine 只消费
已校验的中立端口。
- 真实授权环境下执行文本、工具、取消、超时和重启恢复 smoke;失败自动回到旧模式。
## 完成判据
- `agent_runtime` 能在 AGC 中完成一次真实单 Agent 文本回合。
- 至少一个 AGC 受控工具完成调用/结果回传,且没有绕过 AGC Runtime 权限和审计。
- 新旧模式的 fixture 对比无消息、事件或终态差异;取消和未知副作用不自动重放。
- all/no-default、AGC Rust 定向测试、编码和 diff 检查通过后,才讨论切换默认模式。
## 明确不做
- 不把 `codex-core` 搬进 AGC 或独立 Kernel。
- 不直接删除 `codex_app_server`/`codex_cli`,不先升级 0.147.0 sidecar。
- 不用 standalone AgentHost 的 SQLite 取代 AGC 现有 Runtime 持久化。
@@ -0,0 +1,71 @@
# AGC 与独立 Agent Codex 替换对比
## 结论
当前不能直接把 AGC 的 Codex 实现替换成 `rust/` workspace 的
`agent-codex`,也不能只替换可执行文件后宣称兼容。三处版本和职责并不一致:
这里的目标不是“换一个 Codex 适配器”,而是让 AGC 的 Codex 模式改由新 Agent
Kernel/Engine 执行;AGC 继续作为宿主,保留项目工具、权限、持久化、预览和完成门。
因此替换对象是 Codex 的推理/工具循环,不是把 AGC 的宿主安全层删掉。
| 位置 | 当前版本/协议 | 实际范围 |
| --- | --- | --- |
| AGC npm/Windows sidecar | `@openai/codex` / `codex-cli 0.147.0` | 自建 Tokio app-server 连接池、隔离 `CODEX_HOME`、workspace sandbox、MCP/Skill、项目工具桥、Runtime 事件和取消/审计 |
| 独立 `rust/agent-codex` | 通用 JSONL/V2 + 窄 `codex_0_152_1` | 进程与 JSON-RPC transport、有限生命周期、有限 server-request/notification DTO;不嵌入 `codex-core`,不持有项目 Runtime 真相 |
| 本机 Codex | `codex-cli 0.153.4` | 可验证 initialize/thread-startschema 已扩展,不能作为 0.152.1 的兼容证明 |
## 接线差异
AGC 的 `codex_app_server.rs` 还负责凭据桥接、敏感环境变量排除、临时目录、只读/可写
workspace 模式、`agc_tools` MCP、Skill roots、连接池和项目级安全策略。独立适配器的
`CodexAppServerProcess` 只负责进程/协议边界,默认不会清空环境、注入 `CODEX_HOME`
设置工作目录;Host 的 handler 也只覆盖工具调用桥接。因此直接替换会丢失 AGC 的安全和
业务运行时语义。
协议上,AGC 当前使用 initialize → thread/start → turn/start,并处理项目工具请求、MCP/
Skill 初始化、turn/agent-message 通知和 `turn/interrupt`。独立适配器的通用层可承接
JSON-RPC transport0.152.1 typed 层只覆盖有限的 initialize/thread/start/turn/start/
interrupt、8 类 server request 和 6 类通知;它不是完整生成 schema。
## 与 Codex 版本对比
本机 0.153.4 的实验 schema 已明显扩展:v2 bundle 约 706653 bytes,包含约 155 个
client methods、11 个 server requests 和 81 个 server notifications;新增/扩展了
thread queue、resume/steer、settings、goals、realtime、plugins、skills、MCP、文件与
进程等边界。独立仓库当前 fixture 固定 0.152.1,不能把相同的 `v2` 标签当作发行版兼容。
上游 `openai/codex` 还把 `app-server-protocol``app-server-client``app-server-transport`
`app-server` 拆成多个 crate,并提供异步 typed/raw API、server-request resolution、
有序事件队列和多种 transport;这不等于应把 `codex-core` 搬进 AGC。AGC 的项目 Runtime、
工具权限、持久化和预览完成门仍应由 AGC 掌控。
## 分阶段替换路线
1. 先把 AGC 宿主能力抽成新 Engine 可调用的 Provider/Tool/Approval/Context 端口适配器;
AGC 仍持有项目 Runtime、权限、审计、预览和持久化真相。
2. 给独立 Engine 增加 AGC 所需的异步 facadeTokio↔同步 Engine 的受控线程桥),并把
AGC 的工具动作转换为新 Kernel 的 `ToolExecutor`,不复制第二套项目状态机。
3. 在 AGC 增加实验模式 `agent_runtime`,与旧 `codex_app_server` 并行;先覆盖文本、
function tool、审批拒绝/等待、取消、超时和事件投影,失败自动回滚到旧模式。
4. 再处理 Codex 版本适配:新 Agent 若仍通过 Codex App Server 访问模型,新增
`codex_0_153_4` typed adapter;若直接用 OpenAI Provider,则不把 Codex wire 作为
新 Engine 依赖。两条路径都必须保留 AGC 的隔离环境策略。
5. 通过同一批 AGC fixture 对比两条模式的消息、工具调用、Runtime 事件、取消和终态,
之后才考虑把 `agent_runtime` 设为默认;0.147.0 旧路径在验收前保留。
## 当前不做
- 本阶段不直接修改 AGC 的默认 Codex 接线或删除 0.147.0 fallback;先实现可回滚的
`agent_runtime` 实验路径。
- 不复制 `codex-core`、ThreadStore、sandbox、认证、MCP/Skill/plugin 子系统到独立内核。
- 不把本机 0.153.4 的握手通过写成完整 session、工具或版本兼容已验收。
## 证据
- AGC`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs`
`codex_cli.rs``runtime_protocol/provider_retry.rs`
- 独立适配器:`rust/crates/agent-codex/src/lib.rs`
`rust/crates/agent-codex/src/codex_0_152_1.rs` 及其审计文档。
- 版本来源:AGC `package.json`/`package-lock.json``src-tauri/build.rs`;本机
`codex --version``codex-cli 0.153.4`
@@ -0,0 +1,88 @@
name: Agent Runtime CI
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
env:
CARGO_INCREMENTAL: '0'
CARGO_NET_RETRY: '10'
CARGO_TERM_COLOR: always
RUSTUP_AUTO_INSTALL: '0'
jobs:
rust:
name: Rust workspace
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Prepare isolated temporary directory
shell: bash
run: |
mkdir -p "$HOME/data/tmp"
echo "TMPDIR=$HOME/data/tmp" >> "$GITHUB_ENV"
# rust-toolchain.toml 固定编译器和组件;runner 镜像需预装它们,
# 这里先失败得更明确,不让后续 Clippy 步骤才暴露环境缺口。
- name: Verify pinned Rust toolchain
shell: bash
run: |
rustc --version
cargo fmt --version
cargo clippy --version
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check warnings and targets
run: RUSTFLAGS='-D warnings' cargo check --locked --workspace --all-targets --all-features
- name: Check no-default-features build
run: RUSTFLAGS='-D warnings' cargo check --locked --workspace --all-targets --no-default-features
# The workspace compatibility matrix includes the SQLite service through
# Host. Test the portable Runtime package in isolation so this gate
# proves the physical dependency boundary rather than relying on feature
# unification in the full workspace.
- name: Check portable Runtime without SQLite
shell: bash
run: |
if cargo tree --locked --no-default-features -p agent-runtime -e normal \
| grep -Eiq 'agent-storage-sqlite|rusqlite|libsqlite3-sys'; then
echo 'portable agent-runtime unexpectedly contains SQLite' >&2
exit 1
fi
RUSTFLAGS='-D warnings' cargo check --locked -p agent-runtime --all-targets --no-default-features
cargo test --locked -p agent-runtime --all-targets --no-default-features --no-fail-fast
RUSTDOCFLAGS='-D warnings' cargo doc --locked -p agent-runtime --no-default-features --no-deps
- name: Verify kernel dependency boundary
shell: bash
run: |
./scripts/check-dependencies.sh Cargo.toml
- name: Verify package manifests
shell: bash
run: |
./scripts/check-package-manifests.sh Cargo.toml
# cargo-audit 和 RustSec advisory DB 由 runner 预装/挂载;本步骤刻意不
# 执行 cargo install、git fetch 或其它联网更新。可用 CARGO_AUDIT_BIN
# 指向 runner 固定版本的二进制,并必须提供 RUSTSEC_ADVISORY_DB。
- name: Run offline cargo-audit
shell: bash
run: ./scripts/run-cargo-audit.sh
- name: Verify independent workspace copy
shell: bash
run: ./scripts/verify-independent-workspace.sh
- name: Run tests
run: cargo test --locked --workspace --all-features --no-fail-fast
- name: Run no-default-features tests
run: cargo test --locked --workspace --no-default-features --no-fail-fast
- name: Run deterministic agent test set
shell: bash
run: ./scripts/run-agent-test-set.sh --quick
- name: Build documentation without warnings
run: RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --all-features --no-deps
- name: Run Clippy policy gate
run: cargo clippy --locked --workspace --all-features --all-targets -- -D warnings
- name: Run no-default-features Clippy policy gate
run: cargo clippy --locked --workspace --no-default-features --all-targets -- -D warnings
- name: Run portable Runtime Clippy policy gate
run: cargo clippy --locked -p agent-runtime --no-default-features --all-targets -- -D warnings
+8
View File
@@ -0,0 +1,8 @@
/target/
*.db
*.db-shm
*.db-wal
.env
agent.toml
.tmp-test/
.tmp-cli.*/
+1858
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
[workspace]
resolver = "2"
members = [
"crates/agent-runtime-core",
"crates/agent-runtime-contracts",
"crates/agent-runtime-engine",
"crates/agent-runtime",
"crates/agent-runtime-sqlite",
"crates/agent-provider-openai",
"crates/agent-provider-fake",
"crates/agent-codex",
"crates/agent-runtime-orchestration",
"crates/agent-storage-sqlite",
"crates/agent-mcp",
"crates/agent-skills",
"crates/agent-app",
"crates/agent-host",
"crates/agent-cli",
]
[workspace.package]
edition = "2024"
version = "0.1.0"
rust-version = "1.96"
license = "UNLICENSED"
[workspace.dependencies]
agent-runtime-core = { path = "crates/agent-runtime-core", version = "0.1.0" }
agent-runtime-contracts = { path = "crates/agent-runtime-contracts", version = "0.1.0" }
agent-runtime-engine = { path = "crates/agent-runtime-engine", version = "0.1.0" }
agent-runtime = { path = "crates/agent-runtime", version = "0.1.0" }
agent-runtime-sqlite = { path = "crates/agent-runtime-sqlite", version = "0.1.0" }
agent-provider-openai = { path = "crates/agent-provider-openai", version = "0.1.0" }
agent-provider-fake = { path = "crates/agent-provider-fake", version = "0.1.0" }
agent-codex = { path = "crates/agent-codex", version = "0.1.0" }
agent-runtime-orchestration = { path = "crates/agent-runtime-orchestration", version = "0.1.0" }
agent-storage-sqlite = { path = "crates/agent-storage-sqlite", version = "0.1.0" }
agent-mcp = { path = "crates/agent-mcp", version = "0.1.0" }
agent-skills = { path = "crates/agent-skills", version = "0.1.0" }
agent-app = { path = "crates/agent-app", version = "0.1.0" }
agent-host = { path = "crates/agent-host", version = "0.1.0" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
toml = "0.8"
thiserror = "2"
+647
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
# 复制为 agent.toml 后即可运行 `cargo run -p agent-cli -- run "任务"`。
# 认证字段只保存环境变量名;不要把 api_key、token 或 Cookie 写进此文件。
db = "./agent.db"
provider = "fake"
model = "fake"
stream = false
# provider = "openai" 时可使用以下任一 endpoint 配置:
# openai_base_url = "https://gateway.example/v1" # 自动补 /responses
# openai_endpoint = "https://gateway.example/v1/responses" # 完整地址,优先
# openai_api_key_env = "OPENAI_API_KEY" # 只保存环境变量名
# OPENAI_MODEL 或 AGENT_MODEL 可覆盖 model
# 可选的固定提示词 sectionsystem/developer/context 不会被压成同一条 user 消息。
# system_prompt = "你是一个简洁的助手"
# developer_prompt = "输出可审计的步骤"
# context_prompt = "这是不可信的外部背景"
[skills]
# roots = ["./.codex/skills", "./.agents/skills"]
# names = ["review"]
[mcp]
# server = "workspace"
# stdio_command = "npx"
# stdio_args = ["-y", "@modelcontextprotocol/server-filesystem", "."]
# timeout_secs = 30
# allow = ["read_file"]
# 只有显式列出的内容会被读取并作为不可信上下文注入;默认不读取资源或 prompt。
# context_resources = ["file:///workspace/README.md"]
# context_prompts = ["welcome"] # CLI 使用空参数;带参数请用库 API
# HTTP 或 stdio 的秘密均通过环境变量引用。示例:
# [[mcp.auth]]
# variable = "MCP_TOKEN"
# target = "http_bearer"
#
# stdio 服务器也可以把环境变量转发给子进程:
# [[mcp.auth]]
# variable = "MCP_WORKSPACE_TOKEN"
# target = "stdio_environment"
# name = "WORKSPACE_TOKEN"
# Codex 是显式外部 backend,不替换当前 CLI 的 ModelProvider。
# `agent codex validate` 只校验配置和参数白名单,不启动进程。
# [codex.cli]
# program = "codex"
# args = ["--model=gpt-5-codex"]
# allowed_arg_prefixes = ["--model"]
# timeout_ms = 120000
# max_output_bytes = 1048576
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "agent-app"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "通用 Agent 程序的配置与装配边界"
[dependencies]
agent-codex.workspace = true
agent-mcp.workspace = true
agent-provider-openai.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
+332
View File
@@ -0,0 +1,332 @@
//! 通用 Agent 程序的无状态配置与装配输入。
//!
//! 这个 crate 只承接 `agent.toml`、环境变量和非秘密路由 metadata 的解析。
//! 它不依赖 Host、Runtime、线程或数据库,因此 CLI 之外的入口也可以复用
//! 同一套配置优先级,而不会复制一份运行状态机。
use std::env;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use agent_codex::CodexCliConfig;
use agent_mcp::{McpAuthEnv, McpAuthTarget};
use agent_provider_openai::OpenAiProviderConfig;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// `agent.toml` 只描述可复现的装配参数;密钥字段故意没有对应结构,
/// 未知字段会被拒绝,避免用户误把明文 token 写入配置文件。
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AgentTomlConfig {
pub db: Option<String>,
pub provider: Option<String>,
pub model: Option<String>,
pub stream: Option<bool>,
pub openai_api_key_env: Option<String>,
/// OpenAI-compatible 网关的 base URL;运行时会自动补 /responses。
pub openai_base_url: Option<String>,
/// OpenAI Responses 的完整 endpoint,优先于 base URL。
pub openai_endpoint: Option<String>,
pub system_prompt: Option<String>,
pub developer_prompt: Option<String>,
pub context_prompt: Option<String>,
#[serde(default)]
pub skills: SkillTomlConfig,
#[serde(default)]
pub mcp: McpTomlConfig,
#[serde(default)]
pub codex: CodexTomlConfig,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SkillTomlConfig {
pub roots: Vec<String>,
pub names: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct McpTomlConfig {
pub server: Option<String>,
pub stdio_command: Option<String>,
pub stdio_args: Vec<String>,
pub http_url: Option<String>,
/// 认证只保存环境变量引用;解析后的 token 由 agent-mcp 在连接时读取。
#[serde(default)]
pub auth: Vec<McpAuthToml>,
pub timeout_secs: Option<u64>,
pub allow: Vec<String>,
/// 只读取这些明确列出的 resource URI 作为不可信上下文。
pub context_resources: Vec<String>,
/// 只展开这些明确列出的 prompt(CLI 使用空参数;需要参数时使用库 API)。
pub context_prompts: Vec<String>,
}
/// Codex 仍是显式外部 backend,不替换 CLI 的 ModelProvider。这里先把一次性
/// CLI 的受限启动配置纳入同一份 agent.toml,并由 `codex validate` 做无副作用
/// 校验;App Server channel 继续由嵌入方注入,避免配置层偷藏第二套运行状态。
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct CodexTomlConfig {
pub cli: Option<CodexCliConfig>,
}
/// agent.toml 中的认证引用保持扁平、可读的写法:
/// `target = "http_bearer"`,真正的 Core target 在连接前才构造。
/// 结构里没有 secret 字段,故配置序列化和 Debug 都不会持有凭据原文。
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct McpAuthToml {
pub variable: String,
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
}
impl fmt::Debug for McpAuthToml {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("McpAuthToml")
.field("variable", &"<env-ref>")
.field("target", &self.target)
.field("name", &self.name)
.field("prefix", &self.prefix.as_deref().map(|_| "<redacted>"))
.finish()
}
}
impl McpAuthToml {
/// 将非秘密 TOML 引用转换为 MCP 的 Core 认证引用。
pub fn into_core(self) -> Result<McpAuthEnv, Box<dyn std::error::Error>> {
let target = match self.target.as_str() {
"http_bearer" => {
if self.name.is_some() || self.prefix.is_some() {
return Err("http_bearer 认证引用不应带 name/prefix 字段"
.to_owned()
.into());
}
McpAuthTarget::HttpBearer
}
"http_header" => {
let name = self
.name
.filter(|name| !name.trim().is_empty())
.ok_or("http_header 认证引用需要非空 name")?;
McpAuthTarget::HttpHeader {
name,
prefix: self.prefix.unwrap_or_default(),
}
}
"stdio_environment" => {
let name = self
.name
.filter(|name| !name.trim().is_empty())
.ok_or("stdio_environment 认证引用需要非空 name")?;
McpAuthTarget::StdioEnvironment { name }
}
_ => {
return Err(
"MCP 认证 target 无效(支持 http_bearer/http_header/stdio_environment"
.to_owned()
.into(),
);
}
};
if self.variable.trim().is_empty() {
return Err("MCP 认证 variable 不能为空".to_owned().into());
}
Ok(McpAuthEnv {
variable: self.variable,
target,
})
}
}
impl AgentTomlConfig {
/// 从 `AGENT_CONFIG`(缺省为当前目录 `agent.toml`)读取配置。
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let path = env::var_os("AGENT_CONFIG")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("agent.toml"));
Self::load_from_path(path)
}
/// 从明确路径加载配置;调用方可用它避免在测试中修改全局环境。
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, Box<dyn std::error::Error>> {
let path = path.as_ref();
if !path.exists() {
return Ok(Self::default());
}
let text = fs::read_to_string(path)?;
toml::from_str(&text)
.map_err(|error| format!("配置文件 {} 无效: {error}", path.display()).into())
}
/// 解析数据库路径:进程环境覆盖 TOML,最后使用 `agent.db`。
pub fn db_path(&self) -> PathBuf {
env::var_os("AGENT_DB")
.map(PathBuf::from)
.or_else(|| self.db.as_deref().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("agent.db"))
}
/// 解析 Provider 名称。没有显式选择时,有可用 OpenAI key 才默认 openai。
pub fn provider(&self) -> String {
non_empty_env("AGENT_PROVIDER")
.or_else(|| self.provider.clone())
.unwrap_or_else(|| {
let key_env = self.openai_api_key_env();
if env::var(key_env)
.map(|key| !key.trim().is_empty())
.unwrap_or(false)
{
"openai".to_owned()
} else {
"fake".to_owned()
}
})
}
/// 解析模型名称,并让环境变量覆盖配置文件。
pub fn model(&self) -> String {
non_empty_env("AGENT_MODEL")
.or_else(|| {
(self.provider() == "openai")
.then(|| non_empty_env("OPENAI_MODEL"))
.flatten()
})
// Treat a blank TOML value like an unset override, matching the
// environment helpers and allowing the normal provider default.
.or_else(|| self.model.clone().filter(|value| !value.trim().is_empty()))
.unwrap_or_else(|| "fake".to_owned())
}
/// API key 只解析环境变量名,从不读取或保存 key 原文。
pub fn openai_api_key_env(&self) -> String {
non_empty_env("AGENT_OPENAI_API_KEY_ENV")
.or_else(|| non_empty_env("OPENAI_API_KEY_ENV"))
.or_else(|| self.openai_api_key_env.clone())
.unwrap_or_else(|| "OPENAI_API_KEY".to_owned())
}
/// 按环境变量优先级构造 OpenAI 非秘密配置。
pub fn openai_provider_config(&self) -> OpenAiProviderConfig {
self.openai_provider_config_with_env(
non_empty_env("OPENAI_ENDPOINT").as_deref(),
non_empty_env("OPENAI_BASE_URL").as_deref(),
)
}
/// 明确传入环境候选,便于嵌入方做纯函数测试而不修改进程环境。
/// 环境完整 endpoint 优先于环境 base URL;任一环境来源都优先于 TOML。
pub fn openai_provider_config_with_env(
&self,
env_endpoint: Option<&str>,
env_base_url: Option<&str>,
) -> OpenAiProviderConfig {
let mut config =
OpenAiProviderConfig::default().with_api_key_env(self.openai_api_key_env());
if let Some(endpoint) = env_endpoint
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
{
config = config.with_endpoint(endpoint);
} else if let Some(base_url) = env_base_url
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
{
config = config.with_base_url(base_url);
} else if let Some(endpoint) = self
.openai_endpoint
.clone()
.filter(|value| !value.trim().is_empty())
{
config = config.with_endpoint(endpoint);
} else if let Some(base_url) = self
.openai_base_url
.clone()
.filter(|value| !value.trim().is_empty())
{
config = config.with_base_url(base_url);
}
config
}
/// 解析流式开关;显式环境值优先,OpenAI 缺省开启流式。
pub fn streaming(&self) -> bool {
if let Some(value) = non_empty_env("AGENT_STREAM") {
return matches!(value.as_str(), "1" | "true" | "yes" | "on");
}
self.stream.unwrap_or_else(|| self.provider() == "openai")
}
}
/// 将 provider 与实际模型写成后台 queued run 的非秘密路由观察。
/// worker 仍会在真正执行前重新装配 Provider,不会从 metadata 读取凭据。
pub fn queued_run_metadata(config: &AgentTomlConfig, provider: &str) -> Value {
json!({
"provider": effective_model(config, provider),
"providerKind": provider,
})
}
/// 将配置模型解析为实际执行模型;OpenAI 的 `fake` 只是未指定模型的哨兵。
pub fn effective_model(config: &AgentTomlConfig, provider: &str) -> String {
let model = config.model();
if provider == "openai" && model == "fake" {
"gpt-4.1-mini".to_owned()
} else {
model
}
}
fn non_empty_env(name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::{AgentTomlConfig, effective_model, queued_run_metadata};
#[test]
fn blank_model_uses_provider_default_without_secret_values() {
let config: AgentTomlConfig =
toml::from_str("provider = 'openai'\nmodel = ' '").expect("配置应可解析");
assert_eq!(config.model(), "fake");
assert_eq!(effective_model(&config, "openai"), "gpt-4.1-mini");
assert_eq!(
queued_run_metadata(&config, "openai"),
serde_json::json!({"provider": "gpt-4.1-mini", "providerKind": "openai"})
);
}
#[test]
fn load_missing_path_returns_default() {
let config = AgentTomlConfig::load_from_path(
std::env::temp_dir().join("agent-app-missing-config-does-not-exist.toml"),
)
.expect("缺失配置应使用默认值");
assert!(config.db.is_none());
assert_eq!(config.provider, None);
}
#[test]
fn endpoint_prefers_environment_before_toml() {
let config: AgentTomlConfig = toml::from_str(
"provider = 'openai'\nopenai_endpoint = 'https://toml.example/v1/responses'\nopenai_base_url = 'https://toml-base.example/v1'",
)
.expect("配置应可解析");
assert_eq!(
config
.openai_provider_config_with_env(None, Some("https://env.example/v1"))
.resolve_endpoint()
.expect("endpoint 应可解析"),
"https://env.example/v1/responses"
);
}
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "agent-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "通用 Agent 单智能体命令行程序"
[[bin]]
name = "agent"
path = "src/main.rs"
[dependencies]
agent-app.workspace = true
agent-codex.workspace = true
agent-host.workspace = true
agent-mcp.workspace = true
agent-provider-fake.workspace = true
agent-provider-openai.workspace = true
agent-runtime-core.workspace = true
agent-runtime-engine.workspace = true
agent-skills.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true

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