mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 06:04:23 +00:00
refactor: rename engine/ to server/
This commit is contained in:
parent
016024c04b
commit
71ab40388c
37 changed files with 917 additions and 3 deletions
12
server/packages/agent-credentials/Cargo.toml
Normal file
12
server/packages/agent-credentials/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "sandbox-agent-agent-credentials"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dirs = "5.0"
|
||||
time = { version = "0.3", features = ["parsing", "formatting"] }
|
||||
371
server/packages/agent-credentials/src/lib.rs
Normal file
371
server/packages/agent-credentials/src/lib.rs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ProviderCredentials {
|
||||
pub api_key: String,
|
||||
pub source: String,
|
||||
pub auth_type: AuthType,
|
||||
pub provider: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthType {
|
||||
ApiKey,
|
||||
Oauth,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ExtractedCredentials {
|
||||
pub anthropic: Option<ProviderCredentials>,
|
||||
pub openai: Option<ProviderCredentials>,
|
||||
pub other: HashMap<String, ProviderCredentials>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CredentialExtractionOptions {
|
||||
pub home_dir: Option<PathBuf>,
|
||||
pub include_oauth: bool,
|
||||
}
|
||||
|
||||
impl CredentialExtractionOptions {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
home_dir: None,
|
||||
include_oauth: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_claude_credentials(options: &CredentialExtractionOptions) -> Option<ProviderCredentials> {
|
||||
let home_dir = options.home_dir.clone().unwrap_or_else(default_home_dir);
|
||||
let include_oauth = options.include_oauth;
|
||||
|
||||
let config_paths = [
|
||||
home_dir.join(".claude.json.api"),
|
||||
home_dir.join(".claude.json"),
|
||||
home_dir.join(".claude.json.nathan"),
|
||||
];
|
||||
|
||||
let key_paths = [
|
||||
vec!["primaryApiKey"],
|
||||
vec!["apiKey"],
|
||||
vec!["anthropicApiKey"],
|
||||
vec!["customApiKey"],
|
||||
];
|
||||
|
||||
for path in config_paths {
|
||||
let data = read_json_file(&path)?;
|
||||
for key_path in &key_paths {
|
||||
if let Some(key) = read_string_field(&data, key_path) {
|
||||
if key.starts_with("sk-ant-") {
|
||||
return Some(ProviderCredentials {
|
||||
api_key: key,
|
||||
source: "claude-code".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: "anthropic".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if include_oauth {
|
||||
let oauth_paths = [
|
||||
home_dir.join(".claude").join(".credentials.json"),
|
||||
home_dir.join(".claude-oauth-credentials.json"),
|
||||
];
|
||||
for path in oauth_paths {
|
||||
let data = match read_json_file(&path) {
|
||||
Some(value) => value,
|
||||
None => continue,
|
||||
};
|
||||
let access = read_string_field(&data, &["claudeAiOauth", "accessToken"]);
|
||||
if let Some(token) = access {
|
||||
if let Some(expires_at) =
|
||||
read_string_field(&data, &["claudeAiOauth", "expiresAt"])
|
||||
{
|
||||
if is_expired_rfc3339(&expires_at) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Some(ProviderCredentials {
|
||||
api_key: token,
|
||||
source: "claude-code".to_string(),
|
||||
auth_type: AuthType::Oauth,
|
||||
provider: "anthropic".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn extract_codex_credentials(options: &CredentialExtractionOptions) -> Option<ProviderCredentials> {
|
||||
let home_dir = options.home_dir.clone().unwrap_or_else(default_home_dir);
|
||||
let include_oauth = options.include_oauth;
|
||||
let path = home_dir.join(".codex").join("auth.json");
|
||||
let data = read_json_file(&path)?;
|
||||
|
||||
if let Some(key) = data.get("OPENAI_API_KEY").and_then(Value::as_str) {
|
||||
if !key.is_empty() {
|
||||
return Some(ProviderCredentials {
|
||||
api_key: key.to_string(),
|
||||
source: "codex".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: "openai".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if include_oauth {
|
||||
if let Some(token) = read_string_field(&data, &["tokens", "access_token"]) {
|
||||
return Some(ProviderCredentials {
|
||||
api_key: token,
|
||||
source: "codex".to_string(),
|
||||
auth_type: AuthType::Oauth,
|
||||
provider: "openai".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn extract_opencode_credentials(options: &CredentialExtractionOptions) -> ExtractedCredentials {
|
||||
let home_dir = options.home_dir.clone().unwrap_or_else(default_home_dir);
|
||||
let include_oauth = options.include_oauth;
|
||||
let path = home_dir
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("opencode")
|
||||
.join("auth.json");
|
||||
|
||||
let mut result = ExtractedCredentials::default();
|
||||
let data = match read_json_file(&path) {
|
||||
Some(value) => value,
|
||||
None => return result,
|
||||
};
|
||||
|
||||
let obj = match data.as_object() {
|
||||
Some(obj) => obj,
|
||||
None => return result,
|
||||
};
|
||||
|
||||
for (provider_name, value) in obj {
|
||||
let config = match value.as_object() {
|
||||
Some(config) => config,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let auth_type = config
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
|
||||
let credentials = if auth_type == "api" {
|
||||
config.get("key").and_then(Value::as_str).map(|key| ProviderCredentials {
|
||||
api_key: key.to_string(),
|
||||
source: "opencode".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: provider_name.to_string(),
|
||||
})
|
||||
} else if auth_type == "oauth" && include_oauth {
|
||||
let expires = config.get("expires").and_then(Value::as_i64);
|
||||
if let Some(expires) = expires {
|
||||
if expires < current_epoch_millis() {
|
||||
None
|
||||
} else {
|
||||
config
|
||||
.get("access")
|
||||
.and_then(Value::as_str)
|
||||
.map(|token| ProviderCredentials {
|
||||
api_key: token.to_string(),
|
||||
source: "opencode".to_string(),
|
||||
auth_type: AuthType::Oauth,
|
||||
provider: provider_name.to_string(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
config
|
||||
.get("access")
|
||||
.and_then(Value::as_str)
|
||||
.map(|token| ProviderCredentials {
|
||||
api_key: token.to_string(),
|
||||
source: "opencode".to_string(),
|
||||
auth_type: AuthType::Oauth,
|
||||
provider: provider_name.to_string(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(credentials) = credentials {
|
||||
if provider_name == "anthropic" {
|
||||
result.anthropic = Some(credentials.clone());
|
||||
} else if provider_name == "openai" {
|
||||
result.openai = Some(credentials.clone());
|
||||
} else {
|
||||
result.other.insert(provider_name.to_string(), credentials.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn extract_amp_credentials(options: &CredentialExtractionOptions) -> Option<ProviderCredentials> {
|
||||
let home_dir = options.home_dir.clone().unwrap_or_else(default_home_dir);
|
||||
let path = home_dir.join(".amp").join("config.json");
|
||||
let data = read_json_file(&path)?;
|
||||
|
||||
let key_paths: Vec<Vec<&str>> = vec![
|
||||
vec!["anthropicApiKey"],
|
||||
vec!["anthropic_api_key"],
|
||||
vec!["apiKey"],
|
||||
vec!["api_key"],
|
||||
vec!["accessToken"],
|
||||
vec!["access_token"],
|
||||
vec!["token"],
|
||||
vec!["auth", "anthropicApiKey"],
|
||||
vec!["auth", "apiKey"],
|
||||
vec!["auth", "token"],
|
||||
vec!["anthropic", "apiKey"],
|
||||
vec!["anthropic", "token"],
|
||||
];
|
||||
|
||||
for key_path in key_paths {
|
||||
if let Some(key) = read_string_field(&data, &key_path) {
|
||||
if !key.is_empty() {
|
||||
return Some(ProviderCredentials {
|
||||
api_key: key,
|
||||
source: "amp".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: "anthropic".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn extract_all_credentials(options: &CredentialExtractionOptions) -> ExtractedCredentials {
|
||||
let mut result = ExtractedCredentials::default();
|
||||
|
||||
if let Ok(value) = std::env::var("ANTHROPIC_API_KEY") {
|
||||
result.anthropic = Some(ProviderCredentials {
|
||||
api_key: value,
|
||||
source: "environment".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: "anthropic".to_string(),
|
||||
});
|
||||
} else if let Ok(value) = std::env::var("CLAUDE_API_KEY") {
|
||||
result.anthropic = Some(ProviderCredentials {
|
||||
api_key: value,
|
||||
source: "environment".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: "anthropic".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(value) = std::env::var("OPENAI_API_KEY") {
|
||||
result.openai = Some(ProviderCredentials {
|
||||
api_key: value,
|
||||
source: "environment".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: "openai".to_string(),
|
||||
});
|
||||
} else if let Ok(value) = std::env::var("CODEX_API_KEY") {
|
||||
result.openai = Some(ProviderCredentials {
|
||||
api_key: value,
|
||||
source: "environment".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: "openai".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if result.anthropic.is_none() {
|
||||
result.anthropic = extract_amp_credentials(options);
|
||||
}
|
||||
|
||||
if result.anthropic.is_none() {
|
||||
result.anthropic = extract_claude_credentials(options);
|
||||
}
|
||||
|
||||
if result.openai.is_none() {
|
||||
result.openai = extract_codex_credentials(options);
|
||||
}
|
||||
|
||||
let opencode_credentials = extract_opencode_credentials(options);
|
||||
if result.anthropic.is_none() {
|
||||
result.anthropic = opencode_credentials.anthropic.clone();
|
||||
}
|
||||
if result.openai.is_none() {
|
||||
result.openai = opencode_credentials.openai.clone();
|
||||
}
|
||||
|
||||
for (key, value) in opencode_credentials.other {
|
||||
result.other.entry(key).or_insert(value);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn get_anthropic_api_key(options: &CredentialExtractionOptions) -> Option<String> {
|
||||
extract_all_credentials(options)
|
||||
.anthropic
|
||||
.map(|cred| cred.api_key)
|
||||
}
|
||||
|
||||
pub fn get_openai_api_key(options: &CredentialExtractionOptions) -> Option<String> {
|
||||
extract_all_credentials(options)
|
||||
.openai
|
||||
.map(|cred| cred.api_key)
|
||||
}
|
||||
|
||||
pub fn set_credentials_as_env_vars(credentials: &ExtractedCredentials) {
|
||||
if let Some(cred) = &credentials.anthropic {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", &cred.api_key);
|
||||
}
|
||||
if let Some(cred) = &credentials.openai {
|
||||
std::env::set_var("OPENAI_API_KEY", &cred.api_key);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json_file(path: &Path) -> Option<Value> {
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
fn read_string_field(value: &Value, path: &[&str]) -> Option<String> {
|
||||
let mut current = value;
|
||||
for key in path {
|
||||
current = current.get(*key)?;
|
||||
}
|
||||
current.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn default_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn current_epoch_millis() -> i64 {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
(now.unix_timestamp() * 1000) + (now.millisecond() as i64)
|
||||
}
|
||||
|
||||
fn is_expired_rfc3339(value: &str) -> bool {
|
||||
match OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) {
|
||||
Ok(expiry) => expiry < OffsetDateTime::now_utc(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
20
server/packages/agent-management/Cargo.toml
Normal file
20
server/packages/agent-management/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "sandbox-agent-agent-management"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sandbox-agent-agent-credentials = { path = "../agent-credentials" }
|
||||
reqwest = { version = "0.11", features = ["blocking", "json", "rustls-tls"] }
|
||||
flate2 = "1.0"
|
||||
tar = "0.4"
|
||||
zip = { version = "0.6", default-features = false, features = ["deflate"] }
|
||||
url = "2.5"
|
||||
dirs = "5.0"
|
||||
tempfile = "3.10"
|
||||
time = { version = "0.3", features = ["parsing", "formatting"] }
|
||||
1001
server/packages/agent-management/src/agents.rs
Normal file
1001
server/packages/agent-management/src/agents.rs
Normal file
File diff suppressed because it is too large
Load diff
1
server/packages/agent-management/src/credentials.rs
Normal file
1
server/packages/agent-management/src/credentials.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub use sandbox_agent_agent_credentials::*;
|
||||
3
server/packages/agent-management/src/lib.rs
Normal file
3
server/packages/agent-management/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod agents;
|
||||
pub mod credentials;
|
||||
pub mod testing;
|
||||
127
server/packages/agent-management/src/testing.rs
Normal file
127
server/packages/agent-management/src/testing.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
use std::env;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::agents::AgentId;
|
||||
use crate::credentials::{AuthType, ExtractedCredentials, ProviderCredentials};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestAgentConfig {
|
||||
pub agent: AgentId,
|
||||
pub credentials: ExtractedCredentials,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TestAgentConfigError {
|
||||
#[error("no test agents configured (set SANDBOX_TEST_AGENTS)")]
|
||||
NoAgentsConfigured,
|
||||
#[error("unknown agent name: {0}")]
|
||||
UnknownAgent(String),
|
||||
#[error("missing credentials for {agent}: {missing}")]
|
||||
MissingCredentials { agent: AgentId, missing: String },
|
||||
}
|
||||
|
||||
const AGENTS_ENV: &str = "SANDBOX_TEST_AGENTS";
|
||||
const ANTHROPIC_ENV: &str = "SANDBOX_TEST_ANTHROPIC_API_KEY";
|
||||
const OPENAI_ENV: &str = "SANDBOX_TEST_OPENAI_API_KEY";
|
||||
|
||||
pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigError> {
|
||||
let raw_agents = env::var(AGENTS_ENV).unwrap_or_default();
|
||||
let mut agents = Vec::new();
|
||||
for entry in raw_agents.split(',') {
|
||||
let trimmed = entry.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if trimmed == "all" {
|
||||
agents.extend([
|
||||
AgentId::Claude,
|
||||
AgentId::Codex,
|
||||
AgentId::Opencode,
|
||||
AgentId::Amp,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
let agent = AgentId::parse(trimmed)
|
||||
.ok_or_else(|| TestAgentConfigError::UnknownAgent(trimmed.to_string()))?;
|
||||
agents.push(agent);
|
||||
}
|
||||
|
||||
if agents.is_empty() {
|
||||
return Err(TestAgentConfigError::NoAgentsConfigured);
|
||||
}
|
||||
|
||||
let anthropic_key = read_env_key(ANTHROPIC_ENV);
|
||||
let openai_key = read_env_key(OPENAI_ENV);
|
||||
|
||||
let mut configs = Vec::new();
|
||||
for agent in agents {
|
||||
let credentials = match agent {
|
||||
AgentId::Claude | AgentId::Amp => {
|
||||
let anthropic_key = anthropic_key.clone().ok_or_else(|| {
|
||||
TestAgentConfigError::MissingCredentials {
|
||||
agent,
|
||||
missing: ANTHROPIC_ENV.to_string(),
|
||||
}
|
||||
})?;
|
||||
credentials_with(anthropic_key, None)
|
||||
}
|
||||
AgentId::Codex => {
|
||||
let openai_key = openai_key.clone().ok_or_else(|| {
|
||||
TestAgentConfigError::MissingCredentials {
|
||||
agent,
|
||||
missing: OPENAI_ENV.to_string(),
|
||||
}
|
||||
})?;
|
||||
credentials_with(None, Some(openai_key))
|
||||
}
|
||||
AgentId::Opencode => {
|
||||
if anthropic_key.is_none() && openai_key.is_none() {
|
||||
return Err(TestAgentConfigError::MissingCredentials {
|
||||
agent,
|
||||
missing: format!("{ANTHROPIC_ENV} or {OPENAI_ENV}"),
|
||||
});
|
||||
}
|
||||
credentials_with(anthropic_key.clone(), openai_key.clone())
|
||||
}
|
||||
};
|
||||
configs.push(TestAgentConfig { agent, credentials });
|
||||
}
|
||||
|
||||
Ok(configs)
|
||||
}
|
||||
|
||||
fn read_env_key(name: &str) -> Option<String> {
|
||||
env::var(name).ok().and_then(|value| {
|
||||
let trimmed = value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn credentials_with(
|
||||
anthropic_key: Option<String>,
|
||||
openai_key: Option<String>,
|
||||
) -> ExtractedCredentials {
|
||||
let mut credentials = ExtractedCredentials::default();
|
||||
if let Some(key) = anthropic_key {
|
||||
credentials.anthropic = Some(ProviderCredentials {
|
||||
api_key: key,
|
||||
source: "sandbox-test-env".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: "anthropic".to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(key) = openai_key {
|
||||
credentials.openai = Some(ProviderCredentials {
|
||||
api_key: key,
|
||||
source: "sandbox-test-env".to_string(),
|
||||
auth_type: AuthType::ApiKey,
|
||||
provider: "openai".to_string(),
|
||||
});
|
||||
}
|
||||
credentials
|
||||
}
|
||||
18
server/packages/agent-schema/Cargo.toml
Normal file
18
server/packages/agent-schema/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "sandbox-agent-agent-schema"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regress = "0.10"
|
||||
|
||||
[build-dependencies]
|
||||
typify = "0.4"
|
||||
serde_json = "1.0"
|
||||
schemars = "0.8"
|
||||
prettyplease = "0.2"
|
||||
syn = "2.0"
|
||||
71
server/packages/agent-schema/build.rs
Normal file
71
server/packages/agent-schema/build.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let schema_dir = Path::new("../../../resources/agent-schemas/dist");
|
||||
|
||||
let schemas = [
|
||||
("opencode", "opencode.json"),
|
||||
("claude", "claude.json"),
|
||||
("codex", "codex.json"),
|
||||
("amp", "amp.json"),
|
||||
];
|
||||
|
||||
for (name, file) in schemas {
|
||||
let schema_path = schema_dir.join(file);
|
||||
|
||||
// Tell cargo to rerun if schema changes
|
||||
emit_stdout(&format!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
schema_path.display()
|
||||
));
|
||||
|
||||
if !schema_path.exists() {
|
||||
emit_stdout(&format!(
|
||||
"cargo:warning=Schema file not found: {}",
|
||||
schema_path.display()
|
||||
));
|
||||
// Write empty module
|
||||
let out_path = Path::new(&out_dir).join(format!("{}.rs", name));
|
||||
fs::write(&out_path, "// Schema not found\n").unwrap();
|
||||
continue;
|
||||
}
|
||||
|
||||
let schema_content = fs::read_to_string(&schema_path)
|
||||
.unwrap_or_else(|e| panic!("Failed to read {}: {}", schema_path.display(), e));
|
||||
|
||||
let schema: schemars::schema::RootSchema = serde_json::from_str(&schema_content)
|
||||
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", schema_path.display(), e));
|
||||
|
||||
let mut type_space = typify::TypeSpace::default();
|
||||
|
||||
type_space
|
||||
.add_root_schema(schema)
|
||||
.unwrap_or_else(|e| panic!("Failed to process {}: {}", schema_path.display(), e));
|
||||
|
||||
let contents = type_space.to_stream();
|
||||
|
||||
// Format the generated code
|
||||
let formatted = prettyplease::unparse(&syn::parse2(contents.clone()).unwrap_or_else(|e| {
|
||||
panic!("Failed to parse generated code for {}: {}", name, e)
|
||||
}));
|
||||
|
||||
let out_path = Path::new(&out_dir).join(format!("{}.rs", name));
|
||||
fs::write(&out_path, formatted)
|
||||
.unwrap_or_else(|e| panic!("Failed to write {}: {}", out_path.display(), e));
|
||||
|
||||
emit_stdout(&format!(
|
||||
"cargo:warning=Generated {} types from {}",
|
||||
name, file
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_stdout(message: &str) {
|
||||
let mut out = io::stdout();
|
||||
let _ = out.write_all(message.as_bytes());
|
||||
let _ = out.write_all(b"\n");
|
||||
let _ = out.flush();
|
||||
}
|
||||
76
server/packages/agent-schema/src/lib.rs
Normal file
76
server/packages/agent-schema/src/lib.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! Generated types from AI coding agent JSON schemas.
|
||||
//!
|
||||
//! This crate provides Rust types for:
|
||||
//! - OpenCode SDK
|
||||
//! - Claude Code SDK
|
||||
//! - Codex SDK
|
||||
//! - AMP Code SDK
|
||||
|
||||
pub mod opencode {
|
||||
//! OpenCode SDK types extracted from OpenAPI 3.1.1 spec.
|
||||
include!(concat!(env!("OUT_DIR"), "/opencode.rs"));
|
||||
}
|
||||
|
||||
pub mod claude {
|
||||
//! Claude Code SDK types extracted from TypeScript definitions.
|
||||
include!(concat!(env!("OUT_DIR"), "/claude.rs"));
|
||||
}
|
||||
|
||||
pub mod codex {
|
||||
//! Codex SDK types.
|
||||
include!(concat!(env!("OUT_DIR"), "/codex.rs"));
|
||||
}
|
||||
|
||||
pub mod amp {
|
||||
//! AMP Code SDK types.
|
||||
include!(concat!(env!("OUT_DIR"), "/amp.rs"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_claude_bash_input() {
|
||||
let input = claude::BashInput {
|
||||
command: "ls -la".to_string(),
|
||||
timeout: Some(5000.0),
|
||||
description: Some("List files".to_string()),
|
||||
run_in_background: None,
|
||||
simulated_sed_edit: None,
|
||||
dangerously_disable_sandbox: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&input).unwrap();
|
||||
assert!(json.contains("ls -la"));
|
||||
|
||||
let parsed: claude::BashInput = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.command, "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_thread_event() {
|
||||
let event = codex::ThreadEvent {
|
||||
type_: codex::ThreadEventType::ThreadCreated,
|
||||
thread_id: Some("thread-123".to_string()),
|
||||
item: None,
|
||||
error: serde_json::Map::new(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("thread.created"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amp_message() {
|
||||
let msg = amp::Message {
|
||||
role: amp::MessageRole::User,
|
||||
content: "Hello".to_string(),
|
||||
tool_calls: vec![],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert!(json.contains("user"));
|
||||
assert!(json.contains("Hello"));
|
||||
}
|
||||
}
|
||||
13
server/packages/error/Cargo.toml
Normal file
13
server/packages/error/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "sandbox-agent-error"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "1.0"
|
||||
schemars = "0.8"
|
||||
utoipa = "4.2"
|
||||
302
server/packages/error/src/lib.rs
Normal file
302
server/packages/error/src/lib.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use thiserror::Error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorType {
|
||||
InvalidRequest,
|
||||
UnsupportedAgent,
|
||||
AgentNotInstalled,
|
||||
InstallFailed,
|
||||
AgentProcessExited,
|
||||
TokenInvalid,
|
||||
PermissionDenied,
|
||||
SessionNotFound,
|
||||
SessionAlreadyExists,
|
||||
ModeNotSupported,
|
||||
StreamError,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
impl ErrorType {
|
||||
pub fn as_urn(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidRequest => "urn:sandbox-agent:error:invalid_request",
|
||||
Self::UnsupportedAgent => "urn:sandbox-agent:error:unsupported_agent",
|
||||
Self::AgentNotInstalled => "urn:sandbox-agent:error:agent_not_installed",
|
||||
Self::InstallFailed => "urn:sandbox-agent:error:install_failed",
|
||||
Self::AgentProcessExited => "urn:sandbox-agent:error:agent_process_exited",
|
||||
Self::TokenInvalid => "urn:sandbox-agent:error:token_invalid",
|
||||
Self::PermissionDenied => "urn:sandbox-agent:error:permission_denied",
|
||||
Self::SessionNotFound => "urn:sandbox-agent:error:session_not_found",
|
||||
Self::SessionAlreadyExists => "urn:sandbox-agent:error:session_already_exists",
|
||||
Self::ModeNotSupported => "urn:sandbox-agent:error:mode_not_supported",
|
||||
Self::StreamError => "urn:sandbox-agent:error:stream_error",
|
||||
Self::Timeout => "urn:sandbox-agent:error:timeout",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidRequest => "Invalid Request",
|
||||
Self::UnsupportedAgent => "Unsupported Agent",
|
||||
Self::AgentNotInstalled => "Agent Not Installed",
|
||||
Self::InstallFailed => "Install Failed",
|
||||
Self::AgentProcessExited => "Agent Process Exited",
|
||||
Self::TokenInvalid => "Token Invalid",
|
||||
Self::PermissionDenied => "Permission Denied",
|
||||
Self::SessionNotFound => "Session Not Found",
|
||||
Self::SessionAlreadyExists => "Session Already Exists",
|
||||
Self::ModeNotSupported => "Mode Not Supported",
|
||||
Self::StreamError => "Stream Error",
|
||||
Self::Timeout => "Timeout",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_code(&self) -> u16 {
|
||||
match self {
|
||||
Self::InvalidRequest => 400,
|
||||
Self::UnsupportedAgent => 400,
|
||||
Self::AgentNotInstalled => 404,
|
||||
Self::InstallFailed => 500,
|
||||
Self::AgentProcessExited => 500,
|
||||
Self::TokenInvalid => 401,
|
||||
Self::PermissionDenied => 403,
|
||||
Self::SessionNotFound => 404,
|
||||
Self::SessionAlreadyExists => 409,
|
||||
Self::ModeNotSupported => 400,
|
||||
Self::StreamError => 502,
|
||||
Self::Timeout => 504,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
pub struct ProblemDetails {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub title: String,
|
||||
pub status: u16,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance: Option<String>,
|
||||
#[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
|
||||
pub extensions: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl ProblemDetails {
|
||||
pub fn new(error_type: ErrorType, detail: Option<String>) -> Self {
|
||||
Self {
|
||||
type_: error_type.as_urn().to_string(),
|
||||
title: error_type.title().to_string(),
|
||||
status: error_type.status_code(),
|
||||
detail,
|
||||
instance: None,
|
||||
extensions: Map::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
pub struct AgentError {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: ErrorType,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SandboxError {
|
||||
#[error("invalid request: {message}")]
|
||||
InvalidRequest { message: String },
|
||||
#[error("unsupported agent: {agent}")]
|
||||
UnsupportedAgent { agent: String },
|
||||
#[error("agent not installed: {agent}")]
|
||||
AgentNotInstalled { agent: String },
|
||||
#[error("install failed: {agent}")]
|
||||
InstallFailed { agent: String, stderr: Option<String> },
|
||||
#[error("agent process exited: {agent}")]
|
||||
AgentProcessExited {
|
||||
agent: String,
|
||||
exit_code: Option<i32>,
|
||||
stderr: Option<String>,
|
||||
},
|
||||
#[error("token invalid")]
|
||||
TokenInvalid { message: Option<String> },
|
||||
#[error("permission denied")]
|
||||
PermissionDenied { message: Option<String> },
|
||||
#[error("session not found: {session_id}")]
|
||||
SessionNotFound { session_id: String },
|
||||
#[error("session already exists: {session_id}")]
|
||||
SessionAlreadyExists { session_id: String },
|
||||
#[error("mode not supported: {agent} {mode}")]
|
||||
ModeNotSupported { agent: String, mode: String },
|
||||
#[error("stream error: {message}")]
|
||||
StreamError { message: String },
|
||||
#[error("timeout")]
|
||||
Timeout { message: Option<String> },
|
||||
}
|
||||
|
||||
impl SandboxError {
|
||||
pub fn error_type(&self) -> ErrorType {
|
||||
match self {
|
||||
Self::InvalidRequest { .. } => ErrorType::InvalidRequest,
|
||||
Self::UnsupportedAgent { .. } => ErrorType::UnsupportedAgent,
|
||||
Self::AgentNotInstalled { .. } => ErrorType::AgentNotInstalled,
|
||||
Self::InstallFailed { .. } => ErrorType::InstallFailed,
|
||||
Self::AgentProcessExited { .. } => ErrorType::AgentProcessExited,
|
||||
Self::TokenInvalid { .. } => ErrorType::TokenInvalid,
|
||||
Self::PermissionDenied { .. } => ErrorType::PermissionDenied,
|
||||
Self::SessionNotFound { .. } => ErrorType::SessionNotFound,
|
||||
Self::SessionAlreadyExists { .. } => ErrorType::SessionAlreadyExists,
|
||||
Self::ModeNotSupported { .. } => ErrorType::ModeNotSupported,
|
||||
Self::StreamError { .. } => ErrorType::StreamError,
|
||||
Self::Timeout { .. } => ErrorType::Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_agent_error(&self) -> AgentError {
|
||||
let (agent, session_id, details) = match self {
|
||||
Self::InvalidRequest { .. } => (None, None, None),
|
||||
Self::UnsupportedAgent { agent } => {
|
||||
(Some(agent.clone()), None, None)
|
||||
}
|
||||
Self::AgentNotInstalled { agent } => (Some(agent.clone()), None, None),
|
||||
Self::InstallFailed { agent, stderr } => {
|
||||
let mut map = Map::new();
|
||||
if let Some(stderr) = stderr {
|
||||
map.insert("stderr".to_string(), Value::String(stderr.clone()));
|
||||
}
|
||||
(
|
||||
Some(agent.clone()),
|
||||
None,
|
||||
if map.is_empty() { None } else { Some(Value::Object(map)) },
|
||||
)
|
||||
}
|
||||
Self::AgentProcessExited {
|
||||
agent,
|
||||
exit_code,
|
||||
stderr,
|
||||
} => {
|
||||
let mut map = Map::new();
|
||||
if let Some(code) = exit_code {
|
||||
map.insert(
|
||||
"exitCode".to_string(),
|
||||
Value::Number(serde_json::Number::from(*code as i64)),
|
||||
);
|
||||
}
|
||||
if let Some(stderr) = stderr {
|
||||
map.insert("stderr".to_string(), Value::String(stderr.clone()));
|
||||
}
|
||||
(
|
||||
Some(agent.clone()),
|
||||
None,
|
||||
if map.is_empty() { None } else { Some(Value::Object(map)) },
|
||||
)
|
||||
}
|
||||
Self::TokenInvalid { message } => {
|
||||
let details = message.as_ref().map(|msg| {
|
||||
let mut map = Map::new();
|
||||
map.insert("message".to_string(), Value::String(msg.clone()));
|
||||
Value::Object(map)
|
||||
});
|
||||
(None, None, details)
|
||||
}
|
||||
Self::PermissionDenied { message } => {
|
||||
let details = message.as_ref().map(|msg| {
|
||||
let mut map = Map::new();
|
||||
map.insert("message".to_string(), Value::String(msg.clone()));
|
||||
Value::Object(map)
|
||||
});
|
||||
(None, None, details)
|
||||
}
|
||||
Self::SessionNotFound { session_id } => {
|
||||
(None, Some(session_id.clone()), None)
|
||||
}
|
||||
Self::SessionAlreadyExists { session_id } => {
|
||||
(None, Some(session_id.clone()), None)
|
||||
}
|
||||
Self::ModeNotSupported { agent, mode } => {
|
||||
let mut map = Map::new();
|
||||
map.insert("mode".to_string(), Value::String(mode.clone()));
|
||||
(
|
||||
Some(agent.clone()),
|
||||
None,
|
||||
Some(Value::Object(map)),
|
||||
)
|
||||
}
|
||||
Self::StreamError { message } => {
|
||||
let mut map = Map::new();
|
||||
map.insert("message".to_string(), Value::String(message.clone()));
|
||||
(None, None, Some(Value::Object(map)))
|
||||
}
|
||||
Self::Timeout { message } => {
|
||||
let details = message.as_ref().map(|msg| {
|
||||
let mut map = Map::new();
|
||||
map.insert("message".to_string(), Value::String(msg.clone()));
|
||||
Value::Object(map)
|
||||
});
|
||||
(None, None, details)
|
||||
}
|
||||
};
|
||||
|
||||
AgentError {
|
||||
type_: self.error_type(),
|
||||
message: self.to_string(),
|
||||
agent,
|
||||
session_id,
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_problem_details(&self) -> ProblemDetails {
|
||||
let mut problem = ProblemDetails::new(self.error_type(), Some(self.to_string()));
|
||||
let agent_error = self.to_agent_error();
|
||||
|
||||
let mut extensions = Map::new();
|
||||
if let Some(agent) = agent_error.agent {
|
||||
extensions.insert("agent".to_string(), Value::String(agent));
|
||||
}
|
||||
if let Some(session_id) = agent_error.session_id {
|
||||
extensions.insert("sessionId".to_string(), Value::String(session_id));
|
||||
}
|
||||
if let Some(details) = agent_error.details {
|
||||
extensions.insert("details".to_string(), details);
|
||||
}
|
||||
problem.extensions = extensions;
|
||||
problem
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SandboxError> for ProblemDetails {
|
||||
fn from(value: SandboxError) -> Self {
|
||||
value.to_problem_details()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SandboxError> for ProblemDetails {
|
||||
fn from(value: &SandboxError) -> Self {
|
||||
value.to_problem_details()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SandboxError> for AgentError {
|
||||
fn from(value: SandboxError) -> Self {
|
||||
value.to_agent_error()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SandboxError> for AgentError {
|
||||
fn from(value: &SandboxError) -> Self {
|
||||
value.to_agent_error()
|
||||
}
|
||||
}
|
||||
17
server/packages/openapi-gen/Cargo.toml
Normal file
17
server/packages/openapi-gen/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "sandbox-agent-openapi-gen"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1"
|
||||
tracing-logfmt = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[build-dependencies]
|
||||
sandbox-agent-core = { path = "../sandbox-agent" }
|
||||
serde_json = "1.0"
|
||||
utoipa = "4.2"
|
||||
31
server/packages/openapi-gen/build.rs
Normal file
31
server/packages/openapi-gen/build.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use sandbox_agent_core::router::ApiDoc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
fn main() {
|
||||
emit_stdout("cargo:rerun-if-changed=../sandbox-agent/src/router.rs");
|
||||
emit_stdout("cargo:rerun-if-changed=../sandbox-agent/src/lib.rs");
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
|
||||
let out_path = Path::new(&out_dir).join("openapi.json");
|
||||
|
||||
let openapi = ApiDoc::openapi();
|
||||
let json = serde_json::to_string_pretty(&openapi)
|
||||
.expect("Failed to serialize OpenAPI spec");
|
||||
|
||||
fs::write(&out_path, json).expect("Failed to write OpenAPI spec");
|
||||
emit_stdout(&format!(
|
||||
"cargo:warning=Generated OpenAPI spec at {}",
|
||||
out_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
fn emit_stdout(message: &str) {
|
||||
let mut out = io::stdout();
|
||||
let _ = out.write_all(message.as_bytes());
|
||||
let _ = out.write_all(b"\n");
|
||||
let _ = out.flush();
|
||||
}
|
||||
3
server/packages/openapi-gen/src/lib.rs
Normal file
3
server/packages/openapi-gen/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! Generated OpenAPI schema output.
|
||||
|
||||
pub const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));
|
||||
59
server/packages/openapi-gen/src/main.rs
Normal file
59
server/packages/openapi-gen/src/main.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
fn main() {
|
||||
init_logging();
|
||||
let mut out: Option<PathBuf> = None;
|
||||
let mut stdout = false;
|
||||
let mut args = env::args().skip(1).peekable();
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--stdout" {
|
||||
stdout = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--out" {
|
||||
if let Some(value) = args.next() {
|
||||
out = Some(PathBuf::from(value));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = arg.strip_prefix("--out=") {
|
||||
out = Some(PathBuf::from(value));
|
||||
continue;
|
||||
}
|
||||
if out.is_none() {
|
||||
out = Some(PathBuf::from(arg));
|
||||
}
|
||||
}
|
||||
|
||||
let schema = sandbox_agent_openapi_gen::OPENAPI_JSON;
|
||||
if stdout {
|
||||
write_stdout(schema);
|
||||
return;
|
||||
}
|
||||
|
||||
let out = out.unwrap_or_else(|| PathBuf::from("openapi.json"));
|
||||
if let Err(err) = fs::write(&out, schema) {
|
||||
tracing::error!(path = %out.display(), error = %err, "failed to write openapi schema");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_logfmt::builder().layer().with_writer(std::io::stderr))
|
||||
.init();
|
||||
}
|
||||
|
||||
fn write_stdout(text: &str) {
|
||||
let mut out = std::io::stdout();
|
||||
let _ = out.write_all(text.as_bytes());
|
||||
let _ = out.write_all(b"\n");
|
||||
let _ = out.flush();
|
||||
}
|
||||
39
server/packages/sandbox-agent/Cargo.toml
Normal file
39
server/packages/sandbox-agent/Cargo.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[package]
|
||||
name = "sandbox-agent-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "sandbox-agent"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
axum = "0.7"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
futures = "0.3"
|
||||
sandbox-agent-error = { path = "../error" }
|
||||
sandbox-agent-agent-management = { path = "../agent-management" }
|
||||
sandbox-agent-agent-credentials = { path = "../agent-credentials" }
|
||||
sandbox-agent-universal-agent-schema = { path = "../universal-agent-schema" }
|
||||
reqwest = { version = "0.11", features = ["blocking", "json", "rustls-tls", "stream"] }
|
||||
dirs = "5.0"
|
||||
time = { version = "0.3", features = ["parsing", "formatting"] }
|
||||
tokio = { version = "1.36", features = ["macros", "rt-multi-thread", "signal", "time"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||
utoipa = { version = "4.2", features = ["axum_extras"] }
|
||||
schemars = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-logfmt = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1"
|
||||
insta = "1.41"
|
||||
tempfile = "3.10"
|
||||
tower = "0.4"
|
||||
1
server/packages/sandbox-agent/src/credentials.rs
Normal file
1
server/packages/sandbox-agent/src/credentials.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub use sandbox_agent_agent_credentials::*;
|
||||
4
server/packages/sandbox-agent/src/lib.rs
Normal file
4
server/packages/sandbox-agent/src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
//! Sandbox daemon core utilities.
|
||||
|
||||
pub mod credentials;
|
||||
pub mod router;
|
||||
856
server/packages/sandbox-agent/src/main.rs
Normal file
856
server/packages/sandbox-agent/src/main.rs
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use reqwest::blocking::Client as HttpClient;
|
||||
use reqwest::Method;
|
||||
use sandbox_agent_agent_management::agents::AgentManager;
|
||||
use sandbox_agent_agent_management::credentials::{
|
||||
extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials,
|
||||
ProviderCredentials,
|
||||
};
|
||||
use sandbox_agent_core::router::{
|
||||
AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest,
|
||||
PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
|
||||
};
|
||||
use sandbox_agent_core::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse};
|
||||
use sandbox_agent_core::router::build_router;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
const API_PREFIX: &str = "/v1";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "sandbox-agent")]
|
||||
#[command(about = "Sandbox agent for managing coding agents", version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
|
||||
#[arg(long, short = 'H', default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
|
||||
#[arg(long, short = 'p', default_value_t = 2468)]
|
||||
port: u16,
|
||||
|
||||
#[arg(long, short = 't')]
|
||||
token: Option<String>,
|
||||
|
||||
#[arg(long, short = 'n')]
|
||||
no_token: bool,
|
||||
|
||||
#[arg(long = "cors-allow-origin", short = 'O')]
|
||||
cors_allow_origin: Vec<String>,
|
||||
|
||||
#[arg(long = "cors-allow-method", short = 'M')]
|
||||
cors_allow_method: Vec<String>,
|
||||
|
||||
#[arg(long = "cors-allow-header", short = 'A')]
|
||||
cors_allow_header: Vec<String>,
|
||||
|
||||
#[arg(long = "cors-allow-credentials", short = 'C')]
|
||||
cors_allow_credentials: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
/// Manage installed agents and their modes.
|
||||
Agents(AgentsArgs),
|
||||
/// Create sessions and interact with session events.
|
||||
Sessions(SessionsArgs),
|
||||
/// Inspect locally discovered credentials.
|
||||
Credentials(CredentialsArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct AgentsArgs {
|
||||
#[command(subcommand)]
|
||||
command: AgentsCommand,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SessionsArgs {
|
||||
#[command(subcommand)]
|
||||
command: SessionsCommand,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct CredentialsArgs {
|
||||
#[command(subcommand)]
|
||||
command: CredentialsCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum AgentsCommand {
|
||||
/// List all agents and install status.
|
||||
List(ClientArgs),
|
||||
/// Install or reinstall an agent.
|
||||
Install(InstallAgentArgs),
|
||||
/// Show available modes for an agent.
|
||||
Modes(AgentModesArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum CredentialsCommand {
|
||||
/// Extract credentials using local discovery rules.
|
||||
Extract(CredentialsExtractArgs),
|
||||
/// Output credentials as environment variable assignments.
|
||||
#[command(name = "extract-env")]
|
||||
ExtractEnv(CredentialsExtractEnvArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SessionsCommand {
|
||||
/// Create a new session for an agent.
|
||||
Create(CreateSessionArgs),
|
||||
#[command(name = "send-message")]
|
||||
/// Send a message to an existing session.
|
||||
SendMessage(SessionMessageArgs),
|
||||
#[command(name = "get-messages")]
|
||||
/// Alias for events; returns session events.
|
||||
GetMessages(SessionEventsArgs),
|
||||
#[command(name = "events")]
|
||||
/// Fetch session events with offset/limit.
|
||||
Events(SessionEventsArgs),
|
||||
#[command(name = "events-sse")]
|
||||
/// Stream session events over SSE.
|
||||
EventsSse(SessionEventsSseArgs),
|
||||
#[command(name = "reply-question")]
|
||||
/// Reply to a question event.
|
||||
ReplyQuestion(QuestionReplyArgs),
|
||||
#[command(name = "reject-question")]
|
||||
/// Reject a question event.
|
||||
RejectQuestion(QuestionRejectArgs),
|
||||
#[command(name = "reply-permission")]
|
||||
/// Reply to a permission request.
|
||||
ReplyPermission(PermissionReplyArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
struct ClientArgs {
|
||||
#[arg(long, short = 'e')]
|
||||
endpoint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct InstallAgentArgs {
|
||||
agent: String,
|
||||
#[arg(long, short = 'r')]
|
||||
reinstall: bool,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct AgentModesArgs {
|
||||
agent: String,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct CreateSessionArgs {
|
||||
session_id: String,
|
||||
#[arg(long, short = 'a')]
|
||||
agent: String,
|
||||
#[arg(long, short = 'g')]
|
||||
agent_mode: Option<String>,
|
||||
#[arg(long, short = 'p')]
|
||||
permission_mode: Option<String>,
|
||||
#[arg(long, short = 'm')]
|
||||
model: Option<String>,
|
||||
#[arg(long, short = 'v')]
|
||||
variant: Option<String>,
|
||||
#[arg(long, short = 'A')]
|
||||
agent_version: Option<String>,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SessionMessageArgs {
|
||||
session_id: String,
|
||||
#[arg(long, short = 'm')]
|
||||
message: String,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SessionEventsArgs {
|
||||
session_id: String,
|
||||
#[arg(long, short = 'o')]
|
||||
offset: Option<u64>,
|
||||
#[arg(long, short = 'l')]
|
||||
limit: Option<u64>,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SessionEventsSseArgs {
|
||||
session_id: String,
|
||||
#[arg(long, short = 'o')]
|
||||
offset: Option<u64>,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct QuestionReplyArgs {
|
||||
session_id: String,
|
||||
question_id: String,
|
||||
#[arg(long, short = 'a')]
|
||||
answers: String,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct QuestionRejectArgs {
|
||||
session_id: String,
|
||||
question_id: String,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct PermissionReplyArgs {
|
||||
session_id: String,
|
||||
permission_id: String,
|
||||
#[arg(long, short = 'r')]
|
||||
reply: PermissionReply,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct CredentialsExtractArgs {
|
||||
#[arg(long, short = 'a', value_enum)]
|
||||
agent: Option<CredentialAgent>,
|
||||
#[arg(long, short = 'p')]
|
||||
provider: Option<String>,
|
||||
#[arg(long, short = 'd')]
|
||||
home_dir: Option<PathBuf>,
|
||||
#[arg(long, short = 'n')]
|
||||
no_oauth: bool,
|
||||
#[arg(long, short = 'r')]
|
||||
reveal: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct CredentialsExtractEnvArgs {
|
||||
/// Prefix each line with "export " for shell sourcing.
|
||||
#[arg(long, short = 'e')]
|
||||
export: bool,
|
||||
#[arg(long, short = 'd')]
|
||||
home_dir: Option<PathBuf>,
|
||||
#[arg(long, short = 'n')]
|
||||
no_oauth: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum CliError {
|
||||
#[error("missing --token or --no-token for server mode")]
|
||||
MissingToken,
|
||||
#[error("invalid cors origin: {0}")]
|
||||
InvalidCorsOrigin(String),
|
||||
#[error("invalid cors method: {0}")]
|
||||
InvalidCorsMethod(String),
|
||||
#[error("invalid cors header: {0}")]
|
||||
InvalidCorsHeader(String),
|
||||
#[error("http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("server error: {0}")]
|
||||
Server(String),
|
||||
#[error("unexpected http status: {0}")]
|
||||
HttpStatus(reqwest::StatusCode),
|
||||
}
|
||||
|
||||
fn main() {
|
||||
init_logging();
|
||||
let cli = Cli::parse();
|
||||
|
||||
let result = match &cli.command {
|
||||
Some(command) => run_client(command, &cli),
|
||||
None => run_server(&cli),
|
||||
};
|
||||
|
||||
if let Err(err) = result {
|
||||
tracing::error!(error = %err, "sandbox-agent failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_logfmt::builder().layer().with_writer(std::io::stderr))
|
||||
.init();
|
||||
}
|
||||
|
||||
fn run_server(cli: &Cli) -> Result<(), CliError> {
|
||||
let auth = if cli.no_token {
|
||||
AuthConfig::disabled()
|
||||
} else if let Some(token) = cli.token.clone() {
|
||||
AuthConfig::with_token(token)
|
||||
} else {
|
||||
return Err(CliError::MissingToken);
|
||||
};
|
||||
|
||||
let agent_manager =
|
||||
AgentManager::new(default_install_dir()).map_err(|err| CliError::Server(err.to_string()))?;
|
||||
let state = AppState::new(auth, agent_manager);
|
||||
let mut router = build_router(state);
|
||||
|
||||
if let Some(cors) = build_cors_layer(cli)? {
|
||||
router = router.layer(cors);
|
||||
}
|
||||
|
||||
let addr = format!("{}:{}", cli.host, cli.port);
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|err| CliError::Server(err.to_string()))?;
|
||||
|
||||
runtime.block_on(async move {
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
tracing::info!(addr = %addr, "server listening");
|
||||
axum::serve(listener, router)
|
||||
.await
|
||||
.map_err(|err| CliError::Server(err.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
fn default_install_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.map(|dir| dir.join("sandbox-agent").join("bin"))
|
||||
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin"))
|
||||
}
|
||||
|
||||
fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> {
|
||||
match command {
|
||||
Command::Agents(subcommand) => run_agents(&subcommand.command, cli),
|
||||
Command::Sessions(subcommand) => run_sessions(&subcommand.command, cli),
|
||||
Command::Credentials(subcommand) => run_credentials(&subcommand.command),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> {
|
||||
match command {
|
||||
AgentsCommand::List(args) => {
|
||||
let ctx = ClientContext::new(cli, args)?;
|
||||
let response = ctx.get(&format!("{API_PREFIX}/agents"))?;
|
||||
print_json_response::<AgentListResponse>(response)
|
||||
}
|
||||
AgentsCommand::Install(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let body = AgentInstallRequest {
|
||||
reinstall: if args.reinstall { Some(true) } else { None },
|
||||
};
|
||||
let path = format!("{API_PREFIX}/agents/{}/install", args.agent);
|
||||
let response = ctx.post(&path, &body)?;
|
||||
print_empty_response(response)
|
||||
}
|
||||
AgentsCommand::Modes(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let path = format!("{API_PREFIX}/agents/{}/modes", args.agent);
|
||||
let response = ctx.get(&path)?;
|
||||
print_json_response::<AgentModesResponse>(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> {
|
||||
match command {
|
||||
SessionsCommand::Create(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let body = CreateSessionRequest {
|
||||
agent: args.agent.clone(),
|
||||
agent_mode: args.agent_mode.clone(),
|
||||
permission_mode: args.permission_mode.clone(),
|
||||
model: args.model.clone(),
|
||||
variant: args.variant.clone(),
|
||||
agent_version: args.agent_version.clone(),
|
||||
};
|
||||
let path = format!("{API_PREFIX}/sessions/{}", args.session_id);
|
||||
let response = ctx.post(&path, &body)?;
|
||||
print_json_response::<CreateSessionResponse>(response)
|
||||
}
|
||||
SessionsCommand::SendMessage(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let body = MessageRequest {
|
||||
message: args.message.clone(),
|
||||
};
|
||||
let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id);
|
||||
let response = ctx.post(&path, &body)?;
|
||||
print_empty_response(response)
|
||||
}
|
||||
SessionsCommand::GetMessages(args) | SessionsCommand::Events(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let path = format!("{API_PREFIX}/sessions/{}/events", args.session_id);
|
||||
let response = ctx.get_with_query(&path, &[ ("offset", args.offset), ("limit", args.limit) ])?;
|
||||
print_json_response::<EventsResponse>(response)
|
||||
}
|
||||
SessionsCommand::EventsSse(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let path = format!("{API_PREFIX}/sessions/{}/events/sse", args.session_id);
|
||||
let response = ctx.get_with_query(&path, &[("offset", args.offset)])?;
|
||||
print_text_response(response)
|
||||
}
|
||||
SessionsCommand::ReplyQuestion(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let answers: Vec<Vec<String>> = serde_json::from_str(&args.answers)?;
|
||||
let body = QuestionReplyRequest { answers };
|
||||
let path = format!(
|
||||
"{API_PREFIX}/sessions/{}/questions/{}/reply",
|
||||
args.session_id, args.question_id
|
||||
);
|
||||
let response = ctx.post(&path, &body)?;
|
||||
print_empty_response(response)
|
||||
}
|
||||
SessionsCommand::RejectQuestion(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let path = format!(
|
||||
"{API_PREFIX}/sessions/{}/questions/{}/reject",
|
||||
args.session_id, args.question_id
|
||||
);
|
||||
let response = ctx.post_empty(&path)?;
|
||||
print_empty_response(response)
|
||||
}
|
||||
SessionsCommand::ReplyPermission(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let body = PermissionReplyRequest {
|
||||
reply: args.reply.clone(),
|
||||
};
|
||||
let path = format!(
|
||||
"{API_PREFIX}/sessions/{}/permissions/{}/reply",
|
||||
args.session_id, args.permission_id
|
||||
);
|
||||
let response = ctx.post(&path, &body)?;
|
||||
print_empty_response(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> {
|
||||
match command {
|
||||
CredentialsCommand::Extract(args) => {
|
||||
let mut options = CredentialExtractionOptions::new();
|
||||
if let Some(home_dir) = args.home_dir.clone() {
|
||||
options.home_dir = Some(home_dir);
|
||||
}
|
||||
if args.no_oauth {
|
||||
options.include_oauth = false;
|
||||
}
|
||||
|
||||
let credentials = extract_all_credentials(&options);
|
||||
if let Some(agent) = args.agent.clone() {
|
||||
let token = select_token_for_agent(&credentials, agent, args.provider.as_deref())?;
|
||||
write_stdout_line(&token)?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(provider) = args.provider.as_deref() {
|
||||
let token = select_token_for_provider(&credentials, provider)?;
|
||||
write_stdout_line(&token)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = credentials_to_output(credentials, args.reveal);
|
||||
let pretty = serde_json::to_string_pretty(&output)?;
|
||||
write_stdout_line(&pretty)?;
|
||||
Ok(())
|
||||
}
|
||||
CredentialsCommand::ExtractEnv(args) => {
|
||||
let mut options = CredentialExtractionOptions::new();
|
||||
if let Some(home_dir) = args.home_dir.clone() {
|
||||
options.home_dir = Some(home_dir);
|
||||
}
|
||||
if args.no_oauth {
|
||||
options.include_oauth = false;
|
||||
}
|
||||
|
||||
let credentials = extract_all_credentials(&options);
|
||||
let prefix = if args.export { "export " } else { "" };
|
||||
|
||||
if let Some(cred) = &credentials.anthropic {
|
||||
write_stdout_line(&format!("{}ANTHROPIC_API_KEY={}", prefix, cred.api_key))?;
|
||||
write_stdout_line(&format!("{}CLAUDE_API_KEY={}", prefix, cred.api_key))?;
|
||||
}
|
||||
if let Some(cred) = &credentials.openai {
|
||||
write_stdout_line(&format!("{}OPENAI_API_KEY={}", prefix, cred.api_key))?;
|
||||
write_stdout_line(&format!("{}CODEX_API_KEY={}", prefix, cred.api_key))?;
|
||||
}
|
||||
for (provider, cred) in &credentials.other {
|
||||
let var_name = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
|
||||
write_stdout_line(&format!("{}{}={}", prefix, var_name, cred.api_key))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CredentialsOutput {
|
||||
anthropic: Option<CredentialSummary>,
|
||||
openai: Option<CredentialSummary>,
|
||||
other: HashMap<String, CredentialSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CredentialSummary {
|
||||
provider: String,
|
||||
source: String,
|
||||
auth_type: String,
|
||||
api_key: String,
|
||||
redacted: bool,
|
||||
}
|
||||
|
||||
#[derive(clap::ValueEnum, Clone, Debug)]
|
||||
enum CredentialAgent {
|
||||
Claude,
|
||||
Codex,
|
||||
Opencode,
|
||||
Amp,
|
||||
}
|
||||
|
||||
fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput {
|
||||
CredentialsOutput {
|
||||
anthropic: credentials.anthropic.map(|cred| summarize_credential(&cred, reveal)),
|
||||
openai: credentials.openai.map(|cred| summarize_credential(&cred, reveal)),
|
||||
other: credentials
|
||||
.other
|
||||
.into_iter()
|
||||
.map(|(key, cred)| (key, summarize_credential(&cred, reveal)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_credential(credential: &ProviderCredentials, reveal: bool) -> CredentialSummary {
|
||||
let api_key = if reveal {
|
||||
credential.api_key.clone()
|
||||
} else {
|
||||
redact_key(&credential.api_key)
|
||||
};
|
||||
CredentialSummary {
|
||||
provider: credential.provider.clone(),
|
||||
source: credential.source.clone(),
|
||||
auth_type: match credential.auth_type {
|
||||
AuthType::ApiKey => "api_key".to_string(),
|
||||
AuthType::Oauth => "oauth".to_string(),
|
||||
},
|
||||
api_key,
|
||||
redacted: !reveal,
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_key(key: &str) -> String {
|
||||
let trimmed = key.trim();
|
||||
let len = trimmed.len();
|
||||
if len <= 8 {
|
||||
return "****".to_string();
|
||||
}
|
||||
let prefix = &trimmed[..4];
|
||||
let suffix = &trimmed[len - 4..];
|
||||
format!("{prefix}...{suffix}")
|
||||
}
|
||||
|
||||
fn select_token_for_agent(
|
||||
credentials: &ExtractedCredentials,
|
||||
agent: CredentialAgent,
|
||||
provider: Option<&str>,
|
||||
) -> Result<String, CliError> {
|
||||
match agent {
|
||||
CredentialAgent::Claude | CredentialAgent::Amp => {
|
||||
if let Some(provider) = provider {
|
||||
if provider != "anthropic" {
|
||||
return Err(CliError::Server(format!(
|
||||
"agent {:?} only supports provider anthropic",
|
||||
agent
|
||||
)));
|
||||
}
|
||||
}
|
||||
select_token_for_provider(credentials, "anthropic")
|
||||
}
|
||||
CredentialAgent::Codex => {
|
||||
if let Some(provider) = provider {
|
||||
if provider != "openai" {
|
||||
return Err(CliError::Server(format!(
|
||||
"agent {:?} only supports provider openai",
|
||||
agent
|
||||
)));
|
||||
}
|
||||
}
|
||||
select_token_for_provider(credentials, "openai")
|
||||
}
|
||||
CredentialAgent::Opencode => {
|
||||
if let Some(provider) = provider {
|
||||
return select_token_for_provider(credentials, provider);
|
||||
}
|
||||
if let Some(openai) = credentials.openai.as_ref() {
|
||||
return Ok(openai.api_key.clone());
|
||||
}
|
||||
if let Some(anthropic) = credentials.anthropic.as_ref() {
|
||||
return Ok(anthropic.api_key.clone());
|
||||
}
|
||||
if credentials.other.len() == 1 {
|
||||
if let Some((_, cred)) = credentials.other.iter().next() {
|
||||
return Ok(cred.api_key.clone());
|
||||
}
|
||||
}
|
||||
let available = available_providers(credentials);
|
||||
if available.is_empty() {
|
||||
Err(CliError::Server(
|
||||
"no credentials found for opencode".to_string(),
|
||||
))
|
||||
} else {
|
||||
Err(CliError::Server(format!(
|
||||
"multiple providers available for opencode: {} (use --provider)",
|
||||
available.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_token_for_provider(
|
||||
credentials: &ExtractedCredentials,
|
||||
provider: &str,
|
||||
) -> Result<String, CliError> {
|
||||
if let Some(cred) = provider_credential(credentials, provider) {
|
||||
Ok(cred.api_key.clone())
|
||||
} else {
|
||||
Err(CliError::Server(format!(
|
||||
"no credentials found for provider {provider}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_credential<'a>(
|
||||
credentials: &'a ExtractedCredentials,
|
||||
provider: &str,
|
||||
) -> Option<&'a ProviderCredentials> {
|
||||
match provider {
|
||||
"openai" => credentials.openai.as_ref(),
|
||||
"anthropic" => credentials.anthropic.as_ref(),
|
||||
_ => credentials.other.get(provider),
|
||||
}
|
||||
}
|
||||
|
||||
fn available_providers(credentials: &ExtractedCredentials) -> Vec<String> {
|
||||
let mut providers = Vec::new();
|
||||
if credentials.openai.is_some() {
|
||||
providers.push("openai".to_string());
|
||||
}
|
||||
if credentials.anthropic.is_some() {
|
||||
providers.push("anthropic".to_string());
|
||||
}
|
||||
for key in credentials.other.keys() {
|
||||
providers.push(key.clone());
|
||||
}
|
||||
providers.sort();
|
||||
providers.dedup();
|
||||
providers
|
||||
}
|
||||
|
||||
fn build_cors_layer(cli: &Cli) -> Result<Option<CorsLayer>, CliError> {
|
||||
let has_config = !cli.cors_allow_origin.is_empty()
|
||||
|| !cli.cors_allow_method.is_empty()
|
||||
|| !cli.cors_allow_header.is_empty()
|
||||
|| cli.cors_allow_credentials;
|
||||
|
||||
if !has_config {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut cors = CorsLayer::new();
|
||||
|
||||
if cli.cors_allow_origin.is_empty() {
|
||||
cors = cors.allow_origin(Any);
|
||||
} else {
|
||||
let mut origins = Vec::new();
|
||||
for origin in &cli.cors_allow_origin {
|
||||
let value = origin
|
||||
.parse()
|
||||
.map_err(|_| CliError::InvalidCorsOrigin(origin.clone()))?;
|
||||
origins.push(value);
|
||||
}
|
||||
cors = cors.allow_origin(origins);
|
||||
}
|
||||
|
||||
if cli.cors_allow_method.is_empty() {
|
||||
cors = cors.allow_methods(Any);
|
||||
} else {
|
||||
let mut methods = Vec::new();
|
||||
for method in &cli.cors_allow_method {
|
||||
let parsed = method
|
||||
.parse()
|
||||
.map_err(|_| CliError::InvalidCorsMethod(method.clone()))?;
|
||||
methods.push(parsed);
|
||||
}
|
||||
cors = cors.allow_methods(methods);
|
||||
}
|
||||
|
||||
if cli.cors_allow_header.is_empty() {
|
||||
cors = cors.allow_headers(Any);
|
||||
} else {
|
||||
let mut headers = Vec::new();
|
||||
for header in &cli.cors_allow_header {
|
||||
let parsed = header
|
||||
.parse()
|
||||
.map_err(|_| CliError::InvalidCorsHeader(header.clone()))?;
|
||||
headers.push(parsed);
|
||||
}
|
||||
cors = cors.allow_headers(headers);
|
||||
}
|
||||
|
||||
if cli.cors_allow_credentials {
|
||||
cors = cors.allow_credentials(true);
|
||||
}
|
||||
|
||||
Ok(Some(cors))
|
||||
}
|
||||
|
||||
struct ClientContext {
|
||||
endpoint: String,
|
||||
token: Option<String>,
|
||||
client: HttpClient,
|
||||
}
|
||||
|
||||
impl ClientContext {
|
||||
fn new(cli: &Cli, args: &ClientArgs) -> Result<Self, CliError> {
|
||||
let endpoint = args
|
||||
.endpoint
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("http://{}:{}", cli.host, cli.port));
|
||||
let token = if cli.no_token { None } else { cli.token.clone() };
|
||||
let client = HttpClient::builder().build()?;
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
token,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.endpoint.trim_end_matches('/'), path)
|
||||
}
|
||||
|
||||
fn request(&self, method: Method, path: &str) -> reqwest::blocking::RequestBuilder {
|
||||
let url = self.url(path);
|
||||
let mut builder = self.client.request(method, url);
|
||||
if let Some(token) = &self.token {
|
||||
builder = builder.bearer_auth(token);
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
|
||||
Ok(self.request(Method::GET, path).send()?)
|
||||
}
|
||||
|
||||
fn get_with_query(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(&str, Option<u64>)],
|
||||
) -> Result<reqwest::blocking::Response, CliError> {
|
||||
let mut request = self.request(Method::GET, path);
|
||||
for (key, value) in query {
|
||||
if let Some(value) = value {
|
||||
request = request.query(&[(key, value)]);
|
||||
}
|
||||
}
|
||||
Ok(request.send()?)
|
||||
}
|
||||
|
||||
fn post<T: Serialize>(&self, path: &str, body: &T) -> Result<reqwest::blocking::Response, CliError> {
|
||||
Ok(self.request(Method::POST, path).json(body).send()?)
|
||||
}
|
||||
|
||||
fn post_empty(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
|
||||
Ok(self.request(Method::POST, path).send()?)
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json_response<T: serde::de::DeserializeOwned + Serialize>(
|
||||
response: reqwest::blocking::Response,
|
||||
) -> Result<(), CliError> {
|
||||
let status = response.status();
|
||||
let text = response.text()?;
|
||||
|
||||
if !status.is_success() {
|
||||
print_error_body(&text)?;
|
||||
return Err(CliError::HttpStatus(status));
|
||||
}
|
||||
|
||||
let parsed: T = serde_json::from_str(&text)?;
|
||||
let pretty = serde_json::to_string_pretty(&parsed)?;
|
||||
write_stdout_line(&pretty)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliError> {
|
||||
let status = response.status();
|
||||
let text = response.text()?;
|
||||
|
||||
if !status.is_success() {
|
||||
print_error_body(&text)?;
|
||||
return Err(CliError::HttpStatus(status));
|
||||
}
|
||||
|
||||
write_stdout(&text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let text = response.text()?;
|
||||
print_error_body(&text)?;
|
||||
Err(CliError::HttpStatus(status))
|
||||
}
|
||||
|
||||
fn print_error_body(text: &str) -> Result<(), CliError> {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(text) {
|
||||
let pretty = serde_json::to_string_pretty(&json)?;
|
||||
write_stderr_line(&pretty)?;
|
||||
} else {
|
||||
write_stderr_line(text)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_stdout(text: &str) -> Result<(), CliError> {
|
||||
let mut out = std::io::stdout();
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_stdout_line(text: &str) -> Result<(), CliError> {
|
||||
let mut out = std::io::stdout();
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.write_all(b"\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_stderr_line(text: &str) -> Result<(), CliError> {
|
||||
let mut out = std::io::stderr();
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.write_all(b"\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
2168
server/packages/sandbox-agent/src/router.rs
Normal file
2168
server/packages/sandbox-agent/src/router.rs
Normal file
File diff suppressed because it is too large
Load diff
114
server/packages/sandbox-agent/tests/agents.rs
Normal file
114
server/packages/sandbox-agent/tests/agents.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use sandbox_agent_agent_management::agents::{
|
||||
AgentError, AgentId, AgentManager, InstallOptions, SpawnOptions,
|
||||
};
|
||||
use sandbox_agent_agent_management::credentials::{
|
||||
extract_all_credentials, CredentialExtractionOptions,
|
||||
};
|
||||
|
||||
fn build_env() -> HashMap<String, String> {
|
||||
let options = CredentialExtractionOptions::new();
|
||||
let credentials = extract_all_credentials(&options);
|
||||
let mut env = HashMap::new();
|
||||
if let Some(anthropic) = credentials.anthropic {
|
||||
env.insert("ANTHROPIC_API_KEY".to_string(), anthropic.api_key);
|
||||
}
|
||||
if let Some(openai) = credentials.openai {
|
||||
env.insert("OPENAI_API_KEY".to_string(), openai.api_key);
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
fn amp_configured() -> bool {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
home.join(".amp").join("config.json").exists()
|
||||
}
|
||||
|
||||
fn prompt_ok(label: &str) -> String {
|
||||
format!("Respond with exactly the text {label} and nothing else.")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let manager = AgentManager::new(temp_dir.path().join("bin"))?;
|
||||
let env = build_env();
|
||||
assert!(!env.is_empty(), "expected credentials to be available");
|
||||
|
||||
let agents = [AgentId::Claude, AgentId::Codex, AgentId::Opencode, AgentId::Amp];
|
||||
for agent in agents {
|
||||
let install = manager.install(agent, InstallOptions::default())?;
|
||||
assert!(install.path.exists(), "expected install for {agent}");
|
||||
assert!(manager.is_installed(agent), "expected is_installed for {agent}");
|
||||
manager.install(
|
||||
agent,
|
||||
InstallOptions {
|
||||
reinstall: true,
|
||||
version: None,
|
||||
},
|
||||
)?;
|
||||
let version = manager.version(agent)?;
|
||||
assert!(version.is_some(), "expected version for {agent}");
|
||||
|
||||
if agent != AgentId::Amp || amp_configured() {
|
||||
let mut spawn = SpawnOptions::new(prompt_ok("OK"));
|
||||
spawn.env = env.clone();
|
||||
let result = manager.spawn(agent, spawn)?;
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"spawn failed for {agent}: {}",
|
||||
result.stderr
|
||||
);
|
||||
assert!(
|
||||
!result.events.is_empty(),
|
||||
"expected events for {agent} but got none"
|
||||
);
|
||||
assert!(
|
||||
result.session_id.is_some(),
|
||||
"expected session id for {agent}"
|
||||
);
|
||||
let combined = format!("{}{}", result.stdout, result.stderr);
|
||||
let output = result.result.clone().unwrap_or(combined);
|
||||
assert!(output.contains("OK"), "expected OK for {agent}, got: {output}");
|
||||
|
||||
if agent == AgentId::Claude || agent == AgentId::Opencode || (agent == AgentId::Amp && amp_configured()) {
|
||||
let mut resume = SpawnOptions::new(prompt_ok("OK2"));
|
||||
resume.env = env.clone();
|
||||
resume.session_id = result.session_id.clone();
|
||||
let resumed = manager.spawn(agent, resume)?;
|
||||
assert!(
|
||||
resumed.status.success(),
|
||||
"resume spawn failed for {agent}: {}",
|
||||
resumed.stderr
|
||||
);
|
||||
let combined = format!("{}{}", resumed.stdout, resumed.stderr);
|
||||
let output = resumed.result.clone().unwrap_or(combined);
|
||||
assert!(output.contains("OK2"), "expected OK2 for {agent}, got: {output}");
|
||||
} else if agent == AgentId::Codex {
|
||||
let mut resume = SpawnOptions::new(prompt_ok("OK2"));
|
||||
resume.env = env.clone();
|
||||
resume.session_id = result.session_id.clone();
|
||||
let err = manager.spawn(agent, resume).expect_err("expected resume error for codex");
|
||||
assert!(matches!(err, AgentError::ResumeUnsupported { .. }));
|
||||
}
|
||||
|
||||
if agent == AgentId::Claude || agent == AgentId::Codex {
|
||||
let mut plan = SpawnOptions::new(prompt_ok("OK3"));
|
||||
plan.env = env.clone();
|
||||
plan.permission_mode = Some("plan".to_string());
|
||||
let planned = manager.spawn(agent, plan)?;
|
||||
assert!(
|
||||
planned.status.success(),
|
||||
"plan spawn failed for {agent}: {}",
|
||||
planned.stderr
|
||||
);
|
||||
let combined = format!("{}{}", planned.stdout, planned.stderr);
|
||||
let output = planned.result.clone().unwrap_or(combined);
|
||||
assert!(output.contains("OK3"), "expected OK3 for {agent}, got: {output}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
465
server/packages/sandbox-agent/tests/http_sse_snapshots.rs
Normal file
465
server/packages/sandbox-agent/tests/http_sse_snapshots.rs
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use axum::Router;
|
||||
use futures::StreamExt;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Map, Value};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use sandbox_agent_agent_management::agents::{AgentId, AgentManager};
|
||||
use sandbox_agent_agent_management::testing::{test_agents_from_env, TestAgentConfig};
|
||||
use sandbox_agent_agent_credentials::ExtractedCredentials;
|
||||
use sandbox_agent_core::router::{build_router, AppState, AuthConfig};
|
||||
use tower::ServiceExt;
|
||||
|
||||
const PROMPT: &str = "Reply with exactly the single word OK.";
|
||||
|
||||
struct TestApp {
|
||||
app: Router,
|
||||
_install_dir: TempDir,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
fn new() -> Self {
|
||||
let install_dir = tempfile::tempdir().expect("create temp install dir");
|
||||
let manager = AgentManager::new(install_dir.path())
|
||||
.expect("create agent manager");
|
||||
let state = AppState::new(AuthConfig::disabled(), manager);
|
||||
let app = build_router(state);
|
||||
Self {
|
||||
app,
|
||||
_install_dir: install_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
saved: BTreeMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
for (key, value) in &self.saved {
|
||||
match value {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_credentials(creds: &ExtractedCredentials) -> EnvGuard {
|
||||
let keys = ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY"];
|
||||
let mut saved = BTreeMap::new();
|
||||
for key in keys {
|
||||
saved.insert(key.to_string(), std::env::var(key).ok());
|
||||
}
|
||||
|
||||
match creds.anthropic.as_ref() {
|
||||
Some(cred) => {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", &cred.api_key);
|
||||
std::env::set_var("CLAUDE_API_KEY", &cred.api_key);
|
||||
}
|
||||
None => {
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
std::env::remove_var("CLAUDE_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
match creds.openai.as_ref() {
|
||||
Some(cred) => {
|
||||
std::env::set_var("OPENAI_API_KEY", &cred.api_key);
|
||||
std::env::set_var("CODEX_API_KEY", &cred.api_key);
|
||||
}
|
||||
None => {
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
std::env::remove_var("CODEX_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
EnvGuard { saved }
|
||||
}
|
||||
|
||||
async fn send_json(app: &Router, method: Method, path: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder().method(method).uri(path);
|
||||
let body = if let Some(body) = body {
|
||||
builder = builder.header("content-type", "application/json");
|
||||
Body::from(body.to_string())
|
||||
} else {
|
||||
Body::empty()
|
||||
};
|
||||
let request = builder.body(body).expect("request");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("request handled");
|
||||
let status = response.status();
|
||||
let bytes = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("read body")
|
||||
.to_bytes();
|
||||
let value = if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).to_string()))
|
||||
};
|
||||
(status, value)
|
||||
}
|
||||
|
||||
async fn send_status(app: &Router, method: Method, path: &str, body: Option<Value>) -> StatusCode {
|
||||
let (status, _) = send_json(app, method, path, body).await;
|
||||
status
|
||||
}
|
||||
|
||||
async fn install_agent(app: &Router, agent: AgentId) {
|
||||
let status = send_status(
|
||||
app,
|
||||
Method::POST,
|
||||
&format!("/v1/agents/{}/install", agent.as_str()),
|
||||
Some(json!({})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "install {agent}");
|
||||
}
|
||||
|
||||
async fn create_session(app: &Router, agent: AgentId, session_id: &str) {
|
||||
let status = send_status(
|
||||
app,
|
||||
Method::POST,
|
||||
&format!("/v1/sessions/{session_id}"),
|
||||
Some(json!({
|
||||
"agent": agent.as_str(),
|
||||
"permissionMode": "bypass"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "create session {agent}");
|
||||
}
|
||||
|
||||
async fn send_message(app: &Router, session_id: &str) {
|
||||
let status = send_status(
|
||||
app,
|
||||
Method::POST,
|
||||
&format!("/v1/sessions/{session_id}/messages"),
|
||||
Some(json!({ "message": PROMPT })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "send message");
|
||||
}
|
||||
|
||||
async fn poll_events_until(
|
||||
app: &Router,
|
||||
session_id: &str,
|
||||
timeout: Duration,
|
||||
) -> Vec<Value> {
|
||||
let start = Instant::now();
|
||||
let mut offset = 0u64;
|
||||
let mut events = Vec::new();
|
||||
while start.elapsed() < timeout {
|
||||
let path = format!("/v1/sessions/{session_id}/events?offset={offset}&limit=200");
|
||||
let (status, payload) = send_json(app, Method::GET, &path, None).await;
|
||||
assert_eq!(status, StatusCode::OK, "poll events");
|
||||
let new_events = payload
|
||||
.get("events")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if !new_events.is_empty() {
|
||||
if let Some(last) = new_events.last().and_then(|event| event.get("id")).and_then(Value::as_u64) {
|
||||
offset = last;
|
||||
}
|
||||
events.extend(new_events);
|
||||
if should_stop(&events) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
async fn read_sse_events(
|
||||
app: &Router,
|
||||
session_id: &str,
|
||||
timeout: Duration,
|
||||
) -> Vec<Value> {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/v1/sessions/{session_id}/events/sse?offset=0"))
|
||||
.body(Body::empty())
|
||||
.expect("sse request");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("sse response");
|
||||
assert_eq!(response.status(), StatusCode::OK, "sse status");
|
||||
|
||||
let mut stream = response.into_body().into_data_stream();
|
||||
let mut buffer = String::new();
|
||||
let mut events = Vec::new();
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
let remaining = match timeout.checked_sub(start.elapsed()) {
|
||||
Some(remaining) if !remaining.is_zero() => remaining,
|
||||
_ => break,
|
||||
};
|
||||
let next = tokio::time::timeout(remaining, stream.next()).await;
|
||||
let chunk = match next {
|
||||
Ok(Some(Ok(chunk))) => chunk,
|
||||
Ok(Some(Err(_))) => break,
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
};
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
while let Some(idx) = buffer.find("\n\n") {
|
||||
let block = buffer[..idx].to_string();
|
||||
buffer = buffer[idx + 2..].to_string();
|
||||
if let Some(event) = parse_sse_block(&block) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
if should_stop(&events) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
fn parse_sse_block(block: &str) -> Option<Value> {
|
||||
let mut data_lines = Vec::new();
|
||||
for line in block.lines() {
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
data_lines.push(rest.trim_start());
|
||||
}
|
||||
}
|
||||
if data_lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let data = data_lines.join("\n");
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
fn should_stop(events: &[Value]) -> bool {
|
||||
events.iter().any(|event| is_assistant_message(event) || is_error_event(event))
|
||||
}
|
||||
|
||||
fn is_assistant_message(event: &Value) -> bool {
|
||||
event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("message"))
|
||||
.and_then(|message| message.get("role"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|role| role == "assistant")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_error_event(event: &Value) -> bool {
|
||||
event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("error"))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn normalize_events(events: &[Value]) -> Value {
|
||||
let normalized = events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, event)| normalize_event(event, idx + 1))
|
||||
.collect::<Vec<_>>();
|
||||
Value::Array(normalized)
|
||||
}
|
||||
|
||||
fn normalize_event(event: &Value, seq: usize) -> Value {
|
||||
let mut map = Map::new();
|
||||
map.insert("seq".to_string(), Value::Number(seq.into()));
|
||||
if let Some(agent) = event.get("agent").and_then(Value::as_str) {
|
||||
map.insert("agent".to_string(), Value::String(agent.to_string()));
|
||||
}
|
||||
let data = event.get("data").unwrap_or(&Value::Null);
|
||||
if let Some(message) = data.get("message") {
|
||||
map.insert("kind".to_string(), Value::String("message".to_string()));
|
||||
map.insert("message".to_string(), normalize_message(message));
|
||||
} else if let Some(started) = data.get("started") {
|
||||
map.insert("kind".to_string(), Value::String("started".to_string()));
|
||||
map.insert("started".to_string(), normalize_started(started));
|
||||
} else if let Some(error) = data.get("error") {
|
||||
map.insert("kind".to_string(), Value::String("error".to_string()));
|
||||
map.insert("error".to_string(), normalize_error(error));
|
||||
} else if let Some(question) = data.get("questionAsked") {
|
||||
map.insert("kind".to_string(), Value::String("question".to_string()));
|
||||
map.insert("question".to_string(), normalize_question(question));
|
||||
} else if let Some(permission) = data.get("permissionAsked") {
|
||||
map.insert("kind".to_string(), Value::String("permission".to_string()));
|
||||
map.insert("permission".to_string(), normalize_permission(permission));
|
||||
} else {
|
||||
map.insert("kind".to_string(), Value::String("unknown".to_string()));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn normalize_message(message: &Value) -> Value {
|
||||
let mut map = Map::new();
|
||||
if let Some(role) = message.get("role").and_then(Value::as_str) {
|
||||
map.insert("role".to_string(), Value::String(role.to_string()));
|
||||
}
|
||||
if let Some(parts) = message.get("parts").and_then(Value::as_array) {
|
||||
let parts = parts.iter().map(normalize_part).collect::<Vec<_>>();
|
||||
map.insert("parts".to_string(), Value::Array(parts));
|
||||
} else if message.get("raw").is_some() {
|
||||
map.insert("unparsed".to_string(), Value::Bool(true));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn normalize_part(part: &Value) -> Value {
|
||||
let mut map = Map::new();
|
||||
if let Some(part_type) = part.get("type").and_then(Value::as_str) {
|
||||
map.insert("type".to_string(), Value::String(part_type.to_string()));
|
||||
}
|
||||
if let Some(name) = part.get("name").and_then(Value::as_str) {
|
||||
map.insert("name".to_string(), Value::String(name.to_string()));
|
||||
}
|
||||
if part.get("text").is_some() {
|
||||
map.insert("text".to_string(), Value::String("<redacted>".to_string()));
|
||||
}
|
||||
if part.get("input").is_some() {
|
||||
map.insert("input".to_string(), Value::Bool(true));
|
||||
}
|
||||
if part.get("output").is_some() {
|
||||
map.insert("output".to_string(), Value::Bool(true));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn normalize_started(started: &Value) -> Value {
|
||||
let mut map = Map::new();
|
||||
if let Some(message) = started.get("message").and_then(Value::as_str) {
|
||||
map.insert("message".to_string(), Value::String(message.to_string()));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn normalize_error(error: &Value) -> Value {
|
||||
let mut map = Map::new();
|
||||
if let Some(kind) = error.get("kind").and_then(Value::as_str) {
|
||||
map.insert("kind".to_string(), Value::String(kind.to_string()));
|
||||
}
|
||||
if let Some(message) = error.get("message").and_then(Value::as_str) {
|
||||
map.insert("message".to_string(), Value::String(message.to_string()));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn normalize_question(question: &Value) -> Value {
|
||||
let mut map = Map::new();
|
||||
if question.get("id").is_some() {
|
||||
map.insert("id".to_string(), Value::String("<redacted>".to_string()));
|
||||
}
|
||||
if let Some(questions) = question.get("questions").and_then(Value::as_array) {
|
||||
map.insert("count".to_string(), Value::Number(questions.len().into()));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn normalize_permission(permission: &Value) -> Value {
|
||||
let mut map = Map::new();
|
||||
if permission.get("id").is_some() {
|
||||
map.insert("id".to_string(), Value::String("<redacted>".to_string()));
|
||||
}
|
||||
if let Some(value) = permission.get("permission").and_then(Value::as_str) {
|
||||
map.insert("permission".to_string(), Value::String(value.to_string()));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn snapshot_name(prefix: &str, agent: AgentId) -> String {
|
||||
format!("{prefix}_{}", agent.as_str())
|
||||
}
|
||||
|
||||
async fn run_http_events_snapshot(app: &Router, config: &TestAgentConfig) {
|
||||
let _guard = apply_credentials(&config.credentials);
|
||||
install_agent(app, config.agent).await;
|
||||
|
||||
let session_id = format!("session-{}", config.agent.as_str());
|
||||
create_session(app, config.agent, &session_id).await;
|
||||
send_message(app, &session_id).await;
|
||||
|
||||
let events = poll_events_until(app, &session_id, Duration::from_secs(120)).await;
|
||||
assert!(
|
||||
!events.is_empty(),
|
||||
"no events collected for {}",
|
||||
config.agent
|
||||
);
|
||||
assert!(
|
||||
should_stop(&events),
|
||||
"timed out waiting for assistant/error event for {}",
|
||||
config.agent
|
||||
);
|
||||
let normalized = normalize_events(&events);
|
||||
insta::with_settings!({
|
||||
snapshot_suffix => snapshot_name("http_events", config.agent),
|
||||
}, {
|
||||
insta::assert_yaml_snapshot!(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_sse_events_snapshot(app: &Router, config: &TestAgentConfig) {
|
||||
let _guard = apply_credentials(&config.credentials);
|
||||
install_agent(app, config.agent).await;
|
||||
|
||||
let session_id = format!("sse-{}", config.agent.as_str());
|
||||
create_session(app, config.agent, &session_id).await;
|
||||
|
||||
let sse_task = {
|
||||
let app = app.clone();
|
||||
let session_id = session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
read_sse_events(&app, &session_id, Duration::from_secs(120)).await
|
||||
})
|
||||
};
|
||||
|
||||
send_message(app, &session_id).await;
|
||||
|
||||
let events = sse_task.await.expect("sse task");
|
||||
assert!(
|
||||
!events.is_empty(),
|
||||
"no sse events collected for {}",
|
||||
config.agent
|
||||
);
|
||||
assert!(
|
||||
should_stop(&events),
|
||||
"timed out waiting for assistant/error event for {}",
|
||||
config.agent
|
||||
);
|
||||
let normalized = normalize_events(&events);
|
||||
insta::with_settings!({
|
||||
snapshot_suffix => snapshot_name("sse_events", config.agent),
|
||||
}, {
|
||||
insta::assert_yaml_snapshot!(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn http_events_snapshots() {
|
||||
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS");
|
||||
let app = TestApp::new();
|
||||
for config in &configs {
|
||||
run_http_events_snapshot(&app.app, config).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn sse_events_snapshots() {
|
||||
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS");
|
||||
let app = TestApp::new();
|
||||
for config in &configs {
|
||||
run_sse_events_snapshot(&app.app, config).await;
|
||||
}
|
||||
}
|
||||
14
server/packages/universal-agent-schema/Cargo.toml
Normal file
14
server/packages/universal-agent-schema/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "sandbox-agent-universal-agent-schema"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sandbox-agent-agent-schema = { path = "../agent-schema" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "0.8"
|
||||
thiserror = "1.0"
|
||||
utoipa = { version = "4.2", features = ["axum_extras"] }
|
||||
155
server/packages/universal-agent-schema/src/agents/amp.rs
Normal file
155
server/packages/universal-agent-schema/src/agents/amp.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
use crate::{
|
||||
message_from_parts,
|
||||
message_from_text,
|
||||
text_only_from_parts,
|
||||
ConversionError,
|
||||
CrashInfo,
|
||||
EventConversion,
|
||||
UniversalEventData,
|
||||
UniversalMessage,
|
||||
UniversalMessageParsed,
|
||||
UniversalMessagePart,
|
||||
};
|
||||
use crate::amp as schema;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub fn event_to_universal(event: &schema::StreamJsonMessage) -> EventConversion {
|
||||
let schema::StreamJsonMessage {
|
||||
content,
|
||||
error,
|
||||
id,
|
||||
tool_call,
|
||||
type_,
|
||||
} = event;
|
||||
match type_ {
|
||||
schema::StreamJsonMessageType::Message => {
|
||||
let text = content.clone().unwrap_or_default();
|
||||
let mut message = message_from_text("assistant", text);
|
||||
if let UniversalMessage::Parsed(parsed) = &mut message {
|
||||
parsed.id = id.clone();
|
||||
}
|
||||
EventConversion::new(UniversalEventData::Message { message })
|
||||
}
|
||||
schema::StreamJsonMessageType::ToolCall => {
|
||||
let tool_call = tool_call.as_ref();
|
||||
let part = if let Some(tool_call) = tool_call {
|
||||
let schema::ToolCall { arguments, id, name } = tool_call;
|
||||
let input = match arguments {
|
||||
schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()),
|
||||
schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()),
|
||||
};
|
||||
UniversalMessagePart::ToolCall {
|
||||
id: Some(id.clone()),
|
||||
name: name.clone(),
|
||||
input,
|
||||
}
|
||||
} else {
|
||||
UniversalMessagePart::Unknown { raw: Value::Null }
|
||||
};
|
||||
let mut message = message_from_parts("assistant", vec![part]);
|
||||
if let UniversalMessage::Parsed(parsed) = &mut message {
|
||||
parsed.id = id.clone();
|
||||
}
|
||||
EventConversion::new(UniversalEventData::Message { message })
|
||||
}
|
||||
schema::StreamJsonMessageType::ToolResult => {
|
||||
let output = content
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null);
|
||||
let part = UniversalMessagePart::ToolResult {
|
||||
id: id.clone(),
|
||||
name: None,
|
||||
output,
|
||||
is_error: None,
|
||||
};
|
||||
let message = message_from_parts("tool", vec![part]);
|
||||
EventConversion::new(UniversalEventData::Message { message })
|
||||
}
|
||||
schema::StreamJsonMessageType::Error => {
|
||||
let message = error.clone().unwrap_or_else(|| "amp error".to_string());
|
||||
let crash = CrashInfo {
|
||||
message,
|
||||
kind: Some("amp".to_string()),
|
||||
details: serde_json::to_value(event).ok(),
|
||||
};
|
||||
EventConversion::new(UniversalEventData::Error { error: crash })
|
||||
}
|
||||
schema::StreamJsonMessageType::Done => EventConversion::new(UniversalEventData::Unknown {
|
||||
raw: serde_json::to_value(event).unwrap_or(Value::Null),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn universal_event_to_amp(event: &UniversalEventData) -> Result<schema::StreamJsonMessage, ConversionError> {
|
||||
match event {
|
||||
UniversalEventData::Message { message } => {
|
||||
let parsed = match message {
|
||||
UniversalMessage::Parsed(parsed) => parsed,
|
||||
UniversalMessage::Unparsed { .. } => {
|
||||
return Err(ConversionError::Unsupported("unparsed message"))
|
||||
}
|
||||
};
|
||||
let content = text_only_from_parts(&parsed.parts)?;
|
||||
Ok(schema::StreamJsonMessage {
|
||||
content: Some(content),
|
||||
error: None,
|
||||
id: parsed.id.clone(),
|
||||
tool_call: None,
|
||||
type_: schema::StreamJsonMessageType::Message,
|
||||
})
|
||||
}
|
||||
_ => Err(ConversionError::Unsupported("amp event")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_to_universal(message: &schema::Message) -> UniversalMessage {
|
||||
let schema::Message {
|
||||
role,
|
||||
content,
|
||||
tool_calls,
|
||||
} = message;
|
||||
let mut parts = vec![UniversalMessagePart::Text {
|
||||
text: content.clone(),
|
||||
}];
|
||||
for call in tool_calls {
|
||||
let schema::ToolCall { arguments, id, name } = call;
|
||||
let input = match arguments {
|
||||
schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()),
|
||||
schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()),
|
||||
};
|
||||
parts.push(UniversalMessagePart::ToolCall {
|
||||
id: Some(id.clone()),
|
||||
name: name.clone(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
UniversalMessage::Parsed(UniversalMessageParsed {
|
||||
role: role.to_string(),
|
||||
id: None,
|
||||
metadata: Map::new(),
|
||||
parts,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn universal_message_to_message(
|
||||
message: &UniversalMessage,
|
||||
) -> Result<schema::Message, ConversionError> {
|
||||
let parsed = match message {
|
||||
UniversalMessage::Parsed(parsed) => parsed,
|
||||
UniversalMessage::Unparsed { .. } => {
|
||||
return Err(ConversionError::Unsupported("unparsed message"))
|
||||
}
|
||||
};
|
||||
let content = text_only_from_parts(&parsed.parts)?;
|
||||
Ok(schema::Message {
|
||||
role: match parsed.role.as_str() {
|
||||
"user" => schema::MessageRole::User,
|
||||
"assistant" => schema::MessageRole::Assistant,
|
||||
"system" => schema::MessageRole::System,
|
||||
_ => schema::MessageRole::User,
|
||||
},
|
||||
content,
|
||||
tool_calls: vec![],
|
||||
})
|
||||
}
|
||||
239
server/packages/universal-agent-schema/src/agents/claude.rs
Normal file
239
server/packages/universal-agent-schema/src/agents/claude.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
use crate::{
|
||||
message_from_parts,
|
||||
message_from_text,
|
||||
text_only_from_parts,
|
||||
ConversionError,
|
||||
EventConversion,
|
||||
QuestionInfo,
|
||||
QuestionOption,
|
||||
QuestionRequest,
|
||||
UniversalEventData,
|
||||
UniversalMessage,
|
||||
UniversalMessageParsed,
|
||||
UniversalMessagePart,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub fn event_to_universal_with_session(
|
||||
event: &Value,
|
||||
session_id: String,
|
||||
) -> EventConversion {
|
||||
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
match event_type {
|
||||
"assistant" => assistant_event_to_universal(event),
|
||||
"tool_use" => tool_use_event_to_universal(event, session_id),
|
||||
"tool_result" => tool_result_event_to_universal(event),
|
||||
"result" => result_event_to_universal(event),
|
||||
_ => EventConversion::new(UniversalEventData::Unknown { raw: event.clone() }),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn universal_event_to_claude(event: &UniversalEventData) -> Result<Value, ConversionError> {
|
||||
match event {
|
||||
UniversalEventData::Message { message } => {
|
||||
let parsed = match message {
|
||||
UniversalMessage::Parsed(parsed) => parsed,
|
||||
UniversalMessage::Unparsed { .. } => {
|
||||
return Err(ConversionError::Unsupported("unparsed message"))
|
||||
}
|
||||
};
|
||||
let text = text_only_from_parts(&parsed.parts)?;
|
||||
Ok(Value::Object(Map::from_iter([
|
||||
("type".to_string(), Value::String("assistant".to_string())),
|
||||
(
|
||||
"message".to_string(),
|
||||
Value::Object(Map::from_iter([(
|
||||
"content".to_string(),
|
||||
Value::Array(vec![Value::Object(Map::from_iter([(
|
||||
"type".to_string(),
|
||||
Value::String("text".to_string()),
|
||||
), (
|
||||
"text".to_string(),
|
||||
Value::String(text),
|
||||
)]))]),
|
||||
)])),
|
||||
),
|
||||
])))
|
||||
}
|
||||
_ => Err(ConversionError::Unsupported("claude event")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt_to_universal(prompt: &str) -> UniversalMessage {
|
||||
message_from_text("user", prompt.to_string())
|
||||
}
|
||||
|
||||
pub fn universal_message_to_prompt(message: &UniversalMessage) -> Result<String, ConversionError> {
|
||||
let parsed = match message {
|
||||
UniversalMessage::Parsed(parsed) => parsed,
|
||||
UniversalMessage::Unparsed { .. } => {
|
||||
return Err(ConversionError::Unsupported("unparsed message"))
|
||||
}
|
||||
};
|
||||
text_only_from_parts(&parsed.parts)
|
||||
}
|
||||
|
||||
fn assistant_event_to_universal(event: &Value) -> EventConversion {
|
||||
let content = event
|
||||
.get("message")
|
||||
.and_then(|msg| msg.get("content"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut parts = Vec::new();
|
||||
for block in content {
|
||||
let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = block.get("text").and_then(Value::as_str) {
|
||||
parts.push(UniversalMessagePart::Text {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
if let Some(name) = block.get("name").and_then(Value::as_str) {
|
||||
let input = block.get("input").cloned().unwrap_or(Value::Null);
|
||||
let id = block.get("id").and_then(Value::as_str).map(|s| s.to_string());
|
||||
parts.push(UniversalMessagePart::ToolCall {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => parts.push(UniversalMessagePart::Unknown { raw: block }),
|
||||
}
|
||||
}
|
||||
let message = UniversalMessage::Parsed(UniversalMessageParsed {
|
||||
role: "assistant".to_string(),
|
||||
id: None,
|
||||
metadata: Map::new(),
|
||||
parts,
|
||||
});
|
||||
EventConversion::new(UniversalEventData::Message { message })
|
||||
}
|
||||
|
||||
fn tool_use_event_to_universal(event: &Value, session_id: String) -> EventConversion {
|
||||
let tool_use = event.get("tool_use");
|
||||
let name = tool_use
|
||||
.and_then(|tool| tool.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let input = tool_use
|
||||
.and_then(|tool| tool.get("input"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let id = tool_use
|
||||
.and_then(|tool| tool.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if name == "AskUserQuestion" {
|
||||
if let Some(question) =
|
||||
question_from_claude_input(&input, id.clone(), session_id.clone())
|
||||
{
|
||||
return EventConversion::new(UniversalEventData::QuestionAsked {
|
||||
question_asked: question,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let message = message_from_parts(
|
||||
"assistant",
|
||||
vec![UniversalMessagePart::ToolCall {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
input,
|
||||
}],
|
||||
);
|
||||
EventConversion::new(UniversalEventData::Message { message })
|
||||
}
|
||||
|
||||
fn tool_result_event_to_universal(event: &Value) -> EventConversion {
|
||||
let tool_result = event.get("tool_result");
|
||||
let output = tool_result
|
||||
.and_then(|tool| tool.get("content"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let is_error = tool_result
|
||||
.and_then(|tool| tool.get("is_error"))
|
||||
.and_then(Value::as_bool);
|
||||
let id = tool_result
|
||||
.and_then(|tool| tool.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let message = message_from_parts(
|
||||
"tool",
|
||||
vec![UniversalMessagePart::ToolResult {
|
||||
id,
|
||||
name: None,
|
||||
output,
|
||||
is_error,
|
||||
}],
|
||||
);
|
||||
EventConversion::new(UniversalEventData::Message { message })
|
||||
}
|
||||
|
||||
fn result_event_to_universal(event: &Value) -> EventConversion {
|
||||
let result_text = event
|
||||
.get("result")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let session_id = event
|
||||
.get("session_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
let message = message_from_text("assistant", result_text);
|
||||
EventConversion::new(UniversalEventData::Message { message }).with_session(session_id)
|
||||
}
|
||||
|
||||
fn question_from_claude_input(
|
||||
input: &Value,
|
||||
tool_id: Option<String>,
|
||||
session_id: String,
|
||||
) -> Option<QuestionRequest> {
|
||||
let questions = input.get("questions").and_then(Value::as_array)?;
|
||||
let mut parsed_questions = Vec::new();
|
||||
for question in questions {
|
||||
let question_text = question.get("question")?.as_str()?.to_string();
|
||||
let header = question
|
||||
.get("header")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
let multi_select = question
|
||||
.get("multiSelect")
|
||||
.and_then(Value::as_bool);
|
||||
let options = question
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.map(|options| {
|
||||
options
|
||||
.iter()
|
||||
.filter_map(|option| {
|
||||
let label = option.get("label")?.as_str()?.to_string();
|
||||
let description = option
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
Some(QuestionOption { label, description })
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})?;
|
||||
parsed_questions.push(QuestionInfo {
|
||||
question: question_text,
|
||||
header,
|
||||
options,
|
||||
multi_select,
|
||||
custom: None,
|
||||
});
|
||||
}
|
||||
Some(QuestionRequest {
|
||||
id: tool_id.unwrap_or_else(|| "claude-question".to_string()),
|
||||
session_id,
|
||||
questions: parsed_questions,
|
||||
tool: None,
|
||||
})
|
||||
}
|
||||
375
server/packages/universal-agent-schema/src/agents/codex.rs
Normal file
375
server/packages/universal-agent-schema/src/agents/codex.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
use crate::{
|
||||
extract_message_from_value,
|
||||
text_only_from_parts,
|
||||
AttachmentSource,
|
||||
ConversionError,
|
||||
CrashInfo,
|
||||
EventConversion,
|
||||
Started,
|
||||
UniversalEventData,
|
||||
UniversalMessage,
|
||||
UniversalMessageParsed,
|
||||
UniversalMessagePart,
|
||||
};
|
||||
use crate::codex as schema;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub fn event_to_universal(event: &schema::ThreadEvent) -> EventConversion {
|
||||
let schema::ThreadEvent {
|
||||
error,
|
||||
item,
|
||||
thread_id,
|
||||
type_,
|
||||
} = event;
|
||||
match type_ {
|
||||
schema::ThreadEventType::ThreadCreated | schema::ThreadEventType::ThreadUpdated => {
|
||||
let started = Started {
|
||||
message: Some(type_.to_string()),
|
||||
details: serde_json::to_value(event).ok(),
|
||||
};
|
||||
EventConversion::new(UniversalEventData::Started { started })
|
||||
.with_session(thread_id.clone())
|
||||
}
|
||||
schema::ThreadEventType::ItemCreated | schema::ThreadEventType::ItemUpdated => {
|
||||
if let Some(item) = item.as_ref() {
|
||||
let message = thread_item_to_message(item);
|
||||
EventConversion::new(UniversalEventData::Message { message })
|
||||
.with_session(thread_id.clone())
|
||||
} else {
|
||||
EventConversion::new(UniversalEventData::Unknown {
|
||||
raw: serde_json::to_value(event).unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
}
|
||||
schema::ThreadEventType::Error => {
|
||||
let message = extract_message_from_value(&Value::Object(error.clone()))
|
||||
.unwrap_or_else(|| "codex error".to_string());
|
||||
let crash = CrashInfo {
|
||||
message,
|
||||
kind: Some("error".to_string()),
|
||||
details: Some(Value::Object(error.clone())),
|
||||
};
|
||||
EventConversion::new(UniversalEventData::Error { error: crash })
|
||||
.with_session(thread_id.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn universal_event_to_codex(event: &UniversalEventData) -> Result<schema::ThreadEvent, ConversionError> {
|
||||
match event {
|
||||
UniversalEventData::Message { message } => {
|
||||
let parsed = match message {
|
||||
UniversalMessage::Parsed(parsed) => parsed,
|
||||
UniversalMessage::Unparsed { .. } => {
|
||||
return Err(ConversionError::Unsupported("unparsed message"))
|
||||
}
|
||||
};
|
||||
let id = parsed.id.clone().ok_or(ConversionError::MissingField("message.id"))?;
|
||||
let content = text_only_from_parts(&parsed.parts)?;
|
||||
let role = match parsed.role.as_str() {
|
||||
"user" => Some(schema::ThreadItemRole::User),
|
||||
"assistant" => Some(schema::ThreadItemRole::Assistant),
|
||||
"system" => Some(schema::ThreadItemRole::System),
|
||||
_ => None,
|
||||
};
|
||||
let item = schema::ThreadItem {
|
||||
content: Some(schema::ThreadItemContent::Variant0(content)),
|
||||
id,
|
||||
role,
|
||||
status: None,
|
||||
type_: schema::ThreadItemType::Message,
|
||||
};
|
||||
Ok(schema::ThreadEvent {
|
||||
error: Map::new(),
|
||||
item: Some(item),
|
||||
thread_id: None,
|
||||
type_: schema::ThreadEventType::ItemCreated,
|
||||
})
|
||||
}
|
||||
_ => Err(ConversionError::Unsupported("codex event")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_to_universal(message: &schema::Message) -> UniversalMessage {
|
||||
let schema::Message { role, content } = message;
|
||||
UniversalMessage::Parsed(UniversalMessageParsed {
|
||||
role: role.to_string(),
|
||||
id: None,
|
||||
metadata: Map::new(),
|
||||
parts: vec![UniversalMessagePart::Text {
|
||||
text: content.clone(),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn universal_message_to_message(
|
||||
message: &UniversalMessage,
|
||||
) -> Result<schema::Message, ConversionError> {
|
||||
let parsed = match message {
|
||||
UniversalMessage::Parsed(parsed) => parsed,
|
||||
UniversalMessage::Unparsed { .. } => {
|
||||
return Err(ConversionError::Unsupported("unparsed message"))
|
||||
}
|
||||
};
|
||||
let content = text_only_from_parts(&parsed.parts)?;
|
||||
Ok(schema::Message {
|
||||
role: match parsed.role.as_str() {
|
||||
"user" => schema::MessageRole::User,
|
||||
"assistant" => schema::MessageRole::Assistant,
|
||||
"system" => schema::MessageRole::System,
|
||||
_ => schema::MessageRole::User,
|
||||
},
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn inputs_to_universal_message(inputs: &[schema::Input], role: &str) -> UniversalMessage {
|
||||
let parts = inputs.iter().map(input_to_universal_part).collect();
|
||||
UniversalMessage::Parsed(UniversalMessageParsed {
|
||||
role: role.to_string(),
|
||||
id: None,
|
||||
metadata: Map::new(),
|
||||
parts,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn input_to_universal_part(input: &schema::Input) -> UniversalMessagePart {
|
||||
let schema::Input {
|
||||
content,
|
||||
mime_type,
|
||||
path,
|
||||
type_,
|
||||
} = input;
|
||||
let raw = serde_json::to_value(input).unwrap_or(Value::Null);
|
||||
match type_ {
|
||||
schema::InputType::Text => match content {
|
||||
Some(content) => UniversalMessagePart::Text {
|
||||
text: content.clone(),
|
||||
},
|
||||
None => UniversalMessagePart::Unknown { raw },
|
||||
},
|
||||
schema::InputType::File => {
|
||||
let source = if let Some(path) = path {
|
||||
AttachmentSource::Path { path: path.clone() }
|
||||
} else if let Some(content) = content {
|
||||
AttachmentSource::Data {
|
||||
data: content.clone(),
|
||||
encoding: None,
|
||||
}
|
||||
} else {
|
||||
return UniversalMessagePart::Unknown { raw };
|
||||
};
|
||||
UniversalMessagePart::File {
|
||||
source,
|
||||
mime_type: mime_type.clone(),
|
||||
filename: None,
|
||||
raw: Some(raw),
|
||||
}
|
||||
}
|
||||
schema::InputType::Image => {
|
||||
let source = if let Some(path) = path {
|
||||
AttachmentSource::Path { path: path.clone() }
|
||||
} else if let Some(content) = content {
|
||||
AttachmentSource::Data {
|
||||
data: content.clone(),
|
||||
encoding: None,
|
||||
}
|
||||
} else {
|
||||
return UniversalMessagePart::Unknown { raw };
|
||||
};
|
||||
UniversalMessagePart::Image {
|
||||
source,
|
||||
mime_type: mime_type.clone(),
|
||||
alt: None,
|
||||
raw: Some(raw),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn universal_message_to_inputs(
|
||||
message: &UniversalMessage,
|
||||
) -> Result<Vec<schema::Input>, ConversionError> {
|
||||
let parsed = match message {
|
||||
UniversalMessage::Parsed(parsed) => parsed,
|
||||
UniversalMessage::Unparsed { .. } => {
|
||||
return Err(ConversionError::Unsupported("unparsed message"))
|
||||
}
|
||||
};
|
||||
universal_parts_to_inputs(&parsed.parts)
|
||||
}
|
||||
|
||||
pub fn universal_parts_to_inputs(
|
||||
parts: &[UniversalMessagePart],
|
||||
) -> Result<Vec<schema::Input>, ConversionError> {
|
||||
let mut inputs = Vec::new();
|
||||
for part in parts {
|
||||
match part {
|
||||
UniversalMessagePart::Text { text } => inputs.push(schema::Input {
|
||||
content: Some(text.clone()),
|
||||
mime_type: None,
|
||||
path: None,
|
||||
type_: schema::InputType::Text,
|
||||
}),
|
||||
UniversalMessagePart::File {
|
||||
source,
|
||||
mime_type,
|
||||
..
|
||||
} => inputs.push(input_from_attachment(source, mime_type.as_ref(), schema::InputType::File)?),
|
||||
UniversalMessagePart::Image {
|
||||
source, mime_type, ..
|
||||
} => inputs.push(input_from_attachment(
|
||||
source,
|
||||
mime_type.as_ref(),
|
||||
schema::InputType::Image,
|
||||
)?),
|
||||
UniversalMessagePart::ToolCall { .. }
|
||||
| UniversalMessagePart::ToolResult { .. }
|
||||
| UniversalMessagePart::FunctionCall { .. }
|
||||
| UniversalMessagePart::FunctionResult { .. }
|
||||
| UniversalMessagePart::Error { .. }
|
||||
| UniversalMessagePart::Unknown { .. } => {
|
||||
return Err(ConversionError::Unsupported("unsupported part"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if inputs.is_empty() {
|
||||
return Err(ConversionError::MissingField("parts"));
|
||||
}
|
||||
Ok(inputs)
|
||||
}
|
||||
|
||||
fn input_from_attachment(
|
||||
source: &AttachmentSource,
|
||||
mime_type: Option<&String>,
|
||||
input_type: schema::InputType,
|
||||
) -> Result<schema::Input, ConversionError> {
|
||||
match source {
|
||||
AttachmentSource::Path { path } => Ok(schema::Input {
|
||||
content: None,
|
||||
mime_type: mime_type.cloned(),
|
||||
path: Some(path.clone()),
|
||||
type_: input_type,
|
||||
}),
|
||||
AttachmentSource::Data { data, encoding } => {
|
||||
if let Some(encoding) = encoding.as_deref() {
|
||||
if encoding != "base64" {
|
||||
return Err(ConversionError::Unsupported("codex data encoding"));
|
||||
}
|
||||
}
|
||||
Ok(schema::Input {
|
||||
content: Some(data.clone()),
|
||||
mime_type: mime_type.cloned(),
|
||||
path: None,
|
||||
type_: input_type,
|
||||
})
|
||||
}
|
||||
AttachmentSource::Url { .. } => Err(ConversionError::Unsupported("codex input url")),
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_item_to_message(item: &schema::ThreadItem) -> UniversalMessage {
|
||||
let schema::ThreadItem {
|
||||
content,
|
||||
id,
|
||||
role,
|
||||
status,
|
||||
type_,
|
||||
} = item;
|
||||
let mut metadata = Map::new();
|
||||
metadata.insert("itemType".to_string(), Value::String(type_.to_string()));
|
||||
if let Some(status) = status {
|
||||
metadata.insert("status".to_string(), Value::String(status.to_string()));
|
||||
}
|
||||
let role = role
|
||||
.as_ref()
|
||||
.map(|role| role.to_string())
|
||||
.unwrap_or_else(|| "assistant".to_string());
|
||||
let parts = match type_ {
|
||||
schema::ThreadItemType::Message => message_parts_from_codex_content(content),
|
||||
schema::ThreadItemType::FunctionCall => vec![function_call_part_from_codex(id, content)],
|
||||
schema::ThreadItemType::FunctionResult => vec![function_result_part_from_codex(id, content)],
|
||||
};
|
||||
UniversalMessage::Parsed(UniversalMessageParsed {
|
||||
role,
|
||||
id: Some(id.clone()),
|
||||
metadata,
|
||||
parts,
|
||||
})
|
||||
}
|
||||
|
||||
fn message_parts_from_codex_content(
|
||||
content: &Option<schema::ThreadItemContent>,
|
||||
) -> Vec<UniversalMessagePart> {
|
||||
match content {
|
||||
Some(schema::ThreadItemContent::Variant0(text)) => {
|
||||
vec![UniversalMessagePart::Text { text: text.clone() }]
|
||||
}
|
||||
Some(schema::ThreadItemContent::Variant1(raw)) => {
|
||||
vec![UniversalMessagePart::Unknown {
|
||||
raw: serde_json::to_value(raw).unwrap_or(Value::Null),
|
||||
}]
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn function_call_part_from_codex(
|
||||
item_id: &str,
|
||||
content: &Option<schema::ThreadItemContent>,
|
||||
) -> UniversalMessagePart {
|
||||
let raw = thread_item_content_to_value(content);
|
||||
let name = extract_object_field(&raw, "name");
|
||||
let arguments = extract_object_value(&raw, "arguments").unwrap_or_else(|| raw.clone());
|
||||
UniversalMessagePart::FunctionCall {
|
||||
id: Some(item_id.to_string()),
|
||||
name,
|
||||
arguments,
|
||||
raw: Some(raw),
|
||||
}
|
||||
}
|
||||
|
||||
fn function_result_part_from_codex(
|
||||
item_id: &str,
|
||||
content: &Option<schema::ThreadItemContent>,
|
||||
) -> UniversalMessagePart {
|
||||
let raw = thread_item_content_to_value(content);
|
||||
let name = extract_object_field(&raw, "name");
|
||||
let result = extract_object_value(&raw, "result")
|
||||
.or_else(|| extract_object_value(&raw, "output"))
|
||||
.or_else(|| extract_object_value(&raw, "content"))
|
||||
.unwrap_or_else(|| raw.clone());
|
||||
UniversalMessagePart::FunctionResult {
|
||||
id: Some(item_id.to_string()),
|
||||
name,
|
||||
result,
|
||||
is_error: None,
|
||||
raw: Some(raw),
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_item_content_to_value(content: &Option<schema::ThreadItemContent>) -> Value {
|
||||
match content {
|
||||
Some(schema::ThreadItemContent::Variant0(text)) => Value::String(text.clone()),
|
||||
Some(schema::ThreadItemContent::Variant1(raw)) => {
|
||||
Value::Array(raw.iter().cloned().map(Value::Object).collect())
|
||||
}
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_object_field(raw: &Value, field: &str) -> Option<String> {
|
||||
extract_object_value(raw, field)
|
||||
.and_then(|value| value.as_str().map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
fn extract_object_value(raw: &Value, field: &str) -> Option<Value> {
|
||||
match raw {
|
||||
Value::Object(map) => map.get(field).cloned(),
|
||||
Value::Array(values) => values
|
||||
.first()
|
||||
.and_then(|value| value.as_object())
|
||||
.and_then(|map| map.get(field).cloned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
4
server/packages/universal-agent-schema/src/agents/mod.rs
Normal file
4
server/packages/universal-agent-schema/src/agents/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod amp;
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod opencode;
|
||||
958
server/packages/universal-agent-schema/src/agents/opencode.rs
Normal file
958
server/packages/universal-agent-schema/src/agents/opencode.rs
Normal file
|
|
@ -0,0 +1,958 @@
|
|||
use crate::{
|
||||
extract_message_from_value,
|
||||
AttachmentSource,
|
||||
ConversionError,
|
||||
CrashInfo,
|
||||
EventConversion,
|
||||
PermissionRequest,
|
||||
PermissionToolRef,
|
||||
QuestionInfo,
|
||||
QuestionOption,
|
||||
QuestionRequest,
|
||||
QuestionToolRef,
|
||||
Started,
|
||||
UniversalEventData,
|
||||
UniversalMessage,
|
||||
UniversalMessageParsed,
|
||||
UniversalMessagePart,
|
||||
};
|
||||
use crate::opencode as schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub fn event_to_universal(event: &schema::Event) -> EventConversion {
|
||||
match event {
|
||||
schema::Event::MessageUpdated(updated) => {
|
||||
let schema::EventMessageUpdated { properties, type_: _ } = updated;
|
||||
let schema::EventMessageUpdatedProperties { info } = properties;
|
||||
let (message, session_id) = message_from_opencode(info);
|
||||
EventConversion::new(UniversalEventData::Message { message })
|
||||
.with_session(session_id)
|
||||
}
|
||||
schema::Event::MessagePartUpdated(updated) => {
|
||||
let schema::EventMessagePartUpdated { properties, type_: _ } = updated;
|
||||
let schema::EventMessagePartUpdatedProperties { part, delta } = properties;
|
||||
let (message, session_id) = part_to_message(part, delta.as_ref());
|
||||
EventConversion::new(UniversalEventData::Message { message })
|
||||
.with_session(session_id)
|
||||
}
|
||||
schema::Event::QuestionAsked(asked) => {
|
||||
let schema::EventQuestionAsked { properties, type_: _ } = asked;
|
||||
let question = question_request_from_opencode(properties);
|
||||
let session_id = question.session_id.clone();
|
||||
EventConversion::new(UniversalEventData::QuestionAsked { question_asked: question })
|
||||
.with_session(Some(session_id))
|
||||
}
|
||||
schema::Event::PermissionAsked(asked) => {
|
||||
let schema::EventPermissionAsked { properties, type_: _ } = asked;
|
||||
let permission = permission_request_from_opencode(properties);
|
||||
let session_id = permission.session_id.clone();
|
||||
EventConversion::new(UniversalEventData::PermissionAsked { permission_asked: permission })
|
||||
.with_session(Some(session_id))
|
||||
}
|
||||
schema::Event::SessionCreated(created) => {
|
||||
let schema::EventSessionCreated { properties, type_: _ } = created;
|
||||
let schema::EventSessionCreatedProperties { info } = properties;
|
||||
let details = serde_json::to_value(info).ok();
|
||||
let started = Started {
|
||||
message: Some("session.created".to_string()),
|
||||
details,
|
||||
};
|
||||
EventConversion::new(UniversalEventData::Started { started })
|
||||
}
|
||||
schema::Event::SessionError(error) => {
|
||||
let schema::EventSessionError { properties, type_: _ } = error;
|
||||
let schema::EventSessionErrorProperties {
|
||||
error: _error,
|
||||
session_id,
|
||||
} = properties;
|
||||
let message = extract_message_from_value(&serde_json::to_value(properties).unwrap_or(Value::Null))
|
||||
.unwrap_or_else(|| "opencode session error".to_string());
|
||||
let crash = CrashInfo {
|
||||
message,
|
||||
kind: Some("session.error".to_string()),
|
||||
details: serde_json::to_value(properties).ok(),
|
||||
};
|
||||
EventConversion::new(UniversalEventData::Error { error: crash })
|
||||
.with_session(session_id.clone())
|
||||
}
|
||||
_ => EventConversion::new(UniversalEventData::Unknown {
|
||||
raw: serde_json::to_value(event).unwrap_or(Value::Null),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn universal_event_to_opencode(event: &UniversalEventData) -> Result<schema::Event, ConversionError> {
|
||||
match event {
|
||||
UniversalEventData::QuestionAsked { question_asked } => {
|
||||
let properties = question_request_to_opencode(question_asked)?;
|
||||
Ok(schema::Event::QuestionAsked(schema::EventQuestionAsked {
|
||||
properties,
|
||||
type_: "question.asked".to_string(),
|
||||
}))
|
||||
}
|
||||
UniversalEventData::PermissionAsked { permission_asked } => {
|
||||
let properties = permission_request_to_opencode(permission_asked)?;
|
||||
Ok(schema::Event::PermissionAsked(schema::EventPermissionAsked {
|
||||
properties,
|
||||
type_: "permission.asked".to_string(),
|
||||
}))
|
||||
}
|
||||
_ => Err(ConversionError::Unsupported("opencode event")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn universal_message_to_parts(
|
||||
message: &UniversalMessage,
|
||||
) -> Result<Vec<schema::TextPartInput>, ConversionError> {
|
||||
let parsed = match message {
|
||||
UniversalMessage::Parsed(parsed) => parsed,
|
||||
UniversalMessage::Unparsed { .. } => {
|
||||
return Err(ConversionError::Unsupported("unparsed message"))
|
||||
}
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
for part in &parsed.parts {
|
||||
match part {
|
||||
UniversalMessagePart::Text { text } => {
|
||||
parts.push(text_part_input_from_text(text));
|
||||
}
|
||||
UniversalMessagePart::ToolCall { .. }
|
||||
| UniversalMessagePart::ToolResult { .. }
|
||||
| UniversalMessagePart::FunctionCall { .. }
|
||||
| UniversalMessagePart::FunctionResult { .. }
|
||||
| UniversalMessagePart::File { .. }
|
||||
| UniversalMessagePart::Image { .. }
|
||||
| UniversalMessagePart::Error { .. }
|
||||
| UniversalMessagePart::Unknown { .. } => {
|
||||
return Err(ConversionError::Unsupported("non-text part"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return Err(ConversionError::MissingField("parts"));
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum OpencodePartInput {
|
||||
Text(schema::TextPartInput),
|
||||
File(schema::FilePartInput),
|
||||
}
|
||||
|
||||
pub fn universal_message_to_part_inputs(
|
||||
message: &UniversalMessage,
|
||||
) -> Result<Vec<OpencodePartInput>, ConversionError> {
|
||||
let parsed = match message {
|
||||
UniversalMessage::Parsed(parsed) => parsed,
|
||||
UniversalMessage::Unparsed { .. } => {
|
||||
return Err(ConversionError::Unsupported("unparsed message"))
|
||||
}
|
||||
};
|
||||
universal_parts_to_part_inputs(&parsed.parts)
|
||||
}
|
||||
|
||||
pub fn universal_parts_to_part_inputs(
|
||||
parts: &[UniversalMessagePart],
|
||||
) -> Result<Vec<OpencodePartInput>, ConversionError> {
|
||||
let mut inputs = Vec::new();
|
||||
for part in parts {
|
||||
inputs.push(universal_part_to_opencode_input(part)?);
|
||||
}
|
||||
if inputs.is_empty() {
|
||||
return Err(ConversionError::MissingField("parts"));
|
||||
}
|
||||
Ok(inputs)
|
||||
}
|
||||
|
||||
pub fn universal_part_to_opencode_input(
|
||||
part: &UniversalMessagePart,
|
||||
) -> Result<OpencodePartInput, ConversionError> {
|
||||
match part {
|
||||
UniversalMessagePart::Text { text } => Ok(OpencodePartInput::Text(
|
||||
text_part_input_from_text(text),
|
||||
)),
|
||||
UniversalMessagePart::File {
|
||||
source,
|
||||
mime_type,
|
||||
filename,
|
||||
..
|
||||
} => Ok(OpencodePartInput::File(file_part_input_from_universal(
|
||||
source,
|
||||
mime_type.as_deref(),
|
||||
filename.as_ref(),
|
||||
)?)),
|
||||
UniversalMessagePart::Image {
|
||||
source, mime_type, ..
|
||||
} => Ok(OpencodePartInput::File(file_part_input_from_universal(
|
||||
source,
|
||||
mime_type.as_deref(),
|
||||
None,
|
||||
)?)),
|
||||
UniversalMessagePart::ToolCall { .. }
|
||||
| UniversalMessagePart::ToolResult { .. }
|
||||
| UniversalMessagePart::FunctionCall { .. }
|
||||
| UniversalMessagePart::FunctionResult { .. }
|
||||
| UniversalMessagePart::Error { .. }
|
||||
| UniversalMessagePart::Unknown { .. } => {
|
||||
Err(ConversionError::Unsupported("unsupported part"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text_part_input_from_text(text: &str) -> schema::TextPartInput {
|
||||
schema::TextPartInput {
|
||||
id: None,
|
||||
ignored: None,
|
||||
metadata: Map::new(),
|
||||
synthetic: None,
|
||||
text: text.to_string(),
|
||||
time: None,
|
||||
type_: "text".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_part_input_to_universal(part: &schema::TextPartInput) -> UniversalMessage {
|
||||
let schema::TextPartInput {
|
||||
id,
|
||||
ignored,
|
||||
metadata,
|
||||
synthetic,
|
||||
text,
|
||||
time,
|
||||
type_,
|
||||
} = part;
|
||||
let mut metadata = metadata.clone();
|
||||
if let Some(id) = id {
|
||||
metadata.insert("partId".to_string(), Value::String(id.clone()));
|
||||
}
|
||||
if let Some(ignored) = ignored {
|
||||
metadata.insert("ignored".to_string(), Value::Bool(*ignored));
|
||||
}
|
||||
if let Some(synthetic) = synthetic {
|
||||
metadata.insert("synthetic".to_string(), Value::Bool(*synthetic));
|
||||
}
|
||||
if let Some(time) = time {
|
||||
metadata.insert(
|
||||
"time".to_string(),
|
||||
serde_json::to_value(time).unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
metadata.insert("type".to_string(), Value::String(type_.clone()));
|
||||
UniversalMessage::Parsed(UniversalMessageParsed {
|
||||
role: "user".to_string(),
|
||||
id: None,
|
||||
metadata,
|
||||
parts: vec![UniversalMessagePart::Text { text: text.clone() }],
|
||||
})
|
||||
}
|
||||
|
||||
fn file_part_input_from_universal(
|
||||
source: &AttachmentSource,
|
||||
mime_type: Option<&str>,
|
||||
filename: Option<&String>,
|
||||
) -> Result<schema::FilePartInput, ConversionError> {
|
||||
let mime = mime_type.ok_or(ConversionError::MissingField("mime_type"))?;
|
||||
let url = attachment_source_to_opencode_url(source, mime)?;
|
||||
Ok(schema::FilePartInput {
|
||||
filename: filename.cloned(),
|
||||
id: None,
|
||||
mime: mime.to_string(),
|
||||
source: None,
|
||||
type_: "file".to_string(),
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
fn attachment_source_to_opencode_url(
|
||||
source: &AttachmentSource,
|
||||
mime_type: &str,
|
||||
) -> Result<String, ConversionError> {
|
||||
match source {
|
||||
AttachmentSource::Url { url } => Ok(url.clone()),
|
||||
AttachmentSource::Path { path } => Ok(format!("file://{}", path)),
|
||||
AttachmentSource::Data { data, encoding } => {
|
||||
let encoding = encoding.as_deref().unwrap_or("base64");
|
||||
if encoding != "base64" {
|
||||
return Err(ConversionError::Unsupported("opencode data encoding"));
|
||||
}
|
||||
Ok(format!("data:{};base64,{}", mime_type, data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn message_from_opencode(message: &schema::Message) -> (UniversalMessage, Option<String>) {
|
||||
match message {
|
||||
schema::Message::UserMessage(user) => {
|
||||
let schema::UserMessage {
|
||||
agent,
|
||||
id,
|
||||
model,
|
||||
role,
|
||||
session_id,
|
||||
summary,
|
||||
system,
|
||||
time,
|
||||
tools,
|
||||
variant,
|
||||
} = user;
|
||||
let mut metadata = Map::new();
|
||||
metadata.insert("agent".to_string(), Value::String(agent.clone()));
|
||||
metadata.insert(
|
||||
"model".to_string(),
|
||||
serde_json::to_value(model).unwrap_or(Value::Null),
|
||||
);
|
||||
metadata.insert(
|
||||
"time".to_string(),
|
||||
serde_json::to_value(time).unwrap_or(Value::Null),
|
||||
);
|
||||
metadata.insert(
|
||||
"tools".to_string(),
|
||||
serde_json::to_value(tools).unwrap_or(Value::Null),
|
||||
);
|
||||
if let Some(summary) = summary {
|
||||
metadata.insert(
|
||||
"summary".to_string(),
|
||||
serde_json::to_value(summary).unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
if let Some(system) = system {
|
||||
metadata.insert("system".to_string(), Value::String(system.clone()));
|
||||
}
|
||||
if let Some(variant) = variant {
|
||||
metadata.insert("variant".to_string(), Value::String(variant.clone()));
|
||||
}
|
||||
let parsed = UniversalMessageParsed {
|
||||
role: role.clone(),
|
||||
id: Some(id.clone()),
|
||||
metadata,
|
||||
parts: Vec::new(),
|
||||
};
|
||||
(
|
||||
UniversalMessage::Parsed(parsed),
|
||||
Some(session_id.clone()),
|
||||
)
|
||||
}
|
||||
schema::Message::AssistantMessage(assistant) => {
|
||||
let schema::AssistantMessage {
|
||||
agent,
|
||||
cost,
|
||||
error,
|
||||
finish,
|
||||
id,
|
||||
mode,
|
||||
model_id,
|
||||
parent_id,
|
||||
path,
|
||||
provider_id,
|
||||
role,
|
||||
session_id,
|
||||
summary,
|
||||
time,
|
||||
tokens,
|
||||
} = assistant;
|
||||
let mut metadata = Map::new();
|
||||
metadata.insert("agent".to_string(), Value::String(agent.clone()));
|
||||
metadata.insert(
|
||||
"cost".to_string(),
|
||||
serde_json::to_value(cost).unwrap_or(Value::Null),
|
||||
);
|
||||
metadata.insert("mode".to_string(), Value::String(mode.clone()));
|
||||
metadata.insert("modelId".to_string(), Value::String(model_id.clone()));
|
||||
metadata.insert("providerId".to_string(), Value::String(provider_id.clone()));
|
||||
metadata.insert("parentId".to_string(), Value::String(parent_id.clone()));
|
||||
metadata.insert(
|
||||
"path".to_string(),
|
||||
serde_json::to_value(path).unwrap_or(Value::Null),
|
||||
);
|
||||
metadata.insert(
|
||||
"tokens".to_string(),
|
||||
serde_json::to_value(tokens).unwrap_or(Value::Null),
|
||||
);
|
||||
metadata.insert(
|
||||
"time".to_string(),
|
||||
serde_json::to_value(time).unwrap_or(Value::Null),
|
||||
);
|
||||
if let Some(error) = error {
|
||||
metadata.insert(
|
||||
"error".to_string(),
|
||||
serde_json::to_value(error).unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
if let Some(finish) = finish {
|
||||
metadata.insert("finish".to_string(), Value::String(finish.clone()));
|
||||
}
|
||||
if let Some(summary) = summary {
|
||||
metadata.insert(
|
||||
"summary".to_string(),
|
||||
serde_json::to_value(summary).unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
let parsed = UniversalMessageParsed {
|
||||
role: role.clone(),
|
||||
id: Some(id.clone()),
|
||||
metadata,
|
||||
parts: Vec::new(),
|
||||
};
|
||||
(
|
||||
UniversalMessage::Parsed(parsed),
|
||||
Some(session_id.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn part_to_message(part: &schema::Part, delta: Option<&String>) -> (UniversalMessage, Option<String>) {
|
||||
match part {
|
||||
schema::Part::Variant0(text_part) => {
|
||||
let schema::TextPart {
|
||||
id,
|
||||
ignored,
|
||||
message_id,
|
||||
metadata,
|
||||
session_id,
|
||||
synthetic,
|
||||
text,
|
||||
time,
|
||||
type_,
|
||||
} = text_part;
|
||||
let mut part_metadata = base_part_metadata(message_id, id, delta);
|
||||
part_metadata.insert("type".to_string(), Value::String(type_.clone()));
|
||||
if let Some(ignored) = ignored {
|
||||
part_metadata.insert("ignored".to_string(), Value::Bool(*ignored));
|
||||
}
|
||||
if let Some(synthetic) = synthetic {
|
||||
part_metadata.insert("synthetic".to_string(), Value::Bool(*synthetic));
|
||||
}
|
||||
if let Some(time) = time {
|
||||
part_metadata.insert(
|
||||
"time".to_string(),
|
||||
serde_json::to_value(time).unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
if !metadata.is_empty() {
|
||||
part_metadata.insert(
|
||||
"partMetadata".to_string(),
|
||||
Value::Object(metadata.clone()),
|
||||
);
|
||||
}
|
||||
let parsed = UniversalMessageParsed {
|
||||
role: "assistant".to_string(),
|
||||
id: Some(message_id.clone()),
|
||||
metadata: part_metadata,
|
||||
parts: vec![UniversalMessagePart::Text { text: text.clone() }],
|
||||
};
|
||||
(UniversalMessage::Parsed(parsed), Some(session_id.clone()))
|
||||
}
|
||||
schema::Part::Variant1 {
|
||||
agent: _agent,
|
||||
command: _command,
|
||||
description: _description,
|
||||
id,
|
||||
message_id,
|
||||
model: _model,
|
||||
prompt: _prompt,
|
||||
session_id,
|
||||
type_: _type,
|
||||
} => unknown_part_message(message_id, id, session_id, serde_json::to_value(part).unwrap_or(Value::Null), delta),
|
||||
schema::Part::Variant2(reasoning_part) => {
|
||||
let schema::ReasoningPart {
|
||||
id,
|
||||
message_id,
|
||||
metadata: _metadata,
|
||||
session_id,
|
||||
text: _text,
|
||||
time: _time,
|
||||
type_: _type,
|
||||
} = reasoning_part;
|
||||
unknown_part_message(
|
||||
message_id,
|
||||
id,
|
||||
session_id,
|
||||
serde_json::to_value(reasoning_part).unwrap_or(Value::Null),
|
||||
delta,
|
||||
)
|
||||
}
|
||||
schema::Part::Variant3(file_part) => {
|
||||
let schema::FilePart {
|
||||
filename: _filename,
|
||||
id,
|
||||
message_id,
|
||||
mime: _mime,
|
||||
session_id,
|
||||
source: _source,
|
||||
type_: _type,
|
||||
url: _url,
|
||||
} = file_part;
|
||||
let part_metadata = base_part_metadata(message_id, id, delta);
|
||||
let part = file_part_to_universal_part(file_part);
|
||||
let parsed = UniversalMessageParsed {
|
||||
role: "assistant".to_string(),
|
||||
id: Some(message_id.clone()),
|
||||
metadata: part_metadata,
|
||||
parts: vec![part],
|
||||
};
|
||||
(UniversalMessage::Parsed(parsed), Some(session_id.clone()))
|
||||
}
|
||||
schema::Part::Variant4(tool_part) => {
|
||||
let schema::ToolPart {
|
||||
call_id,
|
||||
id,
|
||||
message_id,
|
||||
metadata,
|
||||
session_id,
|
||||
state,
|
||||
tool,
|
||||
type_,
|
||||
} = tool_part;
|
||||
let mut part_metadata = base_part_metadata(message_id, id, delta);
|
||||
part_metadata.insert("type".to_string(), Value::String(type_.clone()));
|
||||
part_metadata.insert("callId".to_string(), Value::String(call_id.clone()));
|
||||
part_metadata.insert("tool".to_string(), Value::String(tool.clone()));
|
||||
if !metadata.is_empty() {
|
||||
part_metadata.insert(
|
||||
"partMetadata".to_string(),
|
||||
Value::Object(metadata.clone()),
|
||||
);
|
||||
}
|
||||
let (mut parts, state_meta) = tool_state_to_parts(call_id, tool, state);
|
||||
if let Some(state_meta) = state_meta {
|
||||
part_metadata.insert("toolState".to_string(), state_meta);
|
||||
}
|
||||
let parsed = UniversalMessageParsed {
|
||||
role: "assistant".to_string(),
|
||||
id: Some(message_id.clone()),
|
||||
metadata: part_metadata,
|
||||
parts: parts.drain(..).collect(),
|
||||
};
|
||||
(UniversalMessage::Parsed(parsed), Some(session_id.clone()))
|
||||
}
|
||||
schema::Part::Variant5(step_start) => {
|
||||
let schema::StepStartPart {
|
||||
id,
|
||||
message_id,
|
||||
session_id,
|
||||
snapshot: _snapshot,
|
||||
type_: _type,
|
||||
} = step_start;
|
||||
unknown_part_message(
|
||||
message_id,
|
||||
id,
|
||||
session_id,
|
||||
serde_json::to_value(step_start).unwrap_or(Value::Null),
|
||||
delta,
|
||||
)
|
||||
}
|
||||
schema::Part::Variant6(step_finish) => {
|
||||
let schema::StepFinishPart {
|
||||
cost: _cost,
|
||||
id,
|
||||
message_id,
|
||||
reason: _reason,
|
||||
session_id,
|
||||
snapshot: _snapshot,
|
||||
tokens: _tokens,
|
||||
type_: _type,
|
||||
} = step_finish;
|
||||
unknown_part_message(
|
||||
message_id,
|
||||
id,
|
||||
session_id,
|
||||
serde_json::to_value(step_finish).unwrap_or(Value::Null),
|
||||
delta,
|
||||
)
|
||||
}
|
||||
schema::Part::Variant7(snapshot_part) => {
|
||||
let schema::SnapshotPart {
|
||||
id,
|
||||
message_id,
|
||||
session_id,
|
||||
snapshot: _snapshot,
|
||||
type_: _type,
|
||||
} = snapshot_part;
|
||||
unknown_part_message(
|
||||
message_id,
|
||||
id,
|
||||
session_id,
|
||||
serde_json::to_value(snapshot_part).unwrap_or(Value::Null),
|
||||
delta,
|
||||
)
|
||||
}
|
||||
schema::Part::Variant8(patch_part) => {
|
||||
let schema::PatchPart {
|
||||
files: _files,
|
||||
hash: _hash,
|
||||
id,
|
||||
message_id,
|
||||
session_id,
|
||||
type_: _type,
|
||||
} = patch_part;
|
||||
unknown_part_message(
|
||||
message_id,
|
||||
id,
|
||||
session_id,
|
||||
serde_json::to_value(patch_part).unwrap_or(Value::Null),
|
||||
delta,
|
||||
)
|
||||
}
|
||||
schema::Part::Variant9(agent_part) => {
|
||||
let schema::AgentPart {
|
||||
id,
|
||||
message_id,
|
||||
name: _name,
|
||||
session_id,
|
||||
source: _source,
|
||||
type_: _type,
|
||||
} = agent_part;
|
||||
unknown_part_message(
|
||||
message_id,
|
||||
id,
|
||||
session_id,
|
||||
serde_json::to_value(agent_part).unwrap_or(Value::Null),
|
||||
delta,
|
||||
)
|
||||
}
|
||||
schema::Part::Variant10(retry_part) => {
|
||||
let schema::RetryPart {
|
||||
attempt: _attempt,
|
||||
error: _error,
|
||||
id,
|
||||
message_id,
|
||||
session_id,
|
||||
time: _time,
|
||||
type_: _type,
|
||||
} = retry_part;
|
||||
unknown_part_message(
|
||||
message_id,
|
||||
id,
|
||||
session_id,
|
||||
serde_json::to_value(retry_part).unwrap_or(Value::Null),
|
||||
delta,
|
||||
)
|
||||
}
|
||||
schema::Part::Variant11(compaction_part) => {
|
||||
let schema::CompactionPart {
|
||||
auto: _auto,
|
||||
id,
|
||||
message_id,
|
||||
session_id,
|
||||
type_: _type,
|
||||
} = compaction_part;
|
||||
unknown_part_message(
|
||||
message_id,
|
||||
id,
|
||||
session_id,
|
||||
serde_json::to_value(compaction_part).unwrap_or(Value::Null),
|
||||
delta,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn base_part_metadata(message_id: &str, part_id: &str, delta: Option<&String>) -> Map<String, Value> {
|
||||
let mut metadata = Map::new();
|
||||
metadata.insert("messageId".to_string(), Value::String(message_id.to_string()));
|
||||
metadata.insert("partId".to_string(), Value::String(part_id.to_string()));
|
||||
if let Some(delta) = delta {
|
||||
metadata.insert("delta".to_string(), Value::String(delta.clone()));
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
fn unknown_part_message(
|
||||
message_id: &str,
|
||||
part_id: &str,
|
||||
session_id: &str,
|
||||
raw: Value,
|
||||
delta: Option<&String>,
|
||||
) -> (UniversalMessage, Option<String>) {
|
||||
let metadata = base_part_metadata(message_id, part_id, delta);
|
||||
let parsed = UniversalMessageParsed {
|
||||
role: "assistant".to_string(),
|
||||
id: Some(message_id.to_string()),
|
||||
metadata,
|
||||
parts: vec![UniversalMessagePart::Unknown { raw }],
|
||||
};
|
||||
(UniversalMessage::Parsed(parsed), Some(session_id.to_string()))
|
||||
}
|
||||
|
||||
fn file_part_to_universal_part(file_part: &schema::FilePart) -> UniversalMessagePart {
|
||||
let schema::FilePart {
|
||||
filename,
|
||||
id: _id,
|
||||
message_id: _message_id,
|
||||
mime,
|
||||
session_id: _session_id,
|
||||
source: _source,
|
||||
type_: _type,
|
||||
url,
|
||||
} = file_part;
|
||||
let raw = serde_json::to_value(file_part).unwrap_or(Value::Null);
|
||||
let source = AttachmentSource::Url { url: url.clone() };
|
||||
if mime.starts_with("image/") {
|
||||
UniversalMessagePart::Image {
|
||||
source,
|
||||
mime_type: Some(mime.clone()),
|
||||
alt: filename.clone(),
|
||||
raw: Some(raw),
|
||||
}
|
||||
} else {
|
||||
UniversalMessagePart::File {
|
||||
source,
|
||||
mime_type: Some(mime.clone()),
|
||||
filename: filename.clone(),
|
||||
raw: Some(raw),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_state_to_parts(
|
||||
call_id: &str,
|
||||
tool: &str,
|
||||
state: &schema::ToolState,
|
||||
) -> (Vec<UniversalMessagePart>, Option<Value>) {
|
||||
match state {
|
||||
schema::ToolState::Pending(state) => {
|
||||
let schema::ToolStatePending { input, raw, status } = state;
|
||||
let mut meta = Map::new();
|
||||
meta.insert("status".to_string(), Value::String(status.clone()));
|
||||
meta.insert("raw".to_string(), Value::String(raw.clone()));
|
||||
meta.insert("input".to_string(), Value::Object(input.clone()));
|
||||
(
|
||||
vec![UniversalMessagePart::ToolCall {
|
||||
id: Some(call_id.to_string()),
|
||||
name: tool.to_string(),
|
||||
input: Value::Object(input.clone()),
|
||||
}],
|
||||
Some(Value::Object(meta)),
|
||||
)
|
||||
}
|
||||
schema::ToolState::Running(state) => {
|
||||
let schema::ToolStateRunning {
|
||||
input,
|
||||
metadata,
|
||||
status,
|
||||
time,
|
||||
title,
|
||||
} = state;
|
||||
let mut meta = Map::new();
|
||||
meta.insert("status".to_string(), Value::String(status.clone()));
|
||||
meta.insert("input".to_string(), Value::Object(input.clone()));
|
||||
meta.insert("metadata".to_string(), Value::Object(metadata.clone()));
|
||||
meta.insert(
|
||||
"time".to_string(),
|
||||
serde_json::to_value(time).unwrap_or(Value::Null),
|
||||
);
|
||||
if let Some(title) = title {
|
||||
meta.insert("title".to_string(), Value::String(title.clone()));
|
||||
}
|
||||
(
|
||||
vec![UniversalMessagePart::ToolCall {
|
||||
id: Some(call_id.to_string()),
|
||||
name: tool.to_string(),
|
||||
input: Value::Object(input.clone()),
|
||||
}],
|
||||
Some(Value::Object(meta)),
|
||||
)
|
||||
}
|
||||
schema::ToolState::Completed(state) => {
|
||||
let schema::ToolStateCompleted {
|
||||
attachments,
|
||||
input,
|
||||
metadata,
|
||||
output,
|
||||
status,
|
||||
time,
|
||||
title,
|
||||
} = state;
|
||||
let mut meta = Map::new();
|
||||
meta.insert("status".to_string(), Value::String(status.clone()));
|
||||
meta.insert("input".to_string(), Value::Object(input.clone()));
|
||||
meta.insert("metadata".to_string(), Value::Object(metadata.clone()));
|
||||
meta.insert(
|
||||
"time".to_string(),
|
||||
serde_json::to_value(time).unwrap_or(Value::Null),
|
||||
);
|
||||
meta.insert("title".to_string(), Value::String(title.clone()));
|
||||
if !attachments.is_empty() {
|
||||
meta.insert(
|
||||
"attachments".to_string(),
|
||||
serde_json::to_value(attachments).unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
let mut parts = vec![UniversalMessagePart::ToolResult {
|
||||
id: Some(call_id.to_string()),
|
||||
name: Some(tool.to_string()),
|
||||
output: Value::String(output.clone()),
|
||||
is_error: Some(false),
|
||||
}];
|
||||
for attachment in attachments {
|
||||
parts.push(file_part_to_universal_part(attachment));
|
||||
}
|
||||
(parts, Some(Value::Object(meta)))
|
||||
}
|
||||
schema::ToolState::Error(state) => {
|
||||
let schema::ToolStateError {
|
||||
error,
|
||||
input,
|
||||
metadata,
|
||||
status,
|
||||
time,
|
||||
} = state;
|
||||
let mut meta = Map::new();
|
||||
meta.insert("status".to_string(), Value::String(status.clone()));
|
||||
meta.insert("error".to_string(), Value::String(error.clone()));
|
||||
meta.insert("input".to_string(), Value::Object(input.clone()));
|
||||
meta.insert("metadata".to_string(), Value::Object(metadata.clone()));
|
||||
meta.insert(
|
||||
"time".to_string(),
|
||||
serde_json::to_value(time).unwrap_or(Value::Null),
|
||||
);
|
||||
(
|
||||
vec![UniversalMessagePart::ToolResult {
|
||||
id: Some(call_id.to_string()),
|
||||
name: Some(tool.to_string()),
|
||||
output: Value::String(error.clone()),
|
||||
is_error: Some(true),
|
||||
}],
|
||||
Some(Value::Object(meta)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn question_request_from_opencode(request: &schema::QuestionRequest) -> QuestionRequest {
|
||||
let schema::QuestionRequest {
|
||||
id,
|
||||
questions,
|
||||
session_id,
|
||||
tool,
|
||||
} = request;
|
||||
QuestionRequest {
|
||||
id: id.clone().into(),
|
||||
session_id: session_id.clone().into(),
|
||||
questions: questions
|
||||
.iter()
|
||||
.map(|question| {
|
||||
let schema::QuestionInfo {
|
||||
custom,
|
||||
header,
|
||||
multiple,
|
||||
options,
|
||||
question,
|
||||
} = question;
|
||||
QuestionInfo {
|
||||
question: question.clone(),
|
||||
header: Some(header.clone()),
|
||||
options: options
|
||||
.iter()
|
||||
.map(|opt| {
|
||||
let schema::QuestionOption { description, label } = opt;
|
||||
QuestionOption {
|
||||
label: label.clone(),
|
||||
description: Some(description.clone()),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
multi_select: *multiple,
|
||||
custom: *custom,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
tool: tool.as_ref().map(|tool| {
|
||||
let schema::QuestionRequestTool { message_id, call_id } = tool;
|
||||
QuestionToolRef {
|
||||
message_id: message_id.clone(),
|
||||
call_id: call_id.clone(),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_request_from_opencode(request: &schema::PermissionRequest) -> PermissionRequest {
|
||||
let schema::PermissionRequest {
|
||||
always,
|
||||
id,
|
||||
metadata,
|
||||
patterns,
|
||||
permission,
|
||||
session_id,
|
||||
tool,
|
||||
} = request;
|
||||
PermissionRequest {
|
||||
id: id.clone().into(),
|
||||
session_id: session_id.clone().into(),
|
||||
permission: permission.clone(),
|
||||
patterns: patterns.clone(),
|
||||
metadata: metadata.clone(),
|
||||
always: always.clone(),
|
||||
tool: tool.as_ref().map(|tool| {
|
||||
let schema::PermissionRequestTool { message_id, call_id } = tool;
|
||||
PermissionToolRef {
|
||||
message_id: message_id.clone(),
|
||||
call_id: call_id.clone(),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn question_request_to_opencode(request: &QuestionRequest) -> Result<schema::QuestionRequest, ConversionError> {
|
||||
let id = schema::QuestionRequestId::try_from(request.id.as_str())
|
||||
.map_err(|err| ConversionError::InvalidValue(err.to_string()))?;
|
||||
let session_id = schema::QuestionRequestSessionId::try_from(request.session_id.as_str())
|
||||
.map_err(|err| ConversionError::InvalidValue(err.to_string()))?;
|
||||
let questions = request
|
||||
.questions
|
||||
.iter()
|
||||
.map(|question| schema::QuestionInfo {
|
||||
question: question.question.clone(),
|
||||
header: question
|
||||
.header
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Question".to_string()),
|
||||
options: question
|
||||
.options
|
||||
.iter()
|
||||
.map(|opt| schema::QuestionOption {
|
||||
label: opt.label.clone(),
|
||||
description: opt.description.clone().unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
multiple: question.multi_select,
|
||||
custom: question.custom,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(schema::QuestionRequest {
|
||||
id,
|
||||
session_id,
|
||||
questions,
|
||||
tool: request.tool.as_ref().map(|tool| schema::QuestionRequestTool {
|
||||
message_id: tool.message_id.clone(),
|
||||
call_id: tool.call_id.clone(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_request_to_opencode(
|
||||
request: &PermissionRequest,
|
||||
) -> Result<schema::PermissionRequest, ConversionError> {
|
||||
let id = schema::PermissionRequestId::try_from(request.id.as_str())
|
||||
.map_err(|err| ConversionError::InvalidValue(err.to_string()))?;
|
||||
let session_id = schema::PermissionRequestSessionId::try_from(request.session_id.as_str())
|
||||
.map_err(|err| ConversionError::InvalidValue(err.to_string()))?;
|
||||
Ok(schema::PermissionRequest {
|
||||
id,
|
||||
session_id,
|
||||
permission: request.permission.clone(),
|
||||
patterns: request.patterns.clone(),
|
||||
metadata: request.metadata.clone(),
|
||||
always: request.always.clone(),
|
||||
tool: request.tool.as_ref().map(|tool| schema::PermissionRequestTool {
|
||||
message_id: tool.message_id.clone(),
|
||||
call_id: tool.call_id.clone(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
328
server/packages/universal-agent-schema/src/lib.rs
Normal file
328
server/packages/universal-agent-schema/src/lib.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use schemars::JsonSchema;
|
||||
use thiserror::Error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub use sandbox_agent_agent_schema::{amp, claude, codex, opencode};
|
||||
|
||||
pub mod agents;
|
||||
|
||||
pub use agents::{amp as convert_amp, claude as convert_claude, codex as convert_codex, opencode as convert_opencode};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UniversalEvent {
|
||||
pub id: u64,
|
||||
pub timestamp: String,
|
||||
pub session_id: String,
|
||||
pub agent: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_session_id: Option<String>,
|
||||
pub data: UniversalEventData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum UniversalEventData {
|
||||
Message { message: UniversalMessage },
|
||||
Started { started: Started },
|
||||
Error { error: CrashInfo },
|
||||
QuestionAsked {
|
||||
#[serde(rename = "questionAsked")]
|
||||
question_asked: QuestionRequest,
|
||||
},
|
||||
PermissionAsked {
|
||||
#[serde(rename = "permissionAsked")]
|
||||
permission_asked: PermissionRequest,
|
||||
},
|
||||
Unknown { raw: Value },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Started {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CrashInfo {
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
pub struct UniversalMessageParsed {
|
||||
pub role: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Map::is_empty")]
|
||||
pub metadata: Map<String, Value>,
|
||||
pub parts: Vec<UniversalMessagePart>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum UniversalMessage {
|
||||
Parsed(UniversalMessageParsed),
|
||||
Unparsed {
|
||||
raw: Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum UniversalMessagePart {
|
||||
Text { text: String },
|
||||
ToolCall {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
ToolResult {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
output: Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
is_error: Option<bool>,
|
||||
},
|
||||
FunctionCall {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
arguments: Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
raw: Option<Value>,
|
||||
},
|
||||
FunctionResult {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
result: Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
is_error: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
raw: Option<Value>,
|
||||
},
|
||||
File {
|
||||
source: AttachmentSource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mime_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
filename: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
raw: Option<Value>,
|
||||
},
|
||||
Image {
|
||||
source: AttachmentSource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mime_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
alt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
raw: Option<Value>,
|
||||
},
|
||||
Error { message: String },
|
||||
Unknown { raw: Value },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AttachmentSource {
|
||||
Path { path: String },
|
||||
Url { url: String },
|
||||
Data {
|
||||
data: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
encoding: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionRequest {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub questions: Vec<QuestionInfo>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool: Option<QuestionToolRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionInfo {
|
||||
pub question: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub header: Option<String>,
|
||||
pub options: Vec<QuestionOption>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multi_select: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionOption {
|
||||
pub label: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionToolRef {
|
||||
pub message_id: String,
|
||||
pub call_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionRequest {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub permission: String,
|
||||
pub patterns: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Map::is_empty")]
|
||||
pub metadata: Map<String, Value>,
|
||||
pub always: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool: Option<PermissionToolRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionToolRef {
|
||||
pub message_id: String,
|
||||
pub call_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConversionError {
|
||||
#[error("unsupported conversion: {0}")]
|
||||
Unsupported(&'static str),
|
||||
#[error("missing field: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("invalid value: {0}")]
|
||||
InvalidValue(String),
|
||||
#[error("serde error: {0}")]
|
||||
Serde(String),
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ConversionError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::Serde(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventConversion {
|
||||
pub data: UniversalEventData,
|
||||
pub agent_session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl EventConversion {
|
||||
pub fn new(data: UniversalEventData) -> Self {
|
||||
Self {
|
||||
data,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_session(mut self, session_id: Option<String>) -> Self {
|
||||
self.agent_session_id = session_id;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn message_from_text(role: &str, text: String) -> UniversalMessage {
|
||||
UniversalMessage::Parsed(UniversalMessageParsed {
|
||||
role: role.to_string(),
|
||||
id: None,
|
||||
metadata: Map::new(),
|
||||
parts: vec![UniversalMessagePart::Text { text }],
|
||||
})
|
||||
}
|
||||
|
||||
fn message_from_parts(role: &str, parts: Vec<UniversalMessagePart>) -> UniversalMessage {
|
||||
UniversalMessage::Parsed(UniversalMessageParsed {
|
||||
role: role.to_string(),
|
||||
id: None,
|
||||
metadata: Map::new(),
|
||||
parts,
|
||||
})
|
||||
}
|
||||
|
||||
fn text_only_from_parts(parts: &[UniversalMessagePart]) -> Result<String, ConversionError> {
|
||||
let mut text = String::new();
|
||||
for part in parts {
|
||||
match part {
|
||||
UniversalMessagePart::Text { text: part_text } => {
|
||||
if !text.is_empty() {
|
||||
text.push_str("\n");
|
||||
}
|
||||
text.push_str(part_text);
|
||||
}
|
||||
UniversalMessagePart::ToolCall { .. } => {
|
||||
return Err(ConversionError::Unsupported("tool call part"))
|
||||
}
|
||||
UniversalMessagePart::ToolResult { .. } => {
|
||||
return Err(ConversionError::Unsupported("tool result part"))
|
||||
}
|
||||
UniversalMessagePart::FunctionCall { .. } => {
|
||||
return Err(ConversionError::Unsupported("function call part"))
|
||||
}
|
||||
UniversalMessagePart::FunctionResult { .. } => {
|
||||
return Err(ConversionError::Unsupported("function result part"))
|
||||
}
|
||||
UniversalMessagePart::File { .. } => {
|
||||
return Err(ConversionError::Unsupported("file part"))
|
||||
}
|
||||
UniversalMessagePart::Image { .. } => {
|
||||
return Err(ConversionError::Unsupported("image part"))
|
||||
}
|
||||
UniversalMessagePart::Error { .. } => {
|
||||
return Err(ConversionError::Unsupported("error part"))
|
||||
}
|
||||
UniversalMessagePart::Unknown { .. } => {
|
||||
return Err(ConversionError::Unsupported("unknown part"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if text.is_empty() {
|
||||
Err(ConversionError::MissingField("text part"))
|
||||
} else {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_message_from_value(value: &Value) -> Option<String> {
|
||||
if let Some(message) = value.get("message").and_then(Value::as_str) {
|
||||
return Some(message.to_string());
|
||||
}
|
||||
if let Some(message) = value.get("error").and_then(|v| v.get("message")).and_then(Value::as_str) {
|
||||
return Some(message.to_string());
|
||||
}
|
||||
if let Some(message) = value.get("data").and_then(|v| v.get("message")).and_then(Value::as_str) {
|
||||
return Some(message.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue