Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36ae9425e9 | |||
| bd85694ee9 | |||
| c709b3d9b2 | |||
| fc05617ecc | |||
| d85557cf6d | |||
| ce8425e671 | |||
| 9eb1abfda5 | |||
| 0c41cb6758 | |||
| 662ecd3e57 | |||
| c64d98a18f | |||
| c7d6eade5e | |||
| 3ba935367a | |||
| 69fc3d8ca0 | |||
| 202279c6d9 | |||
| 3e8591040d |
+1
-1
@@ -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-start;schema 已扩展,不能作为 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 transport,0.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 所需的异步 facade(Tokio↔同步 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
|
||||
@@ -0,0 +1,8 @@
|
||||
/target/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
agent.toml
|
||||
.tmp-test/
|
||||
.tmp-cli.*/
|
||||
Generated
+1858
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
# 可选的固定提示词 section;system/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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user