mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
feat(cli): run Hanzo Cloud agents from the terminal (dev agent)
Add `dev agent {list,run,show}` over the canonical `/v1/agents` registry
(cloud/clients/agents), reusing the signed-in hanzo.id session for auth.
- New crate `agents-client` mirrors `cloud-tasks-client`: wire types
(Agent/AgentDetail/Run matching the server contract), an `AgentsBackend`
trait, and a reqwest `HttpClient` that targets `{base}/v1/agents…`,
injects the bearer JWT via `codex_api::SharedAuthProvider`, and quotes
server errors honestly. A recorded error run (HTTP 502 with a Run body)
is surfaced as the Run, not swallowed as a bare status.
- New `cli/src/agent_cmd.rs` module wires the subcommand like the existing
`cloud` command: resolves base URL (default https://api.hanzo.ai,
override CODEX_AGENTS_BASE_URL) + AuthManager from the loaded Config,
fails secure when not signed in. `list`/`run`/`show`, each with `--json`;
`run` reads the prompt from an arg or stdin.
- Cloud-agent invocation is additive and orthogonal to the local
config-defined subprocess agents; no shared state.
Tests: 8 wiremock unit tests (list/get/run happy + recorded-error 502 +
503 server error + bearer header + path encoding). `cargo build`, `cargo
clippy -D warnings`, and `cargo test` are clean for the touched crates.
Bumps @hanzo/dev 0.6.91 -> 0.6.92 (patch) so a release can publish.
This commit is contained in:
Generated
+16
@@ -1894,6 +1894,21 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-agents-client"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-api",
|
||||
"pretty_assertions",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-analytics"
|
||||
version = "0.0.0"
|
||||
@@ -2315,6 +2330,7 @@ dependencies = [
|
||||
"assert_matches",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"codex-agents-client",
|
||||
"codex-api",
|
||||
"codex-app-server",
|
||||
"codex-app-server-daemon",
|
||||
|
||||
@@ -4,6 +4,7 @@ members = [
|
||||
"analytics",
|
||||
"agent-graph-store",
|
||||
"agent-identity",
|
||||
"agents-client",
|
||||
"backend-client",
|
||||
"bwrap",
|
||||
"ansi-escape",
|
||||
@@ -152,6 +153,7 @@ codex-app-server-test-client = { path = "app-server-test-client" }
|
||||
codex-apply-patch = { path = "apply-patch" }
|
||||
codex-arg0 = { path = "arg0" }
|
||||
codex-async-utils = { path = "async-utils" }
|
||||
codex-agents-client = { path = "agents-client" }
|
||||
codex-backend-client = { path = "backend-client" }
|
||||
codex-chatgpt = { path = "chatgpt" }
|
||||
codex-cli = { path = "cli" }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-agents-client"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_agents_client"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-api = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
wiremock = { workspace = true }
|
||||
@@ -0,0 +1,24 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::Agent;
|
||||
use crate::AgentDetail;
|
||||
use crate::Result;
|
||||
use crate::Run;
|
||||
|
||||
type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
|
||||
|
||||
/// The org-scoped cloud agents registry. Object-safe (boxed futures) so it can
|
||||
/// be held as `Arc<dyn AgentsBackend>`, matching the sibling cloud-tasks crate.
|
||||
pub trait AgentsBackend: Send + Sync {
|
||||
/// List the calling org's agents (`GET /v1/agents`).
|
||||
fn list_agents(&self) -> BackendFuture<'_, Vec<Agent>>;
|
||||
|
||||
/// Fetch one agent with its instructions and recent runs
|
||||
/// (`GET /v1/agents/:name`).
|
||||
fn get_agent<'a>(&'a self, name: &'a str) -> BackendFuture<'a, AgentDetail>;
|
||||
|
||||
/// Run an agent against `input` and return the recorded run
|
||||
/// (`POST /v1/agents/:name/run`).
|
||||
fn run_agent<'a>(&'a self, name: &'a str, input: &'a str) -> BackendFuture<'a, Run>;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
use codex_api::SharedAuthProvider;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::AgentsError;
|
||||
use crate::Result;
|
||||
use crate::api::AgentsBackend;
|
||||
use crate::model_provider_unauthenticated;
|
||||
use crate::types::Agent;
|
||||
use crate::types::AgentDetail;
|
||||
use crate::types::Run;
|
||||
|
||||
/// HTTP client for the `/v1/agents` surface. Holds the API base and an auth
|
||||
/// provider that injects the bearer JWT; the gateway derives `X-Org-Id` from it.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpClient {
|
||||
base_url: String,
|
||||
http: reqwest::Client,
|
||||
auth: SharedAuthProvider,
|
||||
user_agent: Option<String>,
|
||||
}
|
||||
|
||||
/// The server's error envelope: `{"status": N, "code": "...", "error": "msg"}`.
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorEnvelope {
|
||||
#[serde(default)]
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AgentsList {
|
||||
#[serde(default)]
|
||||
agents: Vec<Agent>,
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
/// Build a client for `base_url` (e.g. `https://api.hanzo.ai`). Trailing
|
||||
/// slashes are trimmed; requests target `{base_url}/v1/agents…`.
|
||||
pub fn new(base_url: impl Into<String>) -> Result<Self> {
|
||||
let mut base_url = base_url.into();
|
||||
while base_url.ends_with('/') {
|
||||
base_url.pop();
|
||||
}
|
||||
let http = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(|e| AgentsError::Http(e.to_string()))?;
|
||||
Ok(Self {
|
||||
base_url,
|
||||
http,
|
||||
auth: model_provider_unauthenticated(),
|
||||
user_agent: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Attach the auth provider whose bearer token authenticates every request.
|
||||
pub fn with_auth_provider(mut self, auth: SharedAuthProvider) -> Self {
|
||||
self.auth = auth;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the `User-Agent` header sent on every request.
|
||||
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
|
||||
self.user_agent = Some(ua.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
|
||||
let mut req = self.http.request(method, url);
|
||||
if let Some(ua) = &self.user_agent {
|
||||
req = req.header(reqwest::header::USER_AGENT, ua);
|
||||
}
|
||||
req.headers(self.auth.to_auth_headers())
|
||||
}
|
||||
|
||||
/// Send `req`, returning `(status, body)` regardless of status code.
|
||||
async fn send(req: reqwest::RequestBuilder) -> Result<(StatusCode, String)> {
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AgentsError::Http(e.to_string()))?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AgentsError::Http(e.to_string()))?;
|
||||
Ok((status, body))
|
||||
}
|
||||
|
||||
/// Turn a non-success `(status, body)` into a quoted server error. Prefers
|
||||
/// the `{error}` envelope message; falls back to the raw body.
|
||||
fn server_error(status: StatusCode, body: &str) -> AgentsError {
|
||||
let msg = serde_json::from_str::<ErrorEnvelope>(body)
|
||||
.ok()
|
||||
.map(|e| e.error)
|
||||
.filter(|m| !m.is_empty())
|
||||
.unwrap_or_else(|| body.trim().to_string());
|
||||
if msg.is_empty() {
|
||||
AgentsError::Server(format!("server returned {status}"))
|
||||
} else {
|
||||
AgentsError::Server(format!("{status}: {msg}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode<T: DeserializeOwned>(body: &str) -> Result<T> {
|
||||
serde_json::from_str(body).map_err(|e| AgentsError::Decode(format!("{e}; body={body}")))
|
||||
}
|
||||
|
||||
async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
|
||||
let (status, body) = Self::send(self.request(reqwest::Method::GET, url)).await?;
|
||||
if !status.is_success() {
|
||||
return Err(Self::server_error(status, &body));
|
||||
}
|
||||
Self::decode(&body)
|
||||
}
|
||||
|
||||
pub(crate) async fn list(&self) -> Result<Vec<Agent>> {
|
||||
let url = format!("{}/v1/agents", self.base_url);
|
||||
let list: AgentsList = self.get_json(&url).await?;
|
||||
Ok(list.agents)
|
||||
}
|
||||
|
||||
pub(crate) async fn get(&self, name: &str) -> Result<AgentDetail> {
|
||||
let url = format!("{}/v1/agents/{}", self.base_url, encode_segment(name));
|
||||
self.get_json(&url).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run(&self, name: &str, input: &str) -> Result<Run> {
|
||||
let url = format!("{}/v1/agents/{}/run", self.base_url, encode_segment(name));
|
||||
let req = self
|
||||
.request(reqwest::Method::POST, &url)
|
||||
.json(&serde_json::json!({ "input": input }));
|
||||
let (status, body) = Self::send(req).await?;
|
||||
// A recorded error run comes back with a Run body (HTTP 502) — surface it
|
||||
// honestly rather than as a bare status. Only fall back to the error
|
||||
// envelope when the body is not a Run (e.g. 403/404/503).
|
||||
match serde_json::from_str::<Run>(&body) {
|
||||
Ok(run) => Ok(run),
|
||||
Err(decode_err) => {
|
||||
if status.is_success() {
|
||||
Err(AgentsError::Decode(format!("{decode_err}; body={body}")))
|
||||
} else {
|
||||
Err(Self::server_error(status, &body))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode a path segment's reserved characters. Agent names are already
|
||||
/// constrained server-side to `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`; this is a
|
||||
/// boundary safeguard against a stray `/` or space corrupting the path.
|
||||
fn encode_segment(seg: &str) -> String {
|
||||
let mut out = String::with_capacity(seg.len());
|
||||
for b in seg.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl AgentsBackend for HttpClient {
|
||||
fn list_agents(
|
||||
&self,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<Agent>>> + Send + '_>> {
|
||||
Box::pin(async move { self.list().await })
|
||||
}
|
||||
|
||||
fn get_agent<'a>(
|
||||
&'a self,
|
||||
name: &'a str,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<AgentDetail>> + Send + 'a>> {
|
||||
Box::pin(async move { self.get(name).await })
|
||||
}
|
||||
|
||||
fn run_agent<'a>(
|
||||
&'a self,
|
||||
name: &'a str,
|
||||
input: &'a str,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Run>> + Send + 'a>> {
|
||||
Box::pin(async move { self.run(name, input).await })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_api::AuthProvider;
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderValue;
|
||||
use serde_json::json;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::body_json;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A test auth provider that injects a fixed bearer token — the same shape
|
||||
/// the real `BearerAuthProvider` produces from a hanzo.id JWT.
|
||||
#[derive(Debug)]
|
||||
struct StaticBearer(&'static str);
|
||||
|
||||
impl AuthProvider for StaticBearer {
|
||||
fn add_auth_headers(&self, headers: &mut HeaderMap) {
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {}", self.0)).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn client(base: &str) -> HttpClient {
|
||||
HttpClient::new(base)
|
||||
.unwrap()
|
||||
.with_auth_provider(Arc::new(StaticBearer("jwt-123")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_segment_passes_valid_names_and_escapes_separators() {
|
||||
assert_eq!(encode_segment("helper"), "helper");
|
||||
assert_eq!(encode_segment("my.agent-1_x"), "my.agent-1_x");
|
||||
assert_eq!(encode_segment("a/b c"), "a%2Fb%20c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_trims_trailing_slashes() {
|
||||
let c = HttpClient::new("https://api.hanzo.ai///").unwrap();
|
||||
assert_eq!(c.base_url, "https://api.hanzo.ai");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sends_bearer_and_parses_agents_envelope() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/agents"))
|
||||
.and(header(AUTHORIZATION.as_str(), "Bearer jwt-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"agents": [{
|
||||
"id": "agent_1", "name": "helper", "model": "gpt-4o-mini",
|
||||
"description": "be terse", "tools": ["search"], "status": "ready",
|
||||
"runs": 3, "createdAt": "2026-01-01T00:00:00Z",
|
||||
"updatedAt": "2026-01-02T00:00:00Z"
|
||||
}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let agents = client(&server.uri()).list().await.unwrap();
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0].name, "helper");
|
||||
assert_eq!(agents[0].model, "gpt-4o-mini");
|
||||
assert_eq!(agents[0].tools, vec!["search".to_string()]);
|
||||
assert_eq!(agents[0].runs, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_forbidden_quotes_the_server_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/agents"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(403)
|
||||
.set_body_json(json!({ "status": 403, "error": "X-Org-Id required" })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client(&server.uri()).list().await.unwrap_err();
|
||||
assert!(matches!(err, AgentsError::Server(_)));
|
||||
assert!(
|
||||
err.to_string().contains("X-Org-Id required"),
|
||||
"error should quote the server: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_parses_flattened_detail() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/agents/helper"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": "agent_1", "name": "helper", "model": "m", "status": "ready",
|
||||
"tools": [], "runs": 0, "instructions": "be terse",
|
||||
"recentRuns": [{ "id": "run_1", "status": "ok", "model": "m",
|
||||
"input": "hi", "output": "yo", "durationMs": 12 }]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let detail = client(&server.uri()).get("helper").await.unwrap();
|
||||
assert_eq!(detail.agent.name, "helper");
|
||||
assert_eq!(detail.instructions, "be terse");
|
||||
assert_eq!(detail.recent_runs.len(), 1);
|
||||
assert_eq!(detail.recent_runs[0].output, "yo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_posts_input_and_returns_ok_run() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/agents/helper/run"))
|
||||
.and(header(AUTHORIZATION.as_str(), "Bearer jwt-123"))
|
||||
.and(body_json(json!({ "input": "hi" })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": "run_1", "status": "ok", "model": "m",
|
||||
"input": "hi", "output": "the answer", "durationMs": 42,
|
||||
"createdAt": "2026-01-01T00:00:00Z"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let run = client(&server.uri()).run("helper", "hi").await.unwrap();
|
||||
assert!(run.is_ok());
|
||||
assert_eq!(run.output, "the answer");
|
||||
assert_eq!(run.duration_ms, 42);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_recorded_error_502_is_surfaced_as_a_run_not_an_error() {
|
||||
// The server records a real failed run and returns it with HTTP 502.
|
||||
// The client must surface the recorded run so the caller can report the
|
||||
// honest upstream failure — not swallow it as a bare status error.
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/agents/helper/run"))
|
||||
.respond_with(ResponseTemplate::new(502).set_body_json(json!({
|
||||
"id": "run_2", "status": "error", "model": "m",
|
||||
"input": "hi", "error": "upstream model timeout", "durationMs": 5
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let run = client(&server.uri()).run("helper", "hi").await.unwrap();
|
||||
assert!(!run.is_ok());
|
||||
assert_eq!(run.status, "error");
|
||||
assert_eq!(run.error, "upstream model timeout");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_service_unavailable_without_run_body_is_a_server_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/agents/helper/run"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_json(
|
||||
json!({ "status": 503, "error": "inference is not configured on this deployment" }),
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client(&server.uri()).run("helper", "hi").await.unwrap_err();
|
||||
assert!(matches!(err, AgentsError::Server(_)));
|
||||
assert!(err.to_string().contains("inference is not configured"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Client for the Hanzo Cloud `/v1/agents` registry — per-org autonomous agent
|
||||
//! definitions and their runs. Mirrors the shape of `codex-cloud-tasks-client`:
|
||||
//! a backend trait, wire types, and a `reqwest`-based HTTP implementation.
|
||||
//!
|
||||
//! The canonical server contract (repo `cloud/clients/agents`) is:
|
||||
//! GET /v1/agents -> { "agents": [Agent, ...] }
|
||||
//! GET /v1/agents/:name -> AgentDetail
|
||||
//! POST /v1/agents/:name/run -> Run (body { "input": ... })
|
||||
//! GET /v1/agents/:name/runs -> { "runs": [Run, ...] }
|
||||
//!
|
||||
//! Tenant isolation is the gateway-minted `X-Org-Id` derived from the caller's
|
||||
//! validated IAM owner claim (HIP-0026); this client only sends the bearer JWT
|
||||
//! via the supplied [`codex_api::SharedAuthProvider`] and never sets the org
|
||||
//! header itself.
|
||||
|
||||
mod api;
|
||||
mod client;
|
||||
mod types;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use api::AgentsBackend;
|
||||
pub use client::HttpClient;
|
||||
pub use types::Agent;
|
||||
pub use types::AgentDetail;
|
||||
pub use types::Run;
|
||||
|
||||
/// Default provider for an unauthenticated client: adds no headers. A real
|
||||
/// caller replaces it via [`HttpClient::with_auth_provider`].
|
||||
#[derive(Debug)]
|
||||
struct Unauthenticated;
|
||||
|
||||
impl codex_api::AuthProvider for Unauthenticated {
|
||||
fn add_auth_headers(&self, _headers: &mut reqwest::header::HeaderMap) {}
|
||||
}
|
||||
|
||||
pub(crate) fn model_provider_unauthenticated() -> codex_api::SharedAuthProvider {
|
||||
Arc::new(Unauthenticated)
|
||||
}
|
||||
|
||||
/// Errors surfaced by the agents backend.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AgentsError {
|
||||
/// The request could not be sent or the response could not be read.
|
||||
#[error("http error: {0}")]
|
||||
Http(String),
|
||||
/// The server returned a non-success status; the message quotes the server.
|
||||
#[error("{0}")]
|
||||
Server(String),
|
||||
/// The response body could not be decoded into the expected shape.
|
||||
#[error("decode error: {0}")]
|
||||
Decode(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AgentsError>;
|
||||
@@ -0,0 +1,65 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
/// A cloud agent as returned by `GET /v1/agents` (the `agentView` contract).
|
||||
/// `org` is intentionally absent: it is the tenant key and never leaves the
|
||||
/// server.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Agent {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub runs: u64,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// `GET /v1/agents/:name` — an [`Agent`] plus its instructions and recent runs.
|
||||
/// The server embeds the agent view inline, so the fields are flattened here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentDetail {
|
||||
#[serde(flatten)]
|
||||
pub agent: Agent,
|
||||
#[serde(default)]
|
||||
pub instructions: String,
|
||||
#[serde(default)]
|
||||
pub recent_runs: Vec<Run>,
|
||||
}
|
||||
|
||||
/// A single recorded run (`runView`). Every run reflects an execution that
|
||||
/// actually happened: `status` is `"ok"` with `output`, or `"error"` with
|
||||
/// `error` set.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Run {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub input: String,
|
||||
#[serde(default)]
|
||||
pub output: String,
|
||||
#[serde(default)]
|
||||
pub error: String,
|
||||
#[serde(default)]
|
||||
pub duration_ms: i64,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// True when the run completed successfully.
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.status == "ok"
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ workspace = true
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
clap_complete = { workspace = true }
|
||||
codex-agents-client = { workspace = true }
|
||||
codex-app-server = { workspace = true }
|
||||
codex-app-server-daemon = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! `dev agent` — run Hanzo Cloud agents from the terminal over the canonical
|
||||
//! `/v1/agents` registry. This is the CLOUD-agent surface; it is orthogonal to
|
||||
//! the local config-defined subprocess agents and shares no state with them.
|
||||
//!
|
||||
//! Auth reuses the signed-in hanzo.id session (the same `AuthManager` the cloud
|
||||
//! subcommand uses): the bearer JWT is sent on every request and the gateway
|
||||
//! derives the tenant `X-Org-Id` from it. Base URL defaults to the same Hanzo
|
||||
//! API host the rest of the CLI talks to and is overridable for testing.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::anyhow;
|
||||
use clap::Parser;
|
||||
use clap::Subcommand;
|
||||
use codex_agents_client::AgentsBackend;
|
||||
use codex_agents_client::HttpClient;
|
||||
use codex_core::config::Config;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::default_client::get_codex_user_agent;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Read;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.hanzo.ai";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct AgentCli {
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: AgentCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum AgentCommand {
|
||||
/// List the org's cloud agents.
|
||||
#[clap(visible_alias = "ls")]
|
||||
List(ListArgs),
|
||||
|
||||
/// Run a cloud agent against a prompt and print its output.
|
||||
Run(RunArgs),
|
||||
|
||||
/// Show a cloud agent's configuration and recent runs.
|
||||
Show(ShowArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct ListArgs {
|
||||
/// Emit the raw JSON response instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct RunArgs {
|
||||
/// Agent name (org-unique handle).
|
||||
pub name: String,
|
||||
|
||||
/// Prompt to send. Reads stdin when omitted or set to `-`.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Emit the run as JSON instead of just its output.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct ShowArgs {
|
||||
/// Agent name (org-unique handle).
|
||||
pub name: String,
|
||||
|
||||
/// Emit the agent detail as JSON.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
/// Entry point for the `dev agent` subcommand.
|
||||
pub async fn run_main(cli: AgentCli) -> anyhow::Result<()> {
|
||||
let backend = init_backend(&cli.config_overrides).await?;
|
||||
match cli.command {
|
||||
AgentCommand::List(args) => run_list(&backend, args).await,
|
||||
AgentCommand::Run(args) => run_run(&backend, args).await,
|
||||
AgentCommand::Show(args) => run_show(&backend, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn init_backend(overrides: &CliConfigOverrides) -> anyhow::Result<HttpClient> {
|
||||
let base_url =
|
||||
std::env::var("CODEX_AGENTS_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
|
||||
|
||||
let parsed_overrides = overrides
|
||||
.parse_overrides()
|
||||
.map_err(|e| anyhow!("failed to parse -c overrides: {e}"))?;
|
||||
let config = Config::load_with_cli_overrides(parsed_overrides)
|
||||
.await
|
||||
.context("failed to load configuration")?;
|
||||
|
||||
let auth_manager = AuthManager::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
Some(config.chatgpt_base_url.clone()),
|
||||
config.auth_keyring_backend_kind(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth = auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("Not signed in. Run `dev login` to sign in, then retry."))?;
|
||||
|
||||
let auth_provider = codex_model_provider::auth_provider_from_auth(&auth);
|
||||
Ok(HttpClient::new(base_url)?
|
||||
.with_user_agent(get_codex_user_agent())
|
||||
.with_auth_provider(auth_provider))
|
||||
}
|
||||
|
||||
async fn run_list(backend: &HttpClient, args: ListArgs) -> anyhow::Result<()> {
|
||||
let agents = backend.list_agents().await?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&agents)?);
|
||||
return Ok(());
|
||||
}
|
||||
if agents.is_empty() {
|
||||
println!("No agents found.");
|
||||
return Ok(());
|
||||
}
|
||||
let name_w = agents
|
||||
.iter()
|
||||
.map(|a| a.name.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
let model_w = agents
|
||||
.iter()
|
||||
.map(|a| a.model.len())
|
||||
.max()
|
||||
.unwrap_or(5)
|
||||
.max(5);
|
||||
println!(
|
||||
"{:<name_w$} {:<model_w$} {:<8} RUNS",
|
||||
"NAME", "MODEL", "STATUS"
|
||||
);
|
||||
for a in &agents {
|
||||
println!(
|
||||
"{:<name_w$} {:<model_w$} {:<8} {}",
|
||||
a.name, a.model, a.status, a.runs
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_run(backend: &HttpClient, args: RunArgs) -> anyhow::Result<()> {
|
||||
let input = resolve_prompt(args.prompt)?;
|
||||
let run = backend.run_agent(&args.name, &input).await?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&run)?);
|
||||
} else if run.is_ok() {
|
||||
println!("{}", run.output);
|
||||
} else {
|
||||
// The run executed and was recorded as a failure; report it honestly.
|
||||
return Err(anyhow!(
|
||||
"agent run failed ({}): {}",
|
||||
run.status,
|
||||
if run.error.is_empty() {
|
||||
"<no error message>"
|
||||
} else {
|
||||
&run.error
|
||||
}
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_show(backend: &HttpClient, args: ShowArgs) -> anyhow::Result<()> {
|
||||
let detail = backend.get_agent(&args.name).await?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&detail)?);
|
||||
return Ok(());
|
||||
}
|
||||
let a = &detail.agent;
|
||||
println!("{} ({})", a.name, a.status);
|
||||
println!("model: {}", a.model);
|
||||
if !a.description.is_empty() {
|
||||
println!("about: {}", a.description);
|
||||
}
|
||||
if !a.tools.is_empty() {
|
||||
println!("tools: {}", a.tools.join(", "));
|
||||
}
|
||||
if !detail.instructions.is_empty() {
|
||||
println!("\ninstructions:\n{}", detail.instructions);
|
||||
}
|
||||
if !detail.recent_runs.is_empty() {
|
||||
println!("\nrecent runs:");
|
||||
for r in &detail.recent_runs {
|
||||
println!(" [{}] {} ({}ms)", r.status, r.created_at, r.duration_ms);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the run prompt: from the argument, or stdin when omitted or `-`.
|
||||
fn resolve_prompt(prompt: Option<String>) -> anyhow::Result<String> {
|
||||
match prompt {
|
||||
Some(p) if p != "-" => Ok(p),
|
||||
maybe_dash => {
|
||||
let force_stdin = matches!(maybe_dash.as_deref(), Some("-"));
|
||||
if std::io::stdin().is_terminal() && !force_stdin {
|
||||
return Err(anyhow!(
|
||||
"no prompt provided. Pass one as an argument or pipe it via stdin."
|
||||
));
|
||||
}
|
||||
let mut buffer = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buffer)
|
||||
.map_err(|e| anyhow!("failed to read prompt from stdin: {e}"))?;
|
||||
if buffer.trim().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"no prompt provided via stdin (received empty input)."
|
||||
));
|
||||
}
|
||||
Ok(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use supports_color::Stream;
|
||||
|
||||
mod agent_cmd;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
mod app_cmd;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
@@ -61,6 +62,7 @@ mod state_db_recovery;
|
||||
#[cfg(not(windows))]
|
||||
mod wsl_paths;
|
||||
|
||||
use crate::agent_cmd::AgentCli;
|
||||
use crate::mcp_cmd::McpCli;
|
||||
use crate::plugin_cmd::PluginCli;
|
||||
use crate::plugin_cmd::PluginSubcommand;
|
||||
@@ -198,6 +200,9 @@ enum Subcommand {
|
||||
#[clap(name = "cloud", alias = "cloud-tasks")]
|
||||
Cloud(CloudTasksCli),
|
||||
|
||||
/// Run Hanzo Cloud agents from the terminal.
|
||||
Agent(AgentCli),
|
||||
|
||||
/// Internal: run the responses API proxy.
|
||||
#[clap(hide = true)]
|
||||
ResponsesApiProxy(ResponsesApiProxyArgs),
|
||||
@@ -1498,6 +1503,18 @@ async fn cli_main(
|
||||
codex_cloud_tasks::run_main(cloud_cli, arg0_paths.codex_linux_sandbox_exe.clone())
|
||||
.await?;
|
||||
}
|
||||
Some(Subcommand::Agent(mut agent_cli)) => {
|
||||
reject_remote_mode_for_subcommand(
|
||||
root_remote.as_deref(),
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
"agent",
|
||||
)?;
|
||||
prepend_config_flags(
|
||||
&mut agent_cli.config_overrides,
|
||||
root_config_overrides.clone(),
|
||||
);
|
||||
agent_cmd::run_main(agent_cli).await?;
|
||||
}
|
||||
Some(Subcommand::Sandbox(mut sandbox_cli)) => {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(setup_cli) = sandbox_setup::parse_setup_command(&sandbox_cli.command)? {
|
||||
@@ -2180,6 +2197,7 @@ fn unsupported_subcommand_name_for_strict_config(
|
||||
Some(Subcommand::Completion(_)) => Some("completion"),
|
||||
Some(Subcommand::Update) => Some("update"),
|
||||
Some(Subcommand::Cloud(_)) => Some("cloud"),
|
||||
Some(Subcommand::Agent(_)) => Some("agent"),
|
||||
Some(Subcommand::Sandbox(_)) => Some("sandbox"),
|
||||
Some(Subcommand::Debug(_)) => Some("debug"),
|
||||
Some(Subcommand::Execpolicy(_)) => Some("execpolicy"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/dev",
|
||||
"version": "0.6.91",
|
||||
"version": "0.6.92",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Hanzo AI coding assistant - intelligent CLI for developers",
|
||||
"bin": {
|
||||
@@ -35,10 +35,10 @@
|
||||
"prettier": "^3.3.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@hanzo/dev-darwin-arm64": "0.6.91",
|
||||
"@hanzo/dev-darwin-x64": "0.6.91",
|
||||
"@hanzo/dev-linux-x64-musl": "0.6.91",
|
||||
"@hanzo/dev-linux-arm64-musl": "0.6.91",
|
||||
"@hanzo/dev-win32-x64": "0.6.91"
|
||||
"@hanzo/dev-darwin-arm64": "0.6.92",
|
||||
"@hanzo/dev-darwin-x64": "0.6.92",
|
||||
"@hanzo/dev-linux-x64-musl": "0.6.92",
|
||||
"@hanzo/dev-linux-arm64-musl": "0.6.92",
|
||||
"@hanzo/dev-win32-x64": "0.6.92"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user