--- a/crates/airouter-agent-client/Cargo.toml +++ b/crates/airouter-agent-client/Cargo.toml @@ -5,6 +5,8 @@ license = "MIT" [features] +# Observation and application resources without provider engines or host drivers. +application = ["process", "dep:tempfile", "tokio/sync"] http-agent = ["native", "dep:tempfile", "dep:futures-util", "tokio/sync"] http-agent-executor = ["http-agent", "tokio/rt-multi-thread"] default = [] --- /dev/null +++ b/crates/airouter-agent-client/application.Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "airouter-agent-client" +version = "0.1.22" +edition = "2024" +license = "MIT" + +[lib] +path = "src/application.rs" + +[features] +default = [] +application = ["process", "dep:tempfile", "tokio/sync"] +process = ["dep:nix", "nix/process", "nix/signal", "nix/fs", "tokio/process", "tokio/io-util", "tokio/macros"] +# Generic contained command executor support; no provider adapters are shipped. +executor = ["process", "tokio/rt-multi-thread"] + +[dependencies] +nix = { version = "0.30", features = ["user"], optional = true } +tempfile = { version = "3", optional = true } +airouter-agent-protocol = { path = "../airouter-agent-protocol", version = "=0.1.7" } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["time", "rt", "net"] } +tokio-util = { version = "0.7", features = ["rt"] } +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] } + --- /dev/null +++ b/crates/airouter-agent-client/src/activity.rs @@ -0,0 +1,72 @@ +//! Provider-neutral service activity and result contracts. No execution policy. +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ProviderStreamEvent { + Text(String), + /// Preview of an AIRouter-reserved logical response. This is never folded + /// into another durable final answer by the application adapter. + ResponseDelta { + platform_run_id: String, + source_message_id: String, + sequence: u64, + text: String, + }, + ThinkingDelta(String), + ToolCall { + tool_use_id: String, + tool: String, + input: Value, + }, + ToolResult { + tool_use_id: String, + tool: String, + output: String, + exit_code: i32, + }, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ProviderStreamFinal { + pub text: String, + pub input_tokens: i64, + pub output_tokens: i64, + pub cost_usd: f64, + pub stop_reason: Option, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ProviderRunOutput { + pub provider: String, + pub events: Vec, + pub final_event: ProviderStreamFinal, + pub stderr: String, + pub exit_code: Option, + pub user_text: String, +} + +impl ProviderRunOutput { + pub fn errored(&self) -> bool { + self.final_event.error.is_some() || matches!(self.exit_code, Some(code) if code != 0) + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum Event { + Preview { + source_message_id: String, + sequence: u64, + text: String, + }, + Activity(ProviderStreamEvent), +} + + +/// Last acknowledged state and any stopping error, including uncertain outcomes. +#[derive(Debug)] +pub struct SessionResult { + pub run: crate::protocol::Run, + pub error: Option, +} + --- /dev/null +++ b/crates/airouter-agent-client/src/application.rs @@ -0,0 +1,17 @@ +//! AIRouter application SDK: service transport, wire data and resource authority. +//! Provider engines, native adapters and execution drivers are excluded. +pub mod activity; +pub mod resources; +pub mod communication; +#[cfg(feature = "application")] +pub mod dispatch; +#[cfg(feature = "application")] +pub mod journal; +#[cfg(feature = "application")] +pub mod session_service; +#[cfg(feature = "process")] +pub mod process; +pub use airouter_agent_protocol as protocol; +pub use tokio_util::sync::CancellationToken; +mod transport; +pub use transport::Client; --- a/crates/airouter-agent-client/src/host.rs +++ b/crates/airouter-agent-client/src/host.rs @@ -2,28 +2,10 @@ //! Owns claiming, response recovery and cancellation; hosts retain tool authority. use crate::{Client, journal::Journal, protocol::*}; use serde_json::Value; -use std::{future::Future, time::Duration}; +use std::time::Duration; use uuid::Uuid; -/// Application authorization and execution, under the application's host identity. -/// The driver never derives grants, credentials or workspace roots from tool input. -pub trait Host { - fn approval_actor(&self) -> String; - fn authorize(&self, operation: &McpOperation) -> impl Future + Send; - fn handle(&self, execution: HostExecution) -> impl Future + Send; - - /// Cancellation-aware host callback. Existing hosts remain source - /// compatible; resource-owning adapters override this to stop transport - /// work and clean up children when the run lease or deadline ends. - fn handle_cancellable( - &self, - execution: HostExecution, - _cancellation: tokio_util::sync::CancellationToken, - ) -> impl Future> + Send { - let response = self.handle(execution); - async move { Ok(response.await) } - } -} +pub use crate::resources::Host; /// Cancels adapter-owned work if the run driver future itself is dropped. pub(crate) struct CancelTokenOnDrop { @@ -64,12 +46,7 @@ }, } -/// Last acknowledged state and any stopping error, including uncertain outcomes. -#[derive(Debug)] -pub struct SessionResult { - pub run: Run, - pub error: Option, -} +pub use crate::activity::SessionResult; /// Drive a newly created or recovered run without retrying a tool execution. /// A journal permits recovery of saved results; unfinished claims require review. --- a/crates/airouter-agent-client/src/http_agent/mod.rs +++ b/crates/airouter-agent-client/src/http_agent/mod.rs @@ -48,34 +48,7 @@ pub min_p: Option, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] -pub struct LocalToolCall { - pub id: String, - pub name: String, - pub input: Value, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct LocalToolResult { - pub output: String, - pub exit_code: i32, -} - -impl LocalToolResult { - pub fn ok(output: impl Into) -> Self { - Self { - output: output.into(), - exit_code: 0, - } - } - - pub fn err(output: impl Into) -> Self { - Self { - output: output.into(), - exit_code: 1, - } - } -} +pub use crate::resources::{LocalToolCall, LocalToolResult}; #[derive(Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] --- a/crates/airouter-agent-client/src/http_agent/text.rs +++ b/crates/airouter-agent-client/src/http_agent/text.rs @@ -428,19 +428,7 @@ Ok(Some(out)) } -pub fn normalize_tool_name(tool: &str) -> &str { - match tool { - "browser_test" => "mcp__workbench__browser_test", - "rust_check" => "mcp__workbench__rust_check", - "Bash" | "BashOutput" => "bash", - "Read" => "read_file", - "Write" => "write_file", - "Edit" | "MultiEdit" => "edit_file", - "Glob" => "list_files", - "Grep" => "search_files", - other => other, - } -} +pub use crate::resources::normalize_tool_name; pub fn strip_think_markers(text: &str) -> String { let mut owned = text.to_string(); --- a/crates/airouter-agent-client/src/lib.rs +++ b/crates/airouter-agent-client/src/lib.rs @@ -1,18 +1,18 @@ //! Agent API client with an optional application-side host session driver. //! The HTTP transport never retries claims or executes application tools. -#[cfg(feature = "host")] +#[cfg(any(feature = "host", feature = "application"))] pub mod dispatch; #[cfg(feature = "host")] pub mod host; #[cfg(feature = "http-agent")] pub mod http_agent; -#[cfg(feature = "host")] +#[cfg(any(feature = "host", feature = "application"))] pub mod journal; #[cfg(feature = "host")] pub mod managed_host; #[cfg(all(feature = "native", feature = "host"))] pub mod managed_session; -#[cfg(all(feature = "native", feature = "host"))] +#[cfg(any(feature = "application", all(feature = "native", feature = "host")))] pub mod session_service; #[cfg(feature = "native")] pub mod native; @@ -22,314 +22,9 @@ pub mod session_stream; pub use airouter_agent_protocol as protocol; pub use tokio_util::sync::CancellationToken; +pub mod activity; +pub mod resources; pub mod input_context; pub mod communication; -use protocol::*; -use reqwest::Client as Http; -use serde::de::DeserializeOwned; -use std::time::Duration; -use uuid::Uuid; - -#[derive(Clone)] -pub struct Client { - http: Http, - token: String, -} - -impl Client { - pub async fn context_status(&self, id: Uuid) -> Result { - self.send(self.http.get(format!("http://agent/v1/runs/{id}/context"))).await - } - pub async fn context_input_status(&self, id: Uuid) -> Result { - self.send(self.http.get(format!("http://agent/v1/host/runs/{id}/context/inputs"))).await - } - pub async fn context_checkpoint(&self, id: Uuid, request: &lifecycle::Prepare) -> Result { - self.send(self.http.post(format!("http://agent/v1/host/runs/{id}/context/checkpoints")).json(request)).await - } - /// Persist UUID, sequence and body before delivery. Reuse them after a lost - /// response; the transport itself does not replay the POST. - pub async fn deliver_context_input(&self, id: Uuid, input: &lifecycle::Input) -> Result { - self.send(self.http.post(format!("http://agent/v1/host/runs/{id}/context/inputs")).json(input)).await - } - pub async fn authorize_communication_view(&self, id: Uuid) -> Result { - self.send(self.http.post(format!("http://agent/v1/communication/runs/{id}/view")).json(&serde_json::json!({}))).await - } - pub async fn authorize_communication_result(&self, id: Uuid, view_id: Uuid) -> Result { - self.send(self.http.post(format!("http://agent/v1/communication/runs/{id}/result")).json(&serde_json::json!({"version":protocol::communication::RESULT_VIEW_VERSION,"view_id":view_id}))).await - } - pub async fn prepare_communication(&self, request: &protocol::communication::Prepare) -> Result { - self.send(self.http.post("http://agent/v1/communication/runs").json(request)).await - } - - /// Resolve an exact server-owned policy in this application's registered scope. - pub async fn resolve_ai_policy(&self, request: &policy::Resolve) -> Result { - self.send(self.http.post("http://agent/v1/ai-policies/resolve").json(request)).await - } - - pub async fn resolve_agent_policy(&self, request: &protocol::policy_v2::Selection) -> Result { - self.send(self.http.post("http://agent/v2/ai-policies/resolve").json(request)).await - } - - pub async fn prepare_agent_policy(&self, request: &protocol::policy_v2::Prepare) -> Result { - self.send(self.http.post("http://agent/v2/host/ai-policy/runs").json(request)).await - } - - pub async fn orchestration_observe(&self, request: &protocol::orchestration::Observation) -> Result { - self.send(self.http.post("http://agent/v1/orchestration/observe").json(request)).await - } - - /// Persist this complete request before admission. Transport never retries it. - pub async fn create_with_ai_policy(&self, request: &policy::Create) -> Result { - self.send(self.http.post("http://agent/v1/ai-policy/runs").json(request)).await - } - - /// Uses the existing durable task/claim/lease/controller handoff. - pub async fn prepare_host_with_ai_policy(&self, request: &policy::Prepare) -> Result { - self.send(self.http.post("http://agent/v1/host/ai-policy/runs").timeout(Duration::from_secs(5)).json(request)).await - } - - pub async fn resolve_profile( - &self, - request: &execution::ResolveProfile, - ) -> Result { - self.send( - self.http - .post("http://agent/v1/profiles/resolve") - .timeout(Duration::from_secs(5)) - .json(request), - ) - .await - } - pub async fn prepare_host( - &self, - request: &controller::Prepare, - ) -> Result { - self.send( - self.http - .post("http://agent/v1/host/runs") - .timeout(Duration::from_secs(5)) - .json(request), - ) - .await - } - pub async fn read_host(&self, id: Uuid) -> Result { - self.send( - self.http - .get(format!("http://agent/v1/host/runs/{id}")) - .timeout(Duration::from_secs(5)), - ) - .await - } - pub async fn observe_host( - &self, - id: Uuid, - observation: &controller::Observe, - ) -> Result { - self.send( - self.http - .post(format!("http://agent/v1/host/runs/{id}/observations")) - .timeout(Duration::from_secs(5)) - .json(observation), - ) - .await - } - /// Task operations have a short deadline so lease loss cancels host work - /// before ownership expires. Transport does not retry execution claims. - pub async fn submit_task(&self, task: &task::Submit) -> Result { - self.send( - self.http - .post("http://agent/v1/tasks") - .timeout(Duration::from_secs(5)) - .json(task), - ) - .await - } - pub async fn claim_task(&self, claim: &task::Claim) -> Result, String> { - self.send( - self.http - .post("http://agent/v1/tasks/claim") - .timeout(Duration::from_secs(5)) - .json(claim), - ) - .await - } - pub async fn lookup_tasks(&self, ids: &[Uuid]) -> Result, String> { - self.send( - self.http - .post("http://agent/v1/tasks/lookup") - .timeout(Duration::from_secs(5)) - .json(ids), - ) - .await - } - pub async fn step_task(&self, id: Uuid, action: &task::Action) -> Result { - self.send( - self.http - .post(format!("http://agent/v1/tasks/{id}/step")) - .timeout(Duration::from_secs(5)) - .json(action), - ) - .await - } - pub async fn task_events(&self, id: Uuid, after: Option) -> Result, String> { - let mut request = self - .http - .get(format!("http://agent/v1/tasks/{id}/events")) - .timeout(Duration::from_secs(5)); - if let Some(after) = after { - request = request.query(&[("after", after)]); - } - self.send(request).await - } - - pub fn unix(socket: &str, token: String) -> Result { - if token.trim().len() < 32 || token.trim().len() > 512 { - return Err("invalid application credential".into()); - } - let http = Http::builder() - .unix_socket(socket) - .timeout(Duration::from_secs(400)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|_| "agent client configuration")?; - Ok(Self { - http, - token: token.trim().into(), - }) - } - - pub async fn create(&self, request: &CreateRun) -> Result { - self.send(self.http.post("http://agent/v1/runs").json(request)) - .await - } - - /// Create a child using the same grants, profile and limits contract as a root run. - pub async fn create_child(&self, parent: Uuid, request: &CreateRun) -> Result { - self.send( - self.http - .post(format!("http://agent/v1/runs/{parent}/children")) - .json(request), - ) - .await - } - - /// Resolve an application request UUID without assuming it is a runtime run UUID. - pub async fn lookup_request(&self, request: Uuid) -> Result, String> { - self.send( - self.http - .get(format!("http://agent/v1/runs/by-request/{request}")), - ) - .await - } - - /// Read one bounded page of direct children and the parent ID. - pub async fn family(&self, id: Uuid, after: Option) -> Result { - let mut request = self.http.get(format!("http://agent/v1/runs/{id}/family")); - if let Some(after) = after { - request = request.query(&[("after", after)]); - } - self.send(request).await - } - - pub async fn get(&self, id: Uuid) -> Result { - self.send(self.http.get(format!("http://agent/v1/runs/{id}"))) - .await - } - - /// Read generated messages only; input conversation is excluded by origin. - pub async fn projection(&self, id: Uuid) -> Result { - self.send( - self.http - .get(format!("http://agent/v1/runs/{id}/projection")) - .timeout(Duration::from_secs(5)), - ) - .await - } - - pub async fn step(&self, run: &Run, command: Command) -> Result { - self.send( - self.http - .post(format!("http://agent/v1/runs/{}/step", run.id)) - .json(&Step { - expected_revision: run.revision, - command, - }), - ) - .await - } - - pub async fn events(&self, id: Uuid, after: u64) -> Result, String> { - self.send( - self.http - .get(format!("http://agent/v1/runs/{id}/events?after={after}")), - ) - .await - } - - async fn send( - &self, - request: reqwest::RequestBuilder, - ) -> Result { - let mut response = request - .bearer_auth(&self.token) - .send() - .await - .map_err(|_| "agent transport failed; inspect saved run before retrying")?; - let status = response.status(); - let mut bytes = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .map_err(|_| "agent response interrupted")? - { - if bytes.len() + chunk.len() > MAX_BODY_BYTES { - return Err("agent response limit".into()); - } - bytes.extend_from_slice(&chunk); - } - if !status.is_success() { - let error = serde_json::from_slice::(&bytes) - .ok() - .map(|e| e.code) - .unwrap_or_else(|| "invalid_error".into()); - return Err(format!("agent API {}: {error}", status.as_u16())); - } - serde_json::from_slice(&bytes).map_err(|_| "invalid agent API response".into()) - } - - pub async fn prepare_context( - &self, - request: &protocol::context::PrepareContext, - ) -> Result { - self.send( - self.http - .post("http://agent/v1/context/prepare") - .json(request), - ) - .await - } - - pub async fn invalidate_context(&self, subject: &str) -> Result<(), String> { - let _: serde_json::Value = self - .send(self.http.post("http://agent/v1/context/invalidate").json( - &protocol::context::InvalidateContext { - subject: subject.into(), - }, - )) - .await?; - Ok(()) - } - - pub async fn report_outcome( - &self, - run: uuid::Uuid, - outcome: &protocol::context::TaskOutcome, - ) -> Result { - self.send( - self.http - .post(format!("http://agent/v1/runs/{run}/outcome")) - .json(outcome), - ) - .await - } -} +mod transport; +pub use transport::Client; --- a/crates/airouter-agent-client/src/managed_session.rs +++ b/crates/airouter-agent-client/src/managed_session.rs @@ -4,7 +4,7 @@ Client, host::{CancelOnDrop, CancelTokenOnDrop, SessionResult, stopped_error}, journal::Journal, - native::events::ProviderStreamEvent, + activity::ProviderStreamEvent, protocol::{ controller::{Binding, Observation, Observe, Prepared}, *, @@ -60,15 +60,7 @@ Err("session cancellation acknowledgement unavailable; reconcile original run".into()) } -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum Event { - Preview { - source_message_id: String, - sequence: u64, - text: String, - }, - Activity(ProviderStreamEvent), -} +pub use crate::activity::Event; async fn observed( client: &Client, --- a/crates/airouter-agent-client/src/native/events.rs +++ b/crates/airouter-agent-client/src/native/events.rs @@ -1,52 +1,2 @@ -use serde_json::Value; - -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum ProviderStreamEvent { - Text(String), - /// Preview of an AIRouter-reserved logical response. This is never folded - /// into another durable final answer by the application adapter. - ResponseDelta { - platform_run_id: String, - source_message_id: String, - sequence: u64, - text: String, - }, - ThinkingDelta(String), - ToolCall { - tool_use_id: String, - tool: String, - input: Value, - }, - ToolResult { - tool_use_id: String, - tool: String, - output: String, - exit_code: i32, - }, -} - -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ProviderStreamFinal { - pub text: String, - pub input_tokens: i64, - pub output_tokens: i64, - pub cost_usd: f64, - pub stop_reason: Option, - pub error: Option, -} - -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ProviderRunOutput { - pub provider: String, - pub events: Vec, - pub final_event: ProviderStreamFinal, - pub stderr: String, - pub exit_code: Option, - pub user_text: String, -} - -impl ProviderRunOutput { - pub fn errored(&self) -> bool { - self.final_event.error.is_some() || matches!(self.exit_code, Some(code) if code != 0) - } -} +//! Shared activity contract, retained at the native adapter import path. +pub use crate::activity::{ProviderRunOutput, ProviderStreamEvent, ProviderStreamFinal}; --- a/crates/airouter-agent-client/src/native/launch/codex.rs +++ b/crates/airouter-agent-client/src/native/launch/codex.rs @@ -15,17 +15,7 @@ BypassApprovalsAndSandbox, } -/// A host-selected stdio server. Environment order is retained in argv. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct McpServer { - pub name: String, - pub command: String, - pub args: Vec, - pub env: Vec<(String, String)>, - pub tool_timeout_seconds: Option, - pub default_tools_approval_mode: Option, -} +pub use crate::resources::McpServer; pub struct Settings<'a> { pub cli_path: &'a str, --- /dev/null +++ b/crates/airouter-agent-client/src/resources.rs @@ -0,0 +1,80 @@ +//! Application tool and MCP resource contracts, independent of model runtimes. +use crate::protocol::{McpOperation, HostExecution}; +use serde_json::Value; +use std::future::Future; + +/// A host-selected stdio server. Environment order is retained in argv. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct McpServer { + pub name: String, + pub command: String, + pub args: Vec, + pub env: Vec<(String, String)>, + pub tool_timeout_seconds: Option, + pub default_tools_approval_mode: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct LocalToolCall { + pub id: String, + pub name: String, + pub input: Value, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct LocalToolResult { + pub output: String, + pub exit_code: i32, +} + +impl LocalToolResult { + pub fn ok(output: impl Into) -> Self { + Self { + output: output.into(), + exit_code: 0, + } + } + + pub fn err(output: impl Into) -> Self { + Self { + output: output.into(), + exit_code: 1, + } + } +} + +pub fn normalize_tool_name(tool: &str) -> &str { + match tool { + "browser_test" => "mcp__workbench__browser_test", + "rust_check" => "mcp__workbench__rust_check", + "Bash" | "BashOutput" => "bash", + "Read" => "read_file", + "Write" => "write_file", + "Edit" | "MultiEdit" => "edit_file", + "Glob" => "list_files", + "Grep" => "search_files", + other => other, + } +} + +/// Application authorization and execution, under the application's host identity. +/// The driver never derives grants, credentials or workspace roots from tool input. +pub trait Host { + fn approval_actor(&self) -> String; + fn authorize(&self, operation: &McpOperation) -> impl Future + Send; + fn handle(&self, execution: HostExecution) -> impl Future + Send; + + /// Cancellation-aware host callback. Existing hosts remain source + /// compatible; resource-owning adapters override this to stop transport + /// work and clean up children when the run lease or deadline ends. + fn handle_cancellable( + &self, + execution: HostExecution, + _cancellation: tokio_util::sync::CancellationToken, + ) -> impl Future> + Send { + let response = self.handle(execution); + async move { Ok(response.await) } + } +} + --- a/crates/airouter-agent-client/src/session_service.rs +++ b/crates/airouter-agent-client/src/session_service.rs @@ -1,7 +1,7 @@ //! Same-Unix-account observation IPC for AIRouter's independent native driver. //! Submission retries reuse the exact body; disconnecting never cancels a job. use crate::{ - managed_session::Event, + activity::Event, process::JsonlProcessInput, protocol::{Phase, controller::Binding}, }; @@ -37,7 +37,7 @@ #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct NativeConfig { - pub mcp_servers: Vec, + pub mcp_servers: Vec, pub writable_directories: Vec, pub memory_user: Option, } @@ -55,7 +55,7 @@ #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GrokConfig { - pub mcp_servers: Vec, + pub mcp_servers: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] --- /dev/null +++ b/crates/airouter-agent-client/src/transport.rs @@ -0,0 +1,310 @@ +//! AIRouter HTTP service transport. No execution drivers. +use crate::protocol::{self, *}; +use reqwest::Client as Http; +use serde::de::DeserializeOwned; +use std::time::Duration; +use uuid::Uuid; + +#[derive(Clone)] +pub struct Client { + http: Http, + token: String, +} + +impl Client { + pub async fn context_status(&self, id: Uuid) -> Result { + self.send(self.http.get(format!("http://agent/v1/runs/{id}/context"))).await + } + pub async fn context_input_status(&self, id: Uuid) -> Result { + self.send(self.http.get(format!("http://agent/v1/host/runs/{id}/context/inputs"))).await + } + pub async fn context_checkpoint(&self, id: Uuid, request: &lifecycle::Prepare) -> Result { + self.send(self.http.post(format!("http://agent/v1/host/runs/{id}/context/checkpoints")).json(request)).await + } + /// Persist UUID, sequence and body before delivery. Reuse them after a lost + /// response; the transport itself does not replay the POST. + pub async fn deliver_context_input(&self, id: Uuid, input: &lifecycle::Input) -> Result { + self.send(self.http.post(format!("http://agent/v1/host/runs/{id}/context/inputs")).json(input)).await + } + pub async fn authorize_communication_view(&self, id: Uuid) -> Result { + self.send(self.http.post(format!("http://agent/v1/communication/runs/{id}/view")).json(&serde_json::json!({}))).await + } + pub async fn authorize_communication_result(&self, id: Uuid, view_id: Uuid) -> Result { + self.send(self.http.post(format!("http://agent/v1/communication/runs/{id}/result")).json(&serde_json::json!({"version":protocol::communication::RESULT_VIEW_VERSION,"view_id":view_id}))).await + } + pub async fn prepare_communication(&self, request: &protocol::communication::Prepare) -> Result { + self.send(self.http.post("http://agent/v1/communication/runs").json(request)).await + } + + /// Resolve an exact server-owned policy in this application's registered scope. + pub async fn resolve_ai_policy(&self, request: &policy::Resolve) -> Result { + self.send(self.http.post("http://agent/v1/ai-policies/resolve").json(request)).await + } + + pub async fn resolve_agent_policy(&self, request: &protocol::policy_v2::Selection) -> Result { + self.send(self.http.post("http://agent/v2/ai-policies/resolve").json(request)).await + } + + pub async fn prepare_agent_policy(&self, request: &protocol::policy_v2::Prepare) -> Result { + self.send(self.http.post("http://agent/v2/host/ai-policy/runs").json(request)).await + } + + pub async fn orchestration_observe(&self, request: &protocol::orchestration::Observation) -> Result { + self.send(self.http.post("http://agent/v1/orchestration/observe").json(request)).await + } + + /// Persist this complete request before admission. Transport never retries it. + pub async fn create_with_ai_policy(&self, request: &policy::Create) -> Result { + self.send(self.http.post("http://agent/v1/ai-policy/runs").json(request)).await + } + + /// Uses the existing durable task/claim/lease/controller handoff. + pub async fn prepare_host_with_ai_policy(&self, request: &policy::Prepare) -> Result { + self.send(self.http.post("http://agent/v1/host/ai-policy/runs").timeout(Duration::from_secs(5)).json(request)).await + } + + pub async fn resolve_profile( + &self, + request: &execution::ResolveProfile, + ) -> Result { + self.send( + self.http + .post("http://agent/v1/profiles/resolve") + .timeout(Duration::from_secs(5)) + .json(request), + ) + .await + } + pub async fn prepare_host( + &self, + request: &controller::Prepare, + ) -> Result { + self.send( + self.http + .post("http://agent/v1/host/runs") + .timeout(Duration::from_secs(5)) + .json(request), + ) + .await + } + pub async fn read_host(&self, id: Uuid) -> Result { + self.send( + self.http + .get(format!("http://agent/v1/host/runs/{id}")) + .timeout(Duration::from_secs(5)), + ) + .await + } + pub async fn observe_host( + &self, + id: Uuid, + observation: &controller::Observe, + ) -> Result { + self.send( + self.http + .post(format!("http://agent/v1/host/runs/{id}/observations")) + .timeout(Duration::from_secs(5)) + .json(observation), + ) + .await + } + /// Task operations have a short deadline so lease loss cancels host work + /// before ownership expires. Transport does not retry execution claims. + pub async fn submit_task(&self, task: &task::Submit) -> Result { + self.send( + self.http + .post("http://agent/v1/tasks") + .timeout(Duration::from_secs(5)) + .json(task), + ) + .await + } + pub async fn claim_task(&self, claim: &task::Claim) -> Result, String> { + self.send( + self.http + .post("http://agent/v1/tasks/claim") + .timeout(Duration::from_secs(5)) + .json(claim), + ) + .await + } + pub async fn lookup_tasks(&self, ids: &[Uuid]) -> Result, String> { + self.send( + self.http + .post("http://agent/v1/tasks/lookup") + .timeout(Duration::from_secs(5)) + .json(ids), + ) + .await + } + pub async fn step_task(&self, id: Uuid, action: &task::Action) -> Result { + self.send( + self.http + .post(format!("http://agent/v1/tasks/{id}/step")) + .timeout(Duration::from_secs(5)) + .json(action), + ) + .await + } + pub async fn task_events(&self, id: Uuid, after: Option) -> Result, String> { + let mut request = self + .http + .get(format!("http://agent/v1/tasks/{id}/events")) + .timeout(Duration::from_secs(5)); + if let Some(after) = after { + request = request.query(&[("after", after)]); + } + self.send(request).await + } + + pub fn unix(socket: &str, token: String) -> Result { + if token.trim().len() < 32 || token.trim().len() > 512 { + return Err("invalid application credential".into()); + } + let http = Http::builder() + .unix_socket(socket) + .timeout(Duration::from_secs(400)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "agent client configuration")?; + Ok(Self { + http, + token: token.trim().into(), + }) + } + + pub async fn create(&self, request: &CreateRun) -> Result { + self.send(self.http.post("http://agent/v1/runs").json(request)) + .await + } + + /// Create a child using the same grants, profile and limits contract as a root run. + pub async fn create_child(&self, parent: Uuid, request: &CreateRun) -> Result { + self.send( + self.http + .post(format!("http://agent/v1/runs/{parent}/children")) + .json(request), + ) + .await + } + + /// Resolve an application request UUID without assuming it is a runtime run UUID. + pub async fn lookup_request(&self, request: Uuid) -> Result, String> { + self.send( + self.http + .get(format!("http://agent/v1/runs/by-request/{request}")), + ) + .await + } + + /// Read one bounded page of direct children and the parent ID. + pub async fn family(&self, id: Uuid, after: Option) -> Result { + let mut request = self.http.get(format!("http://agent/v1/runs/{id}/family")); + if let Some(after) = after { + request = request.query(&[("after", after)]); + } + self.send(request).await + } + + pub async fn get(&self, id: Uuid) -> Result { + self.send(self.http.get(format!("http://agent/v1/runs/{id}"))) + .await + } + + /// Read generated messages only; input conversation is excluded by origin. + pub async fn projection(&self, id: Uuid) -> Result { + self.send( + self.http + .get(format!("http://agent/v1/runs/{id}/projection")) + .timeout(Duration::from_secs(5)), + ) + .await + } + + pub async fn step(&self, run: &Run, command: Command) -> Result { + self.send( + self.http + .post(format!("http://agent/v1/runs/{}/step", run.id)) + .json(&Step { + expected_revision: run.revision, + command, + }), + ) + .await + } + + pub async fn events(&self, id: Uuid, after: u64) -> Result, String> { + self.send( + self.http + .get(format!("http://agent/v1/runs/{id}/events?after={after}")), + ) + .await + } + + async fn send( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let mut response = request + .bearer_auth(&self.token) + .send() + .await + .map_err(|_| "agent transport failed; inspect saved run before retrying")?; + let status = response.status(); + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "agent response interrupted")? + { + if bytes.len() + chunk.len() > MAX_BODY_BYTES { + return Err("agent response limit".into()); + } + bytes.extend_from_slice(&chunk); + } + if !status.is_success() { + let error = serde_json::from_slice::(&bytes) + .ok() + .map(|e| e.code) + .unwrap_or_else(|| "invalid_error".into()); + return Err(format!("agent API {}: {error}", status.as_u16())); + } + serde_json::from_slice(&bytes).map_err(|_| "invalid agent API response".into()) + } + + pub async fn prepare_context( + &self, + request: &protocol::context::PrepareContext, + ) -> Result { + self.send( + self.http + .post("http://agent/v1/context/prepare") + .json(request), + ) + .await + } + + pub async fn invalidate_context(&self, subject: &str) -> Result<(), String> { + let _: serde_json::Value = self + .send(self.http.post("http://agent/v1/context/invalidate").json( + &protocol::context::InvalidateContext { + subject: subject.into(), + }, + )) + .await?; + Ok(()) + } + + pub async fn report_outcome( + &self, + run: uuid::Uuid, + outcome: &protocol::context::TaskOutcome, + ) -> Result { + self.send( + self.http + .post(format!("http://agent/v1/runs/{run}/outcome")) + .json(outcome), + ) + .await + } +} --- /dev/null +++ b/crates/airouter-agent-client/tests/application_contracts.rs @@ -0,0 +1,72 @@ +#![cfg(feature = "application")] +//! Frozen v1 service messages remain usable without any provider runtime feature. +use airouter_agent_client::{activity::Event, session_service::{Events, State, Status, Submit}}; +use serde_json::json; + +fn saved_submission() -> serde_json::Value { + json!({ + "version":"airouter.session-service.v1", + "binding":{ + "version":"airouter.controller.v1", "application":"fixture", + "task_id":"00000000-0000-4000-8000-000000000001", + "run_id":"00000000-0000-4000-8000-000000000002", + "lease_id":"00000000-0000-4000-8000-000000000003", + "host":"resources", "worker":"observer", "profile_digest":"pinned", + "projection_version":"airouter.projection.v1" + }, + "input":{ + "plan":{"args":["/fixture/executor"],"cwd":"/fixture/workspace","env":{}}, + "stdin":"", "stdout_idle_timeout":null, "stdout_idle_retries":0, + "watchdog":null + }, + "native":{ + "mcp_servers":[{"name":"resources","command":"/fixture/resources", + "args":[],"env":[["PROJECT_ID","fixture"]], + "tool_timeout_seconds":60,"default_tools_approval_mode":"approve"}], + "writable_directories":["/fixture/workspace"], "memory_user":null + } + }) +} + +#[test] +fn application_submission_preserves_saved_v1_body() { + let original = saved_submission(); + let submission: Submit = serde_json::from_value(original.clone()).unwrap(); + assert_eq!(serde_json::to_value(submission).unwrap(), original); +} + +#[test] +fn application_submission_rejects_unknown_authority_fields() { + let mut value = saved_submission(); + value["native"]["bypass_permissions"] = json!(true); + assert!(serde_json::from_value::(value).is_err()); + let mut value = saved_submission(); + value["binding"]["unexpected_lease"] = json!("changed"); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn application_activity_preserves_receipt_and_opaque_cursor() { + let receipt = json!({"workbench_handoff_receipt":{"cmd_id":"child","state":"completed"}, + "report":"x".repeat(16000)}).to_string(); + let wire = json!({"next":8123,"more":true,"events":[ + {"Preview":{"source_message_id":"checkpoint/2","sequence":3,"text":"preview"}}, + {"Activity":{"ToolResult":{"tool_use_id":"call","tool":"spawn_manager", + "output":receipt,"exit_code":0}}} + ]}); + let page: Events = serde_json::from_value(wire.clone()).unwrap(); + assert!(matches!(page.events[0], Event::Preview { sequence: 3, .. })); + assert_eq!(serde_json::to_value(page).unwrap(), wire); +} + +#[test] +fn application_status_keeps_incomplete_activity_visible() { + let status: Status = serde_json::from_value(json!({ + "run_id":"00000000-0000-4000-8000-000000000002", + "state":"finished", "phase":"cancelled", "cancel_requested":true, + "activity_complete":false, "error":"receipt unavailable" + })).unwrap(); + assert_eq!(status.state, State::Finished); + assert!(!status.activity_complete); + assert_eq!(status.error.as_deref(), Some("receipt unavailable")); +} --- /dev/null +++ b/crates/airouter-agent-client/tests/http_contracts.rs @@ -0,0 +1,388 @@ +//! HTTP protocol contracts migrated from Workbench with AI ownership. +#![cfg(feature = "http-agent")] +use airouter_agent_client::http_agent::{ + ProviderMessage, ChatCompletionRequestOptions, + build_chat_completion_request, build_chat_completion_request_with_options, + build_ollama_chat_request_with_options, parse_chat_completion_response, + parse_ollama_chat_response, + session::ollama_model_supports_native_tools, + text::{parse_tool_calls, strip_tool_call_blocks, parse_textual_local_tool_calls, + should_force_investigation_grounding, should_force_project_root_listing, strip_think_markers}, +}; +use serde_json::json; +fn user(text: impl Into) -> ProviderMessage { + ProviderMessage { role: "user".into(), content: text.into() } +} + + +#[test] +fn request_includes_system_memory_history_and_stream_false() { + let body = build_chat_completion_request( + "qwen-local", + "system rules", + &[ + user("hello"), + ProviderMessage::assistant("hi"), + ], + Some(""), + ); + + assert_eq!(body["model"], "qwen-local"); + assert_eq!(body["stream"], false); + assert_eq!( + body["messages"], + json!([ + {"role": "system", "content": "system rules"}, + {"role": "system", "content": ""}, + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ]) + ); +} + +#[test] +fn request_options_include_stream_and_max_tokens() { + let body = build_chat_completion_request_with_options( + "qwen-local", + "", + &[user("hello")], + None, + ChatCompletionRequestOptions { + stream: true, + max_tokens: Some(256), + think: Some(false), + seed: Some(424_242), + temperature: Some(0.0), + top_k: Some(1), + top_p: Some(1.0), + min_p: Some(0.0), + }, + ); + + assert_eq!(body["stream"], true); + assert_eq!(body["max_tokens"], 256); + assert_eq!(body["think"], false); + assert_eq!(body["seed"], 424_242); + assert_eq!(body["temperature"], 0.0); + assert_eq!(body["top_p"], 1.0); + assert!(body.get("top_k").is_none()); + assert!(body.get("min_p").is_none()); +} + +#[test] +fn response_parser_extracts_text_finish_reason_and_usage() { + let final_event = parse_chat_completion_response(&json!({ + "choices": [{ + "finish_reason": "stop", + "message": {"role": "assistant", "content": "done"} + }], + "usage": {"prompt_tokens": 12, "completion_tokens": 4} + })) + .unwrap(); + + assert_eq!(final_event.text, "done"); + assert_eq!(final_event.input_tokens, 12); + assert_eq!(final_event.output_tokens, 4); + assert_eq!(final_event.stop_reason.as_deref(), Some("stop")); + assert_eq!(final_event.error, None); +} + +#[test] +fn response_parser_concatenates_array_text_content() { + let final_event = parse_chat_completion_response(&json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "hello "}, + {"type": "text", "text": "world"} + ] + } + }] + })) + .unwrap(); + + assert_eq!(final_event.text, "hello world"); +} + +#[test] +fn response_parser_rejects_reasoning_only_openai_compatible_output() { + let err = parse_chat_completion_response(&json!({ + "choices": [{ + "finish_reason": "length", + "message": {"role": "assistant", "content": "", "reasoning": "hidden chain"} + }], + "usage": {"prompt_tokens": 12, "completion_tokens": 64} + })) + .unwrap_err(); + + assert!(err.contains("empty message.content")); +} + +#[test] +fn ollama_request_maps_options_to_native_chat_shape() { + let body = build_ollama_chat_request_with_options( + "qwen3:8b", + "system rules", + &[user("hello")], + None, + ChatCompletionRequestOptions { + stream: true, + max_tokens: Some(64), + think: Some(false), + seed: Some(424_242), + temperature: Some(0.0), + top_k: Some(1), + top_p: Some(1.0), + min_p: Some(0.0), + }, + ); + + assert_eq!(body["model"], "qwen3:8b"); + assert_eq!(body["stream"], false); + assert_eq!(body["think"], false); + assert_eq!(body["options"]["num_predict"], 64); + assert_eq!(body["options"]["seed"], 424_242); + assert_eq!(body["options"]["temperature"], 0.0); + assert_eq!(body["options"]["top_k"], 1); + assert_eq!(body["options"]["top_p"], 1.0); + assert_eq!(body["options"]["min_p"], 0.0); + assert_eq!( + body["messages"], + json!([ + {"role": "system", "content": "system rules"}, + {"role": "user", "content": "hello"}, + ]) + ); +} + +#[test] +fn ollama_response_parser_extracts_native_content_and_usage() { + let final_event = parse_ollama_chat_response(&json!({ + "model": "qwen3:8b", + "message": {"role": "assistant", "content": "hello"}, + "done_reason": "stop", + "prompt_eval_count": 7, + "eval_count": 2 + })) + .unwrap(); + + assert_eq!(final_event.text, "hello"); + assert_eq!(final_event.input_tokens, 7); + assert_eq!(final_event.output_tokens, 2); + assert_eq!(final_event.stop_reason.as_deref(), Some("stop")); +} + +#[test] +fn tool_call_parser_extracts_json_blocks_and_generates_ids() { + let calls = parse_tool_calls( + r#"first +{"name":"read_file","input":{"path":"notes.txt"}} +{"id":"custom","tool":"search_files","input":{"query":"needle"}} +last"#, + ) + .unwrap(); + + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].id, "local_tool_1"); + assert_eq!(calls[0].name, "read_file"); + assert_eq!(calls[0].input["path"], "notes.txt"); + assert_eq!(calls[1].id, "custom"); + assert_eq!(calls[1].name, "search_files"); + assert_eq!(calls[1].input["query"], "needle"); +} + +#[test] +fn strip_tool_call_blocks_removes_local_tool_protocol() { + let stripped = strip_tool_call_blocks( + "before\n{\"name\":\"list_files\",\"input\":{}}\nafter", + ); + + assert_eq!(stripped, "before\n\nafter"); +} + +#[test] +fn deepseek_r1_uses_textual_ollama_tools() { + assert!(!ollama_model_supports_native_tools( + "deepseek-r1:32b-qwen-distill-q4_K_M" + )); + assert!(!ollama_model_supports_native_tools( + "DeepSeek-R1:32B-Qwen-Distill-Q4_K_M" + )); + assert!(ollama_model_supports_native_tools("qwen3:30b-a3b")); + assert!(ollama_model_supports_native_tools("qwen3-coder:30b")); + } + +#[test] +fn forces_project_root_listing_for_ungrounded_access_refusal() { + assert!(should_force_project_root_listing( + "I cannot access the directory due to permission restrictions.", + &["read_file", "list_files", "search_files"], + )); + assert!(should_force_project_root_listing( + "I would need access to the specific codebase or a code snippet to analyze Rust files.", + &["Read", "Glob", "Grep"], + )); + assert!(!should_force_project_root_listing( + "I cannot access the directory due to permission restrictions.", + &["read_file"], + )); + assert!(!should_force_project_root_listing( + "I can help after you choose a project.", + &["read_file", "list_files"], + )); + } + +#[test] +fn forces_project_root_listing_for_ungrounded_code_investigation() { + assert!(should_force_investigation_grounding( + Some( + "so let's analyze the code and figure out why an agent would give a false answer without first digging before giving a non answer", + ), + "Based on my analysis, the root cause is a model capability mismatch: qwen3:30b-a3b does not support tools and the provider configuration defaults to it.", + &["read_file", "list_files", "search_files"], + )); + assert!(!should_force_investigation_grounding( + Some("say hello"), + "Based on my analysis, the root cause is a provider configuration issue.", + &["read_file", "list_files", "search_files"], + )); + assert!(!should_force_investigation_grounding( + Some("analyze the code and figure out why this happened"), + "Based on my analysis, the root cause is a provider configuration issue.", + &["read_file", "search_files"], + )); + } + +#[test] +fn parses_qwen_textual_mcp_tool_call_fallback() { + let calls = parse_textual_local_tool_calls( + r#" + +mcp__workbench__project_message: request_id="bf0fcf9e-fff5-4671-8259-d58aaffa3b0a", summary="Smoke-test native Workbench tool bridge using 'true' command", plan="Run `true` as a no-op validation test.", risk_level="low""#, + &["mcp__workbench__project_message"], + ) + .unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "mcp__workbench__project_message"); + assert_eq!( + calls[0].input["request_id"], + "bf0fcf9e-fff5-4671-8259-d58aaffa3b0a" + ); + assert_eq!(calls[0].input["risk_level"], "low"); + assert_eq!( + calls[0].input["plan"], + "Run `true` as a no-op validation test." + ); + } + +#[test] +fn parses_textual_tool_call_booleans_and_numbers() { + let calls = parse_textual_local_tool_calls( + r#"mcp__workbench__local_tools_status(request_id="r1", dry_run=true, timeout_s=5)"#, + &["mcp__workbench__local_tools_status"], + ) + .unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].input["dry_run"], true); + assert_eq!(calls[0].input["timeout_s"], 5); + } + +#[test] +fn parses_command_style_local_tool_call_fallback() { + let calls = parse_textual_local_tool_calls( + r#"```bash +list_files --path "/var/lib/workbench/topics/local/workbench" --recursive false --limit 200 +```"#, + &["list_files"], + ) + .unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "list_files"); + assert_eq!( + calls[0].input["path"], + "/var/lib/workbench/topics/local/workbench" + ); + assert_eq!(calls[0].input["recursive"], false); + assert_eq!(calls[0].input["limit"], 200); + } + +#[test] +fn strips_trailing_qwen_think_markers() { + assert_eq!(strip_think_markers("ok /think"), "ok"); + assert_eq!(strip_think_markers("done\n/no_think\n"), "done"); + assert_eq!( + strip_think_markers("private plan\nConcrete answer."), + "Concrete answer." + ); + assert_eq!( + strip_think_markers("\nConcrete answer."), + "Concrete answer." + ); + assert_eq!( + strip_think_markers(concat!( + "Okay, let's see. The user keeps asking for a command.\n\n", + "The system's instructions say the response should be brief. ", + "No new tool calls are needed.\n\n", + "So the answer is to tell them to use the bash tool as before. ", + "No need for any tool calls here, just a text response.\n\n", + "Use the bash tool to run `equery f osforge` directly." + )), + "Use the bash tool to run `equery f osforge` directly." + ); + assert_eq!( + strip_think_markers("Okay, I updated the runtime and restarted the daemon."), + "Okay, I updated the runtime and restarted the daemon." + ); + assert_eq!( + strip_think_markers(concat!( + "Okay, let's break this down. The user is asking for a command result.\n\n", + "I should use the available bash tool and report the output.\n\n", + "Command `equery f osforge` exited 0.\n\n", + "```text\n/usr/bin/osforge\n```" + )), + "Command `equery f osforge` exited 0.\n\n```text\n/usr/bin/osforge\n```" + ); + assert_eq!( + strip_think_markers(concat!( + "Okay, let me process this. The user asked for a package file list.\n\n", + "I need to run the command and report the result.\n\n", + "Command `equery f osforge` exited 0.\n\n", + "```text\n/usr/bin/osforge\n```" + )), + "Command `equery f osforge` exited 0.\n\n```text\n/usr/bin/osforge\n```" + ); + assert_eq!( + strip_think_markers(concat!( + "The previous commands like `printf alpha-fast-path` were handled by the system.\n\n", + "I should make sure not to add any extra text beyond the command output.\n\n", + "alpha-readonly-fast-path\n\n", + "I need to present this in the same format as before.\n\n", + "No need to do anything else; just output the result.\n\n", + "Command `printf alpha-readonly-fast-path` exited 0.\n\n", + "```text\nalpha-readonly-fast-path\n```" + )), + "Command `printf alpha-readonly-fast-path` exited 0.\n\n```text\nalpha-readonly-fast-path\n```" + ); + assert_eq!( + strip_think_markers( + "Okay, let me know which project to inspect.\n\nI can run the check after that." + ), + "Okay, let me know which project to inspect.\n\nI can run the check after that." + ); + assert_eq!( + strip_think_markers(concat!( + "I'll stick to the previous answer but maybe make it more direct. ", + "The key points are: use bash tool. Let me confirm.\n\n", + "Use the bash tool to run `equery f osforge` directly." + )), + "Use the bash tool to run `equery f osforge` directly." + ); + assert_eq!( + strip_think_markers("literal /think marker inside text"), + "literal /think marker inside text" + ); + } --- /dev/null +++ b/crates/airouter-agentd/examples/orchestration-fixture.rs @@ -0,0 +1,34 @@ +//! Model-free qualification helper using the canonical decision store/policy. +//! Private in-memory state lasts only for this fixture process. Never a service. +use airouter_agent_protocol::orchestration::Observation; +use airouter_agentd::{orchestration_store::OrchestrationStore, store::MemoryStore}; +use std::io::{BufRead, Write}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let store = MemoryStore::default(); + let mut input = std::io::stdin().lock(); + let mut output = std::io::stdout().lock(); + loop { + let mut bytes = Vec::new(); + let count = + std::io::Read::take(&mut input, 1024 * 1024 + 1).read_until(b'\n', &mut bytes)?; + if count == 0 { + return Ok(()); + } + if count > 1024 * 1024 || !bytes.ends_with(b"\n") { + return Err("bounded newline-delimited observation required".into()); + } + let request: Observation = serde_json::from_slice(&bytes)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64; + let receipt = store + .observe("qualification", request, now) + .await + .map_err(|e| std::io::Error::other(format!("{e:?}")))?; + serde_json::to_writer(&mut output, &receipt)?; + output.write_all(b"\n")?; + output.flush()?; + } +} --- a/crates/airouter-agentd/src/session_service/http.rs +++ b/crates/airouter-agentd/src/session_service/http.rs @@ -1,4 +1,5 @@ //! Independent HTTP model loop with app-owned, contained resource operations. +mod direct_operation; use airouter_agent_client::{ CancellationToken, http_agent::{self as http, Host as _, LocalToolCall, LocalToolResult}, @@ -160,9 +161,27 @@ return Err("HTTP tool schema differs from the admitted resource grants".into()); } } - let direct_call: Option = - serde_json::from_value(config.resource_input["direct_call"].clone()) - .map_err(|_| "invalid deterministic resource operation")?; + // Application bindings grant resources; only the service interprets prompts. + let messages: Vec = run + .request + .messages + .iter() + .map(|m| http::ProviderMessage { + role: match m.role { + airouter_agent_protocol::InputRole::User => "user", + airouter_agent_protocol::InputRole::Assistant => "assistant", + } + .into(), + content: m.content.clone(), + }) + .collect(); + let (direct_call, require_tool_evidence) = direct_operation::select( + messages.clone(), + allowed_tools.clone(), + config.resource_input["caller_persona"] + .as_str() + .map(str::to_owned), + ); if direct_call .as_ref() .is_some_and(|call| !names.contains(&http::text::normalize_tool_name(&call.name))) @@ -201,27 +220,13 @@ min_p: generation.min_p, strip_think_markers: registration.strip_think_markers, }, - messages: run - .request - .messages - .iter() - .map(|m| http::ProviderMessage { - role: match m.role { - airouter_agent_protocol::InputRole::User => "user", - airouter_agent_protocol::InputRole::Assistant => "assistant", - } - .into(), - content: m.content.clone(), - }) - .collect(), + messages, native_memory: None, system_prompt, native_system_prompt, native_tools: config.tools.clone(), turn_ending_tools: vec![], - require_tool_evidence: config.resource_input["require_tool_evidence"] - .as_bool() - .unwrap_or(false), + require_tool_evidence, }, resource_program: registration.resource_program.clone(), resources: config.resource_input.clone(), --- /dev/null +++ b/crates/airouter-agentd/src/session_service/http/direct_operation.rs @@ -0,0 +1,497 @@ +//! Prompt-to-operation decisions owned by the AIRouter HTTP driver. +use airouter_agent_client::{ + http_agent::ProviderMessage, + resources::{LocalToolCall, normalize_tool_name}, +}; +use serde_json::json; +const PROJECT_TEST_BASH_TIMEOUT_SECONDS: u64 = 300; +#[derive(Clone, Default)] +struct Grants { + allowed_tools: Vec, + caller_persona: Option, +} +struct ResourceRequest { + launch: Grants, + messages: Vec, +} +pub(super) fn select( + messages: Vec, + allowed_tools: Vec, + caller_persona: Option, +) -> (Option, bool) { + let input = ResourceRequest { + launch: Grants { + allowed_tools, + caller_persona, + }, + messages, + }; + ( + direct_bash_command_fast_path(&input).or_else(|| operator_create_project_fast_path(&input)), + requested_direct_bash_command(&input).is_some(), + ) +} +fn direct_bash_command_fast_path(input: &ResourceRequest) -> Option { + if !input + .launch + .allowed_tools + .iter() + .any(|tool| normalize_tool_name(tool) == "bash") + { + return None; + } + let command = requested_direct_bash_command(input)?; + if !is_read_only_direct_bash_command(&command) && !is_project_scoped_test_command(&command) { + return None; + } + let timeout_seconds = + is_project_scoped_test_command(&command).then_some(PROJECT_TEST_BASH_TIMEOUT_SECONDS); + let mut tool_input = json!({ + "command": command, + }); + if let Some(timeout_seconds) = timeout_seconds { + tool_input["timeout_seconds"] = json!(timeout_seconds); + } + Some(LocalToolCall { + id: "direct_bash_fast_path_1".to_string(), + name: "bash".to_string(), + input: tool_input, + }) +} + +fn requested_direct_bash_command(input: &ResourceRequest) -> Option { + let message = input + .messages + .iter() + .rev() + .find(|message| message.role == "user")?; + parse_direct_bash_request(&message.content) +} + +fn parse_direct_bash_request(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + let markers = [ + "run command and report the results:", + "run command and report results:", + "run command and report:", + "run shell command and report the results:", + "run shell command and report results:", + "run shell command:", + "run command:", + "run:", + ]; + let (idx, marker_len) = markers + .iter() + .filter_map(|marker| lower.find(marker).map(|idx| (idx, marker.len()))) + .min_by_key(|(idx, _)| *idx)?; + let raw = text[idx + marker_len..].trim(); + clean_direct_bash_command(raw) +} + +fn clean_direct_bash_command(raw: &str) -> Option { + let mut value = raw.trim(); + if let Some(rest) = value.strip_prefix("```") { + value = rest; + if let Some(line_end) = value.find('\n') { + let first = value[..line_end].trim(); + if first + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + value = &value[line_end + 1..]; + } + } + if let Some(end) = value.find("```") { + value = &value[..end]; + } + } + let first_line = value.lines().map(str::trim).find(|line| !line.is_empty())?; + let value = first_line + .trim_start_matches('#') + .trim() + .trim_matches(|ch| matches!(ch, '"' | '\'' | '`')); + if value.is_empty() || value.len() > 500 { + return None; + } + Some(value.to_string()) +} + +fn is_read_only_direct_bash_command(command: &str) -> bool { + let value = command.trim(); + if value.is_empty() + || value.contains('\n') + || value.contains('\r') + || value.contains(';') + || value.contains('&') + || value.contains('|') + || value.contains('>') + || value.contains('<') + || value.contains('`') + || value.contains("$(") + { + return false; + } + + let mut parts = value.split_whitespace(); + let Some(program) = parts.next() else { + return false; + }; + if program.contains('=') { + return false; + } + let command_name = program.rsplit('/').next().unwrap_or(program); + let args: Vec<&str> = parts.collect(); + + match command_name { + "awk" | "cat" | "date" | "df" | "du" | "eix" | "equery" | "file" | "grep" | "head" + | "id" | "ls" | "mount" | "pgrep" | "printf" | "ps" | "pwd" | "qfile" | "qgrep" + | "qlist" | "readlink" | "realpath" | "rg" | "ss" | "stat" | "tail" | "uname" | "wc" + | "which" | "whoami" => true, + "echo" => true, + "find" => args.iter().all(|arg| { + !matches!( + *arg, + "-delete" + | "-exec" + | "-execdir" + | "-ok" + | "-okdir" + | "-fprint" + | "-fprintf" + | "-fls" + ) + }), + "git" => args.first().is_some_and(|subcommand| { + matches!( + *subcommand, + "branch" | "diff" | "grep" | "log" | "ls-files" | "rev-parse" | "show" | "status" + ) + }), + "ip" => args.iter().all(|arg| { + !matches!( + *arg, + "add" | "change" | "delete" | "del" | "flush" | "replace" | "set" + ) + }), + "journalctl" => true, + "rc-service" => args.contains(&"status"), + "sed" => args + .iter() + .all(|arg| *arg != "-i" && !arg.starts_with("--in-place")), + "systemctl" => args.first().is_some_and(|subcommand| { + matches!( + *subcommand, + "cat" + | "is-active" + | "is-enabled" + | "list-unit-files" + | "list-units" + | "show" + | "status" + ) + }), + "type" => true, + _ => false, + } +} + +fn is_project_scoped_test_command(command: &str) -> bool { + let value = command.trim(); + if value.is_empty() + || value.contains('\n') + || value.contains('\r') + || value.contains(';') + || value.contains('&') + || value.contains('|') + || value.contains('>') + || value.contains('<') + || value.contains('`') + || value.contains("$(") + { + return false; + } + let parts = value.split_whitespace().collect::>(); + if parts.iter().any(|part| { + part == &".." || part.starts_with("../") || part.contains("/../") || part.starts_with('/') + }) { + return false; + } + match parts.as_slice() { + [python, "-m", "pytest", args @ ..] if matches!(*python, "python" | "python3") => { + !args.iter().any(|arg| { + matches!( + *arg, + "-c" | "-o" | "--basetemp" | "--confcutdir" | "--rootdir" + ) || arg.starts_with("--basetemp=") + || arg.starts_with("--confcutdir=") + || arg.starts_with("--rootdir=") + }) + } + ["cargo", "test", args @ ..] => !args.iter().any(|arg| { + matches!(*arg, "--config" | "--manifest-path" | "--target-dir") + || arg.starts_with("--config=") + || arg.starts_with("--manifest-path=") + || arg.starts_with("--target-dir=") + }), + _ => false, + } +} + +fn operator_create_project_fast_path(input: &ResourceRequest) -> Option { + if input.launch.caller_persona.as_deref() != Some("operator") { + return None; + } + if !input + .launch + .allowed_tools + .iter() + .any(|tool| normalize_tool_name(tool) == "wb_create_project") + { + return None; + } + let message = input + .messages + .iter() + .rev() + .find(|message| message.role == "user")?; + let (topic, name) = parse_operator_create_project_request(&message.content)?; + Some(LocalToolCall { + id: "operator_fast_path_1".to_string(), + name: "wb_create_project".to_string(), + input: json!({ + "name": name, + "topic": topic, + }), + }) +} + +fn parse_operator_create_project_request(text: &str) -> Option<(String, String)> { + let lower = text.to_ascii_lowercase(); + if !lower.contains("project") || !(lower.contains("create") || lower.contains("new")) { + return None; + } + + if let Some((topic, name)) = parse_under_then_called(text, &lower) { + return Some((topic, name)); + } + parse_called_then_under(text, &lower) +} + +fn parse_under_then_called(text: &str, lower: &str) -> Option<(String, String)> { + let under_idx = lower.find(" under ")?; + let after_under = under_idx + " under ".len(); + let rest_lower = &lower[after_under..]; + let (marker_idx, marker_len) = find_first_marker(rest_lower, &[" called ", " named "])?; + let topic = clean_create_project_piece(&text[after_under..after_under + marker_idx])?; + let name_start = after_under + marker_idx + marker_len; + let name = clean_create_project_piece(&text[name_start..])?; + Some((topic, name)) +} + +fn parse_called_then_under(text: &str, lower: &str) -> Option<(String, String)> { + let (marker_idx, marker_len) = find_first_marker(lower, &[" called ", " named "])?; + let after_marker = marker_idx + marker_len; + let rest_lower = &lower[after_marker..]; + let under_idx = rest_lower.find(" under ")?; + let name = clean_create_project_piece(&text[after_marker..after_marker + under_idx])?; + let topic_start = after_marker + under_idx + " under ".len(); + let topic = clean_create_project_piece(&text[topic_start..])?; + Some((topic, name)) +} + +fn find_first_marker(haystack: &str, markers: &[&str]) -> Option<(usize, usize)> { + markers + .iter() + .filter_map(|marker| haystack.find(marker).map(|idx| (idx, marker.len()))) + .min_by_key(|(idx, _)| *idx) +} + +fn clean_create_project_piece(raw: &str) -> Option { + let mut value = raw.trim(); + for stop in [ + "\n", ".", ",", ";", " with ", " using ", " and ", " that ", " to ", + ] { + if let Some(idx) = value.to_ascii_lowercase().find(stop) { + value = &value[..idx]; + } + } + let value = value.trim_matches(|ch: char| { + ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | ':' | '/' | '\\') + }); + (!value.is_empty()).then(|| value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn user(content: &str) -> ProviderMessage { + ProviderMessage { + role: "user".into(), + content: content.into(), + } + } + #[test] + fn parses_operator_project_create_request_under_then_called() { + assert_eq!( + parse_operator_create_project_request("new project under local called wayfire"), + Some(("local".to_string(), "wayfire".to_string())) + ); + assert_eq!( + parse_operator_create_project_request( + "create a project under LOCAL called osforge. give it responsibility for /var/lib/osforge" + ), + Some(("LOCAL".to_string(), "osforge".to_string())) + ); + } + + #[test] + fn parses_operator_project_create_request_called_then_under() { + assert_eq!( + parse_operator_create_project_request("create a project called wayfire under local"), + Some(("local".to_string(), "wayfire".to_string())) + ); + } + + #[test] + fn operator_project_create_fast_path_is_operator_only() { + let mut launch = Grants::default(); + launch.allowed_tools = vec!["wb_create_project".to_string()]; + launch.caller_persona = Some("operator".to_string()); + let input = ResourceRequest { + launch: launch.clone(), + messages: vec![user("new project under local called wayfire")], + }; + + let call = operator_create_project_fast_path(&input).unwrap(); + assert_eq!(call.name, "wb_create_project"); + assert_eq!(call.input["topic"], "local"); + assert_eq!(call.input["name"], "wayfire"); + + launch.caller_persona = Some("project-director".to_string()); + let input = ResourceRequest { + launch, + messages: vec![user("new project under local called wayfire")], + }; + assert!(operator_create_project_fast_path(&input).is_none()); + } + + #[test] + fn parses_direct_bash_command_request() { + assert_eq!( + parse_direct_bash_request("run command and report the results: equery f osforge"), + Some("equery f osforge".to_string()) + ); + assert_eq!( + parse_direct_bash_request("run command: # equery f osforge"), + Some("equery f osforge".to_string()) + ); + assert!(is_read_only_direct_bash_command("equery f osforge")); + assert!(is_read_only_direct_bash_command( + "/usr/bin/equery f osforge" + )); + assert!(is_read_only_direct_bash_command("git status --short")); + assert!(is_read_only_direct_bash_command("printf alpha-fast-path")); + assert!(!is_read_only_direct_bash_command( + "rm -rf /tmp/workbench-test" + )); + assert!(!is_read_only_direct_bash_command("find . -type f -delete")); + assert!(!is_read_only_direct_bash_command("equery f osforge | head")); + } + + #[test] + fn direct_bash_fast_path_requires_bash_tool() { + let mut launch = Grants::default(); + launch.allowed_tools = vec!["Bash".to_string()]; + launch.caller_persona = Some("administrator-director".to_string()); + let input = ResourceRequest { + launch: launch.clone(), + messages: vec![user("run command and report results: equery f osforge")], + }; + + let call = direct_bash_command_fast_path(&input).unwrap(); + assert_eq!(call.name, "bash"); + assert_eq!(call.input["command"], "equery f osforge"); + + let input = ResourceRequest { + launch: launch.clone(), + messages: vec![user( + "run command and report results: rm -rf /tmp/workbench-test", + )], + }; + assert!(direct_bash_command_fast_path(&input).is_none()); + + launch.allowed_tools = vec!["read_file".to_string()]; + let input = ResourceRequest { + launch, + messages: vec![user("run command and report results: equery f osforge")], + }; + assert!(direct_bash_command_fast_path(&input).is_none()); + } + + #[test] + fn direct_bash_fast_path_accepts_project_scoped_test_commands() { + let mut launch = Grants::default(); + launch.allowed_tools = vec!["Bash".to_string()]; + let pytest = ResourceRequest { + launch: launch.clone(), + messages: vec![user( + "run command and report results: python -m pytest -q tests/test_airouter_step4_baseline.py", + )], + }; + let cargo = ResourceRequest { + launch, + messages: vec![user( + "run command and report results: cargo test -p wb-agentd permission_bridge --locked", + )], + }; + + assert_eq!( + direct_bash_command_fast_path(&pytest).unwrap().input["command"], + "python -m pytest -q tests/test_airouter_step4_baseline.py" + ); + assert_eq!( + direct_bash_command_fast_path(&pytest).unwrap().input["timeout_seconds"], + PROJECT_TEST_BASH_TIMEOUT_SECONDS + ); + assert_eq!( + direct_bash_command_fast_path(&cargo).unwrap().input["command"], + "cargo test -p wb-agentd permission_bridge --locked" + ); + assert_eq!( + direct_bash_command_fast_path(&cargo).unwrap().input["timeout_seconds"], + PROJECT_TEST_BASH_TIMEOUT_SECONDS + ); + } + + #[test] + fn project_scoped_test_fast_path_rejects_escape_and_path_overrides() { + assert!(!is_project_scoped_test_command( + "python -m pytest --basetemp=/tmp/escape tests" + )); + assert!(!is_project_scoped_test_command( + "python -m pytest ../other-project/tests" + )); + assert!(!is_project_scoped_test_command( + "cargo test --manifest-path ../other-project/Cargo.toml" + )); + assert!(!is_project_scoped_test_command( + "cargo test --target-dir=/tmp/escape" + )); + assert!(!is_project_scoped_test_command("python -c pass")); + } + + #[test] + fn explicit_command_request_is_detectable_when_model_omits_tool_call() { + let input = ResourceRequest { + launch: Grants::default(), + messages: vec![user( + "run command and report results: custom-check --verify", + )], + }; + assert_eq!( + requested_direct_bash_command(&input).as_deref(), + Some("custom-check --verify") + ); + assert!(direct_bash_command_fast_path(&input).is_none()); + } +} --- a/crates/airouter-agentd/tests/controller_fencing.rs +++ b/crates/airouter-agentd/tests/controller_fencing.rs @@ -103,6 +103,23 @@ // from the private database/socket and fixture credential, not a renamed app. let mut app=Application::new("workbench".into(),"controller-fixture-only-credential-123456789",vec!["fixture".into()]); app.task_queues=vec!["lifecycle".into(),"control".into()];app.managed_hosts=vec!["workbench".into()];app.require_controller=true; + // The independent Workbench consumer uses the same registered policy gate + // as production. Other callers can still qualify AIRouter's core host API. + let policy_root = tempfile::tempdir().unwrap(); + if std::env::var("AIROUTER_CONTROLLER_FIXTURE_AI_POLICY").as_deref() == Ok("true") { + std::fs::create_dir_all(policy_root.path().join("roles/coder")).unwrap(); + std::fs::create_dir(policy_root.path().join("skills")).unwrap(); + std::fs::write(policy_root.path().join("roles/coder/instructions.md"), "Synthetic native consumer policy.").unwrap(); + std::fs::write(policy_root.path().join("roles/coder/tools.yaml"), "default_provider: openai\ndefault_model: exact-native\ntools: []\n").unwrap(); + app.agent_policy = Some(airouter_agentd::policy_v2::Config { + revision: 1, roles_root: policy_root.path().join("roles"), + skills_root: policy_root.path().join("skills"), default_skills: vec![], + routing_program: "/nonexistent-fixture-router".into(), routing_socket: None, + context_enabled: false, opening_balances_micro_usd: Default::default(), + opening_balance_unknown: vec![], + }); + app.require_ai_policy = true; + } let profiles=[("fixture-codex",session::Executor::NativeCodex,"openai","exact-native",vec!["text","image"]), ("fixture-grok",session::Executor::NativeGrok,"grok","grok-4.6",vec!["text"]), ("fixture-claude",session::Executor::NativeClaude,"anthropic","exact-claude",vec!["text"]), --- a/scripts/package-agent-sdk.py +++ b/scripts/package-agent-sdk.py @@ -19,7 +19,7 @@ return hashlib.sha256(data).hexdigest() -def sources(root, extra_crates=()): +def sources(root, extra_crates=(), application_only=False): result = {} for crate in ["airouter-agent-client", "airouter-agent-protocol", *extra_crates]: if not crate.startswith("airouter-") or any(c not in "abcdefghijklmnopqrstuvwxyz0123456789-" for c in crate): @@ -42,17 +42,28 @@ if not path.is_file(): raise ValueError(f"source is not a regular file: {path}") result[str(path.relative_to(root))] = path.read_bytes() + if application_only: + prefix = "crates/airouter-agent-client/" + modules = {"application.rs", "transport.rs", "activity.rs", "resources.rs", + "communication.rs", "dispatch.rs", "journal.rs", + "session_service.rs", "process.rs"} + result = {rel: data for rel, data in result.items() + if not rel.startswith(prefix) or rel == prefix + "README.md" + or rel.removeprefix(prefix + "src/") in modules + or rel.startswith(prefix + "src/process/") + or rel == prefix + "tests/application_contracts.rs"} + result[prefix + "Cargo.toml"] = (root / prefix / "application.Cargo.toml").read_bytes() return result -def publish(root, output, extra_crates=(), bundle_name=None): +def publish(root, output, extra_crates=(), bundle_name=None, application_only=False): if len(set(extra_crates)) != len(extra_crates) or set(extra_crates) & {"airouter-agent-client", "airouter-agent-protocol"}: raise ValueError("duplicate extra crate") if bool(extra_crates) != bool(bundle_name): raise ValueError("extra crates require an explicit versioned bundle name") if bundle_name and (not bundle_name.startswith("airouter-") or any(c not in "abcdefghijklmnopqrstuvwxyz0123456789.-" for c in bundle_name)): raise ValueError("invalid bundle name") - captured = sources(root, extra_crates) + captured = sources(root, extra_crates, application_only) client = tomllib.loads(captured["crates/airouter-agent-client/Cargo.toml"].decode()) protocol = tomllib.loads(captured["crates/airouter-agent-protocol/Cargo.toml"].decode()) version = client["package"]["version"] @@ -90,6 +101,7 @@ "protocol_version": protocol_version, "archive_sha256": archive_sha, "files": hashes, + "scope": "application" if application_only else "full", } (tree / "snapshot.json").write_text(json.dumps(manifest, indent=2) + "\n") if extra_crates: @@ -98,6 +110,8 @@ (tree / "snapshot.json").write_text(json.dumps(manifest, indent=2) + "\n") note = f"# AIRouter SDK snapshot {name}\n\n" note += f"Canonical checkout: `{root}`. Protocol: {protocol_version}.\n" + if application_only: + note += "Application-only export: canonical `application.Cargo.toml` is installed as `Cargo.toml`; `src/application.rs` is the library entry point. Provider runtime sources and binaries are excluded.\n" note += "Exact captured bytes are recorded below; packaging alone is not test qualification.\n\n" note += f"Archive SHA-256: `{archive_sha}`.\n\n" note += "Generated by `scripts/package-agent-sdk.py`; edit canonical AIRouter source,\n" @@ -108,7 +122,7 @@ # inherit the operator's private umask for directories/metadata. for path in [tree, *tree.rglob("*")]: path.chmod(0o755 if path.is_dir() else 0o644) - if sources(root, extra_crates) != captured: + if sources(root, extra_crates, application_only) != captured: raise RuntimeError("canonical SDK source changed during packaging") # Exclusive archive creation also arbitrates concurrent publishers. with archive.open("xb") as dest, packed.open("rb") as src: @@ -122,9 +136,12 @@ parser.add_argument("--output-dir", required=True, type=Path) parser.add_argument("--extra-crate", action="append", default=[]) parser.add_argument("--bundle-name") + parser.add_argument("--application-only", action="store_true", + help="Export only application transport, resource and wire contracts") args = parser.parse_args() root = Path(__file__).resolve().parent.parent - print(json.dumps(publish(root, args.output_dir.resolve(), args.extra_crate, args.bundle_name), indent=2)) + print(json.dumps(publish(root, args.output_dir.resolve(), args.extra_crate, args.bundle_name, + args.application_only), indent=2)) if __name__ == "__main__":