mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
fix(agents): share readiness config projection
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
#[path = "agent_config_readiness.rs"]
|
||||
mod readiness_preview;
|
||||
pub use readiness_preview::{AgentReadinessDraft, AgentReadinessEvaluation};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
agent_readiness,
|
||||
config_bridge::{
|
||||
read_goose_file_config,
|
||||
reader::read_config_surface,
|
||||
@@ -15,10 +18,8 @@ use crate::{
|
||||
},
|
||||
current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key,
|
||||
known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env,
|
||||
save_managed_agents, sync_managed_agent_processes, AgentDefinition, AgentReadiness,
|
||||
BackendKind, GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord,
|
||||
ManagedAgentRuntimeKey, Requirement, RespondTo, DEFAULT_ACP_COMMAND,
|
||||
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, MAX_ENV_VALUE_BYTES,
|
||||
save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig,
|
||||
KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -61,237 +62,6 @@ fn non_blank(v: Option<&str>) -> Option<String> {
|
||||
v.filter(|s| !s.trim().is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// A proposed managed-agent configuration to evaluate before persistence.
|
||||
///
|
||||
/// Every field is an optional draft override of the same persisted field on
|
||||
/// [`ManagedAgentRecord`]. Edit drafts include `pubkey` and inherit omitted
|
||||
/// fields from that saved record; create drafts omit `pubkey` and are evaluated
|
||||
/// from only the submitted draft plus global defaults. JSON `null` for
|
||||
/// tri-state fields clears that value in the draft only. The resulting record
|
||||
/// is never written to disk.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentReadinessDraft {
|
||||
/// Saved agent to use as the baseline for edit drafts. Omit for create
|
||||
/// drafts, which are evaluated from only the submitted proposed config and
|
||||
/// global defaults.
|
||||
#[serde(default)]
|
||||
pub pubkey: Option<String>,
|
||||
/// Draft runtime id. `null` clears the instance runtime so a linked agent
|
||||
/// inherits from its persona.
|
||||
#[serde(default, deserialize_with = "crate::util::double_option")]
|
||||
pub runtime: Option<Option<String>>,
|
||||
/// Draft explicit command pin. `null` clears the pin. Prefer `runtime` for
|
||||
/// catalog-known harnesses; this exists for Custom command drafts.
|
||||
#[serde(default, deserialize_with = "crate::util::double_option")]
|
||||
pub agent_command: Option<Option<String>>,
|
||||
/// Draft model selection. `null` clears the selection.
|
||||
#[serde(default, deserialize_with = "crate::util::double_option")]
|
||||
pub model: Option<Option<String>>,
|
||||
/// Draft provider selection. `null` clears the selection.
|
||||
#[serde(default, deserialize_with = "crate::util::double_option")]
|
||||
pub provider: Option<Option<String>>,
|
||||
/// Draft agent-local env vars. When present, replaces the record's env var
|
||||
/// map for evaluation only.
|
||||
#[serde(default)]
|
||||
pub env_vars: Option<std::collections::BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Presentation-ready readiness result for a saved or draft managed-agent
|
||||
/// configuration.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentReadinessEvaluation {
|
||||
/// `true` when all backend-owned requirements are satisfied.
|
||||
pub ready: bool,
|
||||
/// Structured missing requirements, including the `surface` discriminator
|
||||
/// the UI uses to choose the resolution affordance.
|
||||
pub requirements: Vec<Requirement>,
|
||||
}
|
||||
|
||||
fn readiness_to_evaluation(readiness: AgentReadiness) -> AgentReadinessEvaluation {
|
||||
match readiness {
|
||||
AgentReadiness::Ready => AgentReadinessEvaluation {
|
||||
ready: true,
|
||||
requirements: Vec::new(),
|
||||
},
|
||||
AgentReadiness::NotReady { requirements } => AgentReadinessEvaluation {
|
||||
ready: false,
|
||||
requirements,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_draft_override(record: &mut ManagedAgentRecord, draft: AgentReadinessDraft) {
|
||||
let AgentReadinessDraft {
|
||||
pubkey: _,
|
||||
runtime,
|
||||
agent_command,
|
||||
model,
|
||||
provider,
|
||||
env_vars,
|
||||
} = draft;
|
||||
let runtime_supplied = runtime.is_some();
|
||||
if let Some(runtime) = runtime {
|
||||
record.runtime = trim_to_option(runtime);
|
||||
record.agent_command_override = None;
|
||||
}
|
||||
if let Some(agent_command) = agent_command {
|
||||
if !runtime_supplied || record.runtime.is_none() {
|
||||
record.agent_command_override = trim_to_option(agent_command);
|
||||
if record.agent_command_override.is_some() {
|
||||
record.runtime = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(model) = model {
|
||||
record.model = trim_to_option(model);
|
||||
}
|
||||
if let Some(provider) = provider {
|
||||
record.provider = trim_to_option(provider);
|
||||
}
|
||||
if let Some(env_vars) = env_vars {
|
||||
record.env_vars = env_vars;
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_agent_record_readiness(
|
||||
record: &ManagedAgentRecord,
|
||||
personas: &[AgentDefinition],
|
||||
global: &GlobalAgentConfig,
|
||||
) -> AgentReadinessEvaluation {
|
||||
let command = crate::managed_agents::record_agent_command(record, personas);
|
||||
let metadata = known_acp_runtime(&command);
|
||||
let effective = resolve_effective_agent_env(record, personas, metadata, global);
|
||||
readiness_to_evaluation(agent_readiness(&effective))
|
||||
}
|
||||
|
||||
fn build_draft_baseline(draft: &AgentReadinessDraft) -> ManagedAgentRecord {
|
||||
let command = draft
|
||||
.agent_command
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_ref())
|
||||
.and_then(|value| non_blank(Some(value)))
|
||||
.or_else(|| {
|
||||
draft
|
||||
.runtime
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_ref())
|
||||
.and_then(|id| crate::managed_agents::command_for_runtime_id(id))
|
||||
})
|
||||
.unwrap_or_else(crate::managed_agents::default_agent_command);
|
||||
|
||||
ManagedAgentRecord {
|
||||
pubkey: draft.pubkey.clone().unwrap_or_default(),
|
||||
name: "Draft Agent".to_string(),
|
||||
description: None,
|
||||
persona_id: None,
|
||||
team_id: None,
|
||||
private_key_nsec: String::new(),
|
||||
auth_tag: None,
|
||||
relay_url: String::new(),
|
||||
avatar_url: None,
|
||||
acp_command: DEFAULT_ACP_COMMAND.to_string(),
|
||||
agent_command: command,
|
||||
agent_command_override: None,
|
||||
agent_args: Vec::new(),
|
||||
mcp_command: String::new(),
|
||||
turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: DEFAULT_AGENT_PARALLELISM,
|
||||
system_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
persona_source_version: None,
|
||||
env_vars: Default::default(),
|
||||
start_on_app_launch: false,
|
||||
auto_restart_on_config_change: true,
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_policy_pending: false,
|
||||
provider_binary_path: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
last_error_code: None,
|
||||
respond_to: RespondTo::OwnerOnly,
|
||||
respond_to_allowlist: Vec::new(),
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
shared: false,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
catalog_source: None,
|
||||
team_catalog_source: None,
|
||||
definition_respond_to: None,
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
effort_level: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_saved_agent_readiness_for_app<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
) -> Result<AgentReadinessEvaluation, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let records = load_managed_agents(app)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
let personas = load_personas(app).unwrap_or_default();
|
||||
let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
|
||||
Ok(evaluate_agent_record_readiness(record, &personas, &global))
|
||||
}
|
||||
|
||||
fn evaluate_draft_agent_readiness_for_app<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
state: &AppState,
|
||||
draft: AgentReadinessDraft,
|
||||
) -> Result<AgentReadinessEvaluation, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let records = load_managed_agents(app)?;
|
||||
let mut record = draft
|
||||
.pubkey
|
||||
.as_deref()
|
||||
.and_then(|pubkey| records.into_iter().find(|record| record.pubkey == pubkey))
|
||||
.unwrap_or_else(|| build_draft_baseline(&draft));
|
||||
if let Some(ref env_vars) = draft.env_vars {
|
||||
crate::managed_agents::validate_user_env_keys(env_vars)?;
|
||||
}
|
||||
apply_draft_override(&mut record, draft);
|
||||
let personas = load_personas(app).unwrap_or_default();
|
||||
let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
|
||||
Ok(evaluate_agent_record_readiness(&record, &personas, &global))
|
||||
}
|
||||
|
||||
/// Build a sanitized `InheritedConfigTiers` snapshot at the command boundary.
|
||||
///
|
||||
/// Persona env, global env, and harness definition env are sanitized with
|
||||
@@ -481,32 +251,11 @@ pub fn get_baked_build_env() -> Vec<BakedEnvEntry> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Evaluate the readiness of a saved managed-agent configuration.
|
||||
/// Evaluate a proposed managed-agent configuration without persisting it.
|
||||
///
|
||||
/// This is the structured counterpart to the legacy runtime-status
|
||||
/// `local_setup` boolean. It returns the exact backend-computed missing
|
||||
/// requirements instead of forcing the frontend to rederive provider,
|
||||
/// credential, CLI-login, or missing-binary readiness.
|
||||
#[tauri::command]
|
||||
pub async fn evaluate_agent_readiness(
|
||||
pubkey: String,
|
||||
app: AppHandle,
|
||||
_state: State<'_, AppState>,
|
||||
) -> Result<AgentReadinessEvaluation, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
evaluate_saved_agent_readiness_for_app(&app, &state, &pubkey)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
/// Evaluate a draft managed-agent configuration before it is persisted.
|
||||
///
|
||||
/// The saved agent is used only as a baseline for persona/global inheritance,
|
||||
/// harness definition env, and omitted fields. Draft overrides are applied to a
|
||||
/// clone and then passed through the same effective-env resolver and readiness
|
||||
/// predicate that saved agents use. This command performs no write.
|
||||
/// The request explicitly distinguishes a complete new-agent proposal from a
|
||||
/// patch to one exact saved agent. Projection reuses the production create,
|
||||
/// update, effective-config, harness, and readiness owners.
|
||||
#[tauri::command]
|
||||
pub async fn evaluate_agent_readiness_draft(
|
||||
draft: AgentReadinessDraft,
|
||||
@@ -514,8 +263,21 @@ pub async fn evaluate_agent_readiness_draft(
|
||||
_state: State<'_, AppState>,
|
||||
) -> Result<AgentReadinessEvaluation, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
evaluate_draft_agent_readiness_for_app(&app, &state, draft)
|
||||
// Snapshot persisted inputs while holding the store lock; readiness may
|
||||
// probe an external CLI and therefore runs only after this scope exits.
|
||||
let (records, definitions, global) = {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
(
|
||||
load_managed_agents(&app)?,
|
||||
load_personas(&app)?,
|
||||
crate::managed_agents::load_global_agent_config(&app)?,
|
||||
)
|
||||
};
|
||||
readiness_preview::evaluate_draft(draft, &records, &definitions, &global)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
|
||||
219
desktop/src-tauri/src/commands/agent_config_readiness.rs
Normal file
219
desktop/src-tauri/src/commands/agent_config_readiness.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! Managed-agent readiness preview contract.
|
||||
//!
|
||||
//! A preview is either a complete proposed standalone configuration or a patch
|
||||
//! to one exact saved agent. Both are projected through the same command/env
|
||||
//! owners used by create, update, and spawn before the existing readiness
|
||||
//! predicate runs. Nothing in this module persists the projected record.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::managed_agents::{
|
||||
agent_readiness, resolve_effective_harness_descriptor, AgentDefinition, AgentReadiness,
|
||||
GlobalAgentConfig, ManagedAgentRecord, Requirement,
|
||||
};
|
||||
|
||||
/// Complete configuration for a new standalone agent.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewAgentReadinessDraft {
|
||||
/// Harness command selected by the client. This is the same authoritative
|
||||
/// create field consumed by `create_managed_agent`; Custom commands use the
|
||||
/// same field rather than a parallel runtime identity.
|
||||
#[serde(default)]
|
||||
pub agent_command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env_vars: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Configuration patch for one exact saved agent.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExistingAgentReadinessDraft {
|
||||
pub pubkey: String,
|
||||
/// The same command/sentinel and explicit-override intent accepted by the
|
||||
/// production update path. Absent means keep the saved harness choice.
|
||||
#[serde(default)]
|
||||
pub agent_command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub harness_override: bool,
|
||||
#[serde(default, deserialize_with = "crate::util::double_option")]
|
||||
pub model: Option<Option<String>>,
|
||||
#[serde(default, deserialize_with = "crate::util::double_option")]
|
||||
pub provider: Option<Option<String>>,
|
||||
/// Absent means keep the saved map; present means replace it, matching
|
||||
/// `UpdateManagedAgentRequest`.
|
||||
#[serde(default)]
|
||||
pub env_vars: Option<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
/// An unsaved readiness proposal, discriminated so a missing edit target can
|
||||
/// never silently become a create preview.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AgentReadinessDraft {
|
||||
New { config: NewAgentReadinessDraft },
|
||||
Existing { config: ExistingAgentReadinessDraft },
|
||||
}
|
||||
|
||||
/// Presentation-ready readiness result.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentReadinessEvaluation {
|
||||
pub ready: bool,
|
||||
/// Existing surface-discriminated requirements tell the client which
|
||||
/// affordance can resolve each gap.
|
||||
pub requirements: Vec<Requirement>,
|
||||
}
|
||||
|
||||
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn readiness_to_evaluation(readiness: AgentReadiness) -> AgentReadinessEvaluation {
|
||||
match readiness {
|
||||
AgentReadiness::Ready => AgentReadinessEvaluation {
|
||||
ready: true,
|
||||
requirements: Vec::new(),
|
||||
},
|
||||
AgentReadiness::NotReady { requirements } => AgentReadinessEvaluation {
|
||||
ready: false,
|
||||
requirements,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_existing_draft(
|
||||
record: &mut ManagedAgentRecord,
|
||||
draft: ExistingAgentReadinessDraft,
|
||||
definitions: &[AgentDefinition],
|
||||
) -> Result<(), String> {
|
||||
crate::commands::managed_agent_definition::apply_model_provider_prompt_update(
|
||||
record,
|
||||
draft.model,
|
||||
draft.provider,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let inherit_transition = match draft.agent_command {
|
||||
Some(command) => crate::managed_agents::apply_agent_command_update(
|
||||
record,
|
||||
definitions,
|
||||
&command,
|
||||
draft.harness_override,
|
||||
),
|
||||
None => false,
|
||||
};
|
||||
|
||||
if let Some(ref env_vars) = draft.env_vars {
|
||||
crate::managed_agents::validate_user_env_keys(env_vars)?;
|
||||
}
|
||||
crate::managed_agents::apply_env_vars_then_effort_transition(
|
||||
record,
|
||||
draft.env_vars,
|
||||
inherit_transition,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_new_record(
|
||||
draft: NewAgentReadinessDraft,
|
||||
definitions: &[AgentDefinition],
|
||||
) -> Result<ManagedAgentRecord, String> {
|
||||
crate::managed_agents::validate_user_env_keys(&draft.env_vars)?;
|
||||
let picked_command = trim_to_option(draft.agent_command);
|
||||
let agent_command_override = crate::managed_agents::create_time_agent_command_override(
|
||||
None,
|
||||
definitions,
|
||||
picked_command.as_deref(),
|
||||
true,
|
||||
);
|
||||
let agent_command = crate::managed_agents::effective_agent_command(
|
||||
None,
|
||||
definitions,
|
||||
agent_command_override.as_deref(),
|
||||
);
|
||||
|
||||
let inference = crate::commands::agents::create_fields::resolve_created_inference_config(
|
||||
draft.provider.as_deref(),
|
||||
draft.model.as_deref(),
|
||||
None,
|
||||
);
|
||||
let record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({
|
||||
"pubkey": "",
|
||||
"name": "Readiness preview",
|
||||
"private_key_nsec": "",
|
||||
"relay_url": "",
|
||||
"acp_command": crate::managed_agents::DEFAULT_ACP_COMMAND,
|
||||
"agent_command": agent_command,
|
||||
"agent_command_override": agent_command_override,
|
||||
"agent_args": [],
|
||||
"mcp_command": "",
|
||||
"turn_timeout_seconds": crate::managed_agents::DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
"parallelism": crate::managed_agents::DEFAULT_AGENT_PARALLELISM,
|
||||
"system_prompt": null,
|
||||
"model": inference.model,
|
||||
"provider": inference.provider,
|
||||
"env_vars": draft.env_vars,
|
||||
"relay_mesh": inference.relay_mesh,
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"last_started_at": null,
|
||||
"last_stopped_at": null,
|
||||
"last_exit_code": null,
|
||||
"last_error": null
|
||||
}))
|
||||
.map_err(|error| format!("failed to build readiness preview: {error}"))?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn evaluate_record(
|
||||
record: &ManagedAgentRecord,
|
||||
definitions: &[AgentDefinition],
|
||||
global: &GlobalAgentConfig,
|
||||
) -> Result<AgentReadinessEvaluation, String> {
|
||||
crate::managed_agents::effective_config::resolve_effective_config(record, definitions, global)
|
||||
.require_resolved()?;
|
||||
let descriptor = resolve_effective_harness_descriptor(record, definitions, global)
|
||||
.map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?;
|
||||
let effective = crate::managed_agents::readiness::EffectiveAgentEnv {
|
||||
env: descriptor.env,
|
||||
config_file_path: crate::managed_agents::known_acp_runtime(&descriptor.command)
|
||||
.and_then(|runtime| runtime.config_file_path),
|
||||
effective_command: descriptor.command,
|
||||
};
|
||||
Ok(readiness_to_evaluation(agent_readiness(&effective)))
|
||||
}
|
||||
|
||||
pub(super) fn evaluate_draft(
|
||||
draft: AgentReadinessDraft,
|
||||
saved_records: &[ManagedAgentRecord],
|
||||
definitions: &[AgentDefinition],
|
||||
global: &GlobalAgentConfig,
|
||||
) -> Result<AgentReadinessEvaluation, String> {
|
||||
let record = match draft {
|
||||
AgentReadinessDraft::New { config } => build_new_record(config, definitions)?,
|
||||
AgentReadinessDraft::Existing { config } => {
|
||||
let mut record = saved_records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == config.pubkey)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("agent {} not found", config.pubkey))?;
|
||||
apply_existing_draft(&mut record, config, definitions)?;
|
||||
record
|
||||
}
|
||||
};
|
||||
evaluate_record(&record, definitions, global)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "agent_config_readiness_tests.rs"]
|
||||
mod tests;
|
||||
251
desktop/src-tauri/src/commands/agent_config_readiness_tests.rs
Normal file
251
desktop/src-tauri/src/commands/agent_config_readiness_tests.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use super::*;
|
||||
use crate::managed_agents::{BackendKind, RespondTo};
|
||||
|
||||
fn record() -> ManagedAgentRecord {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"pubkey": "a".repeat(64),
|
||||
"name": "Agent",
|
||||
"private_key_nsec": "",
|
||||
"relay_url": "",
|
||||
"acp_command": "buzz-acp",
|
||||
"agent_command": "buzz-agent",
|
||||
"agent_args": [],
|
||||
"mcp_command": "",
|
||||
"turn_timeout_seconds": 300,
|
||||
"parallelism": 1,
|
||||
"system_prompt": null,
|
||||
"model": "claude-opus-4-5",
|
||||
"provider": "anthropic",
|
||||
"env_vars": {},
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"last_started_at": null,
|
||||
"last_stopped_at": null,
|
||||
"last_exit_code": null,
|
||||
"last_error": null,
|
||||
"backend": { "type": "local" },
|
||||
"respond_to": "owner-only"
|
||||
}))
|
||||
.expect("record fixture")
|
||||
}
|
||||
|
||||
fn definition() -> AgentDefinition {
|
||||
AgentDefinition {
|
||||
id: "persona-1".to_string(),
|
||||
display_name: "Persona".to_string(),
|
||||
description: None,
|
||||
avatar_url: None,
|
||||
system_prompt: "Instructions".to_string(),
|
||||
runtime: Some("buzz-agent".to_string()),
|
||||
model: Some("openrouter/model".to_string()),
|
||||
provider: Some("openrouter".to_string()),
|
||||
env_vars: BTreeMap::from([("OPENROUTER_API_KEY".to_string(), "sk-test".to_string())]),
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
shared: false,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
catalog_source: None,
|
||||
team_catalog_source: None,
|
||||
respond_to: None,
|
||||
respond_to_allowlist: Vec::new(),
|
||||
parallelism: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn existing(pubkey: String) -> AgentReadinessDraft {
|
||||
AgentReadinessDraft::Existing {
|
||||
config: ExistingAgentReadinessDraft {
|
||||
pubkey,
|
||||
agent_command: None,
|
||||
harness_override: false,
|
||||
model: None,
|
||||
provider: None,
|
||||
env_vars: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_draft_uses_production_update_projection() {
|
||||
let saved = record();
|
||||
let mut config = match existing(saved.pubkey.clone()) {
|
||||
AgentReadinessDraft::Existing { config } => config,
|
||||
AgentReadinessDraft::New { .. } => unreachable!(),
|
||||
};
|
||||
config.env_vars = Some(BTreeMap::from([(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
"sk-test".to_string(),
|
||||
)]));
|
||||
|
||||
let result = evaluate_draft(
|
||||
AgentReadinessDraft::Existing { config },
|
||||
std::slice::from_ref(&saved),
|
||||
&[],
|
||||
&GlobalAgentConfig::default(),
|
||||
)
|
||||
.expect("draft evaluates");
|
||||
|
||||
assert!(result.ready);
|
||||
assert!(
|
||||
saved.env_vars.is_empty(),
|
||||
"saved baseline remains unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_existing_target_is_not_reinterpreted_as_create() {
|
||||
let error = evaluate_draft(
|
||||
existing("missing".to_string()),
|
||||
&[],
|
||||
&[],
|
||||
&GlobalAgentConfig::default(),
|
||||
)
|
||||
.expect_err("unknown edit target must fail");
|
||||
|
||||
assert!(error.contains("agent missing not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_draft_uses_production_create_harness_projection() {
|
||||
let draft = AgentReadinessDraft::New {
|
||||
config: NewAgentReadinessDraft {
|
||||
agent_command: Some("buzz-agent".to_string()),
|
||||
model: Some("claude-opus-4-5".to_string()),
|
||||
provider: Some("anthropic".to_string()),
|
||||
env_vars: BTreeMap::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = evaluate_draft(draft, &[], &[], &GlobalAgentConfig::default())
|
||||
.expect("new draft evaluates");
|
||||
assert_eq!(
|
||||
result.requirements,
|
||||
vec![Requirement::EnvKey {
|
||||
key: "ANTHROPIC_API_KEY".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
#[test]
|
||||
fn new_relay_mesh_draft_uses_create_default_model_projection() {
|
||||
let draft = AgentReadinessDraft::New {
|
||||
config: NewAgentReadinessDraft {
|
||||
agent_command: Some("buzz-agent".to_string()),
|
||||
model: None,
|
||||
provider: Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID.to_string()),
|
||||
env_vars: BTreeMap::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = evaluate_draft(draft, &[], &[], &GlobalAgentConfig::default())
|
||||
.expect("relay mesh draft evaluates");
|
||||
assert!(
|
||||
!result.requirements.contains(&Requirement::NormalizedField {
|
||||
field: "model".to_string(),
|
||||
}),
|
||||
"Create projects an omitted relay-mesh model to auto before readiness"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_custom_command_uses_same_binary_check_as_startup() {
|
||||
let draft = AgentReadinessDraft::New {
|
||||
config: NewAgentReadinessDraft {
|
||||
agent_command: Some("buzz-readiness-missing-command".to_string()),
|
||||
model: None,
|
||||
provider: None,
|
||||
env_vars: BTreeMap::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = evaluate_draft(draft, &[], &[], &GlobalAgentConfig::default())
|
||||
.expect("custom command readiness evaluates");
|
||||
assert_eq!(
|
||||
result.requirements,
|
||||
vec![Requirement::MissingBinary {
|
||||
command: "buzz-readiness-missing-command".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_edit_uses_definition_owned_config() {
|
||||
let mut saved = record();
|
||||
saved.persona_id = Some("persona-1".to_string());
|
||||
saved.provider = Some("anthropic".to_string());
|
||||
saved.model = Some("stale".to_string());
|
||||
let definition = definition();
|
||||
|
||||
let result = evaluate_draft(
|
||||
existing(saved.pubkey.clone()),
|
||||
std::slice::from_ref(&saved),
|
||||
std::slice::from_ref(&definition),
|
||||
&GlobalAgentConfig::default(),
|
||||
)
|
||||
.expect("linked draft evaluates");
|
||||
|
||||
assert!(
|
||||
result.ready,
|
||||
"definition provider/model/env are authoritative"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphaned_link_is_refused_like_spawn() {
|
||||
let mut saved = record();
|
||||
saved.persona_id = Some("missing-persona".to_string());
|
||||
|
||||
let error = evaluate_draft(
|
||||
existing(saved.pubkey.clone()),
|
||||
std::slice::from_ref(&saved),
|
||||
&[],
|
||||
&GlobalAgentConfig::default(),
|
||||
)
|
||||
.expect_err("orphan must not report readiness");
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_shape_preserves_new_vs_existing_and_patch_nulls() {
|
||||
let new: AgentReadinessDraft = serde_json::from_value(serde_json::json!({
|
||||
"kind": "new",
|
||||
"config": {
|
||||
"agentCommand": "buzz-agent",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-5"
|
||||
}
|
||||
}))
|
||||
.expect("new wire shape");
|
||||
assert!(matches!(new, AgentReadinessDraft::New { .. }));
|
||||
|
||||
let existing: AgentReadinessDraft = serde_json::from_value(serde_json::json!({
|
||||
"kind": "existing",
|
||||
"config": {
|
||||
"pubkey": "agent",
|
||||
"provider": null,
|
||||
"model": null
|
||||
}
|
||||
}))
|
||||
.expect("existing wire shape");
|
||||
let AgentReadinessDraft::Existing { config } = existing else {
|
||||
panic!("expected existing draft");
|
||||
};
|
||||
assert_eq!(config.provider, Some(None));
|
||||
assert_eq!(config.model, Some(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_defaults_match_local_managed_agent_shape() {
|
||||
let saved = record();
|
||||
assert_eq!(saved.backend, BackendKind::Local);
|
||||
assert_eq!(saved.respond_to, RespondTo::OwnerOnly);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::managed_agents::config_bridge::types::ConfigOrigin;
|
||||
use crate::managed_agents::{save_managed_agents, BackendKind, RespondTo};
|
||||
use crate::managed_agents::{BackendKind, RespondTo};
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -67,68 +67,6 @@ fn goose_runtime() -> &'static KnownAcpRuntime {
|
||||
&RUNTIME
|
||||
}
|
||||
|
||||
fn agent_readiness_draft(pubkey: &str) -> AgentReadinessDraft {
|
||||
AgentReadinessDraft {
|
||||
pubkey: Some(pubkey.to_string()),
|
||||
runtime: None,
|
||||
agent_command: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
env_vars: None,
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
prior: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &std::path::Path) -> Self {
|
||||
let prior = std::env::var_os(key);
|
||||
#[allow(deprecated)]
|
||||
unsafe {
|
||||
std::env::set_var(key, value)
|
||||
};
|
||||
Self { key, prior }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
#[allow(deprecated)]
|
||||
unsafe {
|
||||
match &self.prior {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MockAppFixture {
|
||||
app: tauri::App<tauri::test::MockRuntime>,
|
||||
_temp: tempfile::TempDir,
|
||||
_home: EnvVarGuard,
|
||||
_xdg: EnvVarGuard,
|
||||
}
|
||||
|
||||
fn mock_app_in_temp_data_dir() -> MockAppFixture {
|
||||
let temp = tempfile::tempdir().expect("temp app data root");
|
||||
let home = EnvVarGuard::set("HOME", temp.path());
|
||||
let xdg = EnvVarGuard::set("XDG_DATA_HOME", temp.path());
|
||||
let app = tauri::test::mock_builder()
|
||||
.manage(crate::app_state::build_app_state())
|
||||
.build(tauri::test::mock_context(tauri::test::noop_assets()))
|
||||
.expect("mock app builds headless");
|
||||
MockAppFixture {
|
||||
app,
|
||||
_temp: temp,
|
||||
_home: home,
|
||||
_xdg: xdg,
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_record() -> ManagedAgentRecord {
|
||||
ManagedAgentRecord {
|
||||
description: None,
|
||||
@@ -224,290 +162,6 @@ fn persona_without_model() -> AgentDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_readiness_evaluation_returns_structured_missing_requirements() {
|
||||
let mut record = agent_record();
|
||||
record.persona_id = None;
|
||||
record.runtime = Some("buzz-agent".to_string());
|
||||
record.agent_command = "buzz-agent".to_string();
|
||||
record.provider = Some("anthropic".to_string());
|
||||
record.model = Some("claude-opus-4-5".to_string());
|
||||
|
||||
let evaluation = evaluate_agent_record_readiness(
|
||||
&record,
|
||||
&[],
|
||||
&crate::managed_agents::GlobalAgentConfig::default(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
!evaluation.ready,
|
||||
"anthropic without ANTHROPIC_API_KEY must be NotReady"
|
||||
);
|
||||
assert_eq!(
|
||||
evaluation.requirements,
|
||||
vec![Requirement::EnvKey {
|
||||
key: "ANTHROPIC_API_KEY".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_readiness_override_can_be_ready_without_mutating_record() {
|
||||
let mut record = agent_record();
|
||||
record.persona_id = None;
|
||||
record.runtime = Some("buzz-agent".to_string());
|
||||
record.agent_command = "buzz-agent".to_string();
|
||||
record.provider = Some("anthropic".to_string());
|
||||
record.model = Some("claude-opus-4-5".to_string());
|
||||
record.env_vars.clear();
|
||||
let original = record.clone();
|
||||
|
||||
let mut draft = agent_readiness_draft(&record.pubkey);
|
||||
draft.env_vars = Some(std::collections::BTreeMap::from([(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
"sk-test".to_string(),
|
||||
)]));
|
||||
apply_draft_override(&mut record, draft);
|
||||
|
||||
let evaluation = evaluate_agent_record_readiness(
|
||||
&record,
|
||||
&[],
|
||||
&crate::managed_agents::GlobalAgentConfig::default(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
evaluation.ready,
|
||||
"draft env var should satisfy the production readiness predicate"
|
||||
);
|
||||
assert!(
|
||||
original.env_vars.is_empty(),
|
||||
"draft evaluation must not require mutating the saved baseline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_readiness_runtime_override_uses_runtime_catalog_env_keys() {
|
||||
let mut record = agent_record();
|
||||
record.persona_id = None;
|
||||
record.runtime = Some("claude".to_string());
|
||||
record.agent_command = "claude-agent-acp".to_string();
|
||||
record.provider = None;
|
||||
record.model = None;
|
||||
|
||||
let mut draft = agent_readiness_draft(&record.pubkey);
|
||||
draft.runtime = Some(Some("buzz-agent".to_string()));
|
||||
draft.provider = Some(Some("anthropic".to_string()));
|
||||
draft.model = Some(Some("claude-opus-4-5".to_string()));
|
||||
draft.env_vars = Some(std::collections::BTreeMap::new());
|
||||
apply_draft_override(&mut record, draft);
|
||||
|
||||
let evaluation = evaluate_agent_record_readiness(
|
||||
&record,
|
||||
&[],
|
||||
&crate::managed_agents::GlobalAgentConfig::default(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
!evaluation.ready,
|
||||
"draft runtime should be evaluated as buzz-agent, not the saved Claude command"
|
||||
);
|
||||
assert!(
|
||||
evaluation.requirements.contains(&Requirement::EnvKey {
|
||||
key: "ANTHROPIC_API_KEY".to_string(),
|
||||
}),
|
||||
"missing requirements must come from the selected runtime/provider catalog; got {:?}",
|
||||
evaluation.requirements
|
||||
);
|
||||
assert!(
|
||||
!evaluation
|
||||
.requirements
|
||||
.iter()
|
||||
.any(|requirement| matches!(requirement, Requirement::CliLogin { .. })),
|
||||
"runtime override to buzz-agent must not run the saved Claude CLI-login readiness path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_readiness_null_provider_clears_to_global_fallback() {
|
||||
let mut record = agent_record();
|
||||
record.persona_id = None;
|
||||
record.runtime = Some("buzz-agent".to_string());
|
||||
record.agent_command = "buzz-agent".to_string();
|
||||
record.provider = Some("anthropic".to_string());
|
||||
record.model = Some("claude-opus-4-5".to_string());
|
||||
|
||||
let mut global = crate::managed_agents::GlobalAgentConfig {
|
||||
provider: Some("openrouter".to_string()),
|
||||
model: Some("openrouter/model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
global.env_vars.insert(
|
||||
"OPENROUTER_API_KEY".to_string(),
|
||||
"sk-openrouter".to_string(),
|
||||
);
|
||||
|
||||
let mut draft = agent_readiness_draft(&record.pubkey);
|
||||
draft.provider = Some(None);
|
||||
draft.model = Some(None);
|
||||
apply_draft_override(&mut record, draft);
|
||||
|
||||
let evaluation = evaluate_agent_record_readiness(&record, &[], &global);
|
||||
|
||||
assert!(
|
||||
evaluation.ready,
|
||||
"explicit null draft fields should clear saved values and fall through to global config"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_readiness_preserves_definition_authority_for_linked_instances() {
|
||||
let mut record = agent_record();
|
||||
record.persona_id = Some("persona-1".to_string());
|
||||
record.runtime = Some("buzz-agent".to_string());
|
||||
record.agent_command = "buzz-agent".to_string();
|
||||
record.provider = None;
|
||||
record.model = None;
|
||||
|
||||
let mut persona = persona_without_model();
|
||||
persona.provider = Some("openrouter".to_string());
|
||||
persona.model = Some("openrouter/model".to_string());
|
||||
persona.env_vars.insert(
|
||||
"OPENROUTER_API_KEY".to_string(),
|
||||
"sk-openrouter".to_string(),
|
||||
);
|
||||
|
||||
let mut draft = agent_readiness_draft(&record.pubkey);
|
||||
draft.provider = Some(Some("anthropic".to_string()));
|
||||
draft.model = Some(Some("claude-opus-4-5".to_string()));
|
||||
draft.env_vars = Some(std::collections::BTreeMap::new());
|
||||
apply_draft_override(&mut record, draft);
|
||||
|
||||
let evaluation = evaluate_agent_record_readiness(
|
||||
&record,
|
||||
&[persona],
|
||||
&crate::managed_agents::GlobalAgentConfig::default(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
evaluation.ready,
|
||||
"linked instances must still resolve provider/model from the persona, not draft instance fields"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_readiness_command_reads_persisted_agent_and_returns_requirements() {
|
||||
let _guard = crate::managed_agents::lock_env_mutex();
|
||||
let fixture = mock_app_in_temp_data_dir();
|
||||
let mut record = agent_record();
|
||||
record.pubkey = "a".repeat(64);
|
||||
record.persona_id = None;
|
||||
record.runtime = Some("buzz-agent".to_string());
|
||||
record.agent_command = "buzz-agent".to_string();
|
||||
record.provider = Some("anthropic".to_string());
|
||||
record.model = Some("claude-opus-4-5".to_string());
|
||||
save_managed_agents(fixture.app.handle(), &[record.clone()]).expect("seed saved agent");
|
||||
|
||||
let evaluation = evaluate_saved_agent_readiness_for_app(
|
||||
fixture.app.handle(),
|
||||
&fixture.app.state::<AppState>(),
|
||||
&record.pubkey,
|
||||
)
|
||||
.expect("command evaluates saved readiness");
|
||||
|
||||
assert_eq!(
|
||||
evaluation.requirements,
|
||||
vec![Requirement::EnvKey {
|
||||
key: "ANTHROPIC_API_KEY".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn draft_readiness_command_uses_overrides_without_writing_them() {
|
||||
let _guard = crate::managed_agents::lock_env_mutex();
|
||||
let fixture = mock_app_in_temp_data_dir();
|
||||
let mut record = agent_record();
|
||||
record.pubkey = "b".repeat(64);
|
||||
record.persona_id = None;
|
||||
record.runtime = Some("buzz-agent".to_string());
|
||||
record.agent_command = "buzz-agent".to_string();
|
||||
record.provider = Some("anthropic".to_string());
|
||||
record.model = Some("claude-opus-4-5".to_string());
|
||||
save_managed_agents(fixture.app.handle(), &[record.clone()]).expect("seed saved agent");
|
||||
|
||||
let mut draft = agent_readiness_draft(&record.pubkey);
|
||||
draft.env_vars = Some(std::collections::BTreeMap::from([(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
"sk-test".to_string(),
|
||||
)]));
|
||||
|
||||
let evaluation = evaluate_draft_agent_readiness_for_app(
|
||||
fixture.app.handle(),
|
||||
&fixture.app.state::<AppState>(),
|
||||
draft,
|
||||
)
|
||||
.expect("command evaluates draft readiness");
|
||||
|
||||
assert!(evaluation.ready);
|
||||
let saved = load_managed_agents(fixture.app.handle()).expect("reload saved agents");
|
||||
assert!(
|
||||
saved[0].env_vars.is_empty(),
|
||||
"draft command must not persist override env vars"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn draft_readiness_command_accepts_unsaved_create_baseline() {
|
||||
let _guard = crate::managed_agents::lock_env_mutex();
|
||||
let fixture = mock_app_in_temp_data_dir();
|
||||
let mut draft = agent_readiness_draft(&"c".repeat(64));
|
||||
draft.pubkey = None;
|
||||
draft.runtime = Some(Some("buzz-agent".to_string()));
|
||||
draft.provider = Some(Some("anthropic".to_string()));
|
||||
draft.model = Some(Some("claude-opus-4-5".to_string()));
|
||||
draft.env_vars = Some(std::collections::BTreeMap::new());
|
||||
|
||||
let evaluation = evaluate_draft_agent_readiness_for_app(
|
||||
fixture.app.handle(),
|
||||
&fixture.app.state::<AppState>(),
|
||||
draft,
|
||||
)
|
||||
.expect("command evaluates create draft readiness");
|
||||
|
||||
assert_eq!(
|
||||
evaluation.requirements,
|
||||
vec![Requirement::EnvKey {
|
||||
key: "ANTHROPIC_API_KEY".to_string(),
|
||||
}],
|
||||
"create drafts should use the runtime catalog without requiring a saved agent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_readiness_custom_command_reports_missing_binary() {
|
||||
let mut record = agent_record();
|
||||
record.persona_id = None;
|
||||
let missing_command = "buzz-readiness-draft-missing-harness";
|
||||
|
||||
let mut draft = agent_readiness_draft(&record.pubkey);
|
||||
draft.runtime = Some(None);
|
||||
draft.agent_command = Some(Some(missing_command.to_string()));
|
||||
apply_draft_override(&mut record, draft);
|
||||
|
||||
let evaluation = evaluate_agent_record_readiness(
|
||||
&record,
|
||||
&[],
|
||||
&crate::managed_agents::GlobalAgentConfig::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
evaluation.requirements,
|
||||
vec![Requirement::MissingBinary {
|
||||
command: missing_command.to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// A post-spawn session cache whose live model is `current_model` and whose
|
||||
/// `model_overridden` flag records whether a `SwitchModel` control signal set
|
||||
/// it (the live-switch signal).
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::{
|
||||
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
|
||||
stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest,
|
||||
validate_provider_config, BackendKind, CreateManagedAgentRequest,
|
||||
CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
|
||||
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND,
|
||||
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
},
|
||||
relay::relay_ws_url_with_override,
|
||||
util::now_iso,
|
||||
@@ -56,8 +56,10 @@ pub(super) fn summarize_from_disk(
|
||||
}
|
||||
|
||||
#[path = "agents_create_fields.rs"]
|
||||
mod create_fields;
|
||||
use create_fields::{normalize_relay_mesh, resolve_created_avatar_url, trim_to_optional_string};
|
||||
pub(super) mod create_fields;
|
||||
use create_fields::{
|
||||
normalize_relay_mesh, resolve_created_avatar_url, resolve_created_inference_config,
|
||||
};
|
||||
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
async fn ensure_relay_mesh_for_record(
|
||||
@@ -579,15 +581,13 @@ pub async fn create_managed_agent(
|
||||
let snapshot_model = persona_snapshot.as_ref().and_then(|s| s.model.clone());
|
||||
let snapshot_provider = persona_snapshot.as_ref().and_then(|s| s.provider.clone());
|
||||
let snapshot_source_version = persona_snapshot.as_ref().map(|s| s.source_version.clone());
|
||||
let effective_provider = snapshot_provider
|
||||
.or_else(|| input.provider.as_deref().and_then(trim_to_optional_string));
|
||||
let mut effective_model =
|
||||
snapshot_model.or_else(|| input.model.as_deref().and_then(trim_to_optional_string));
|
||||
if effective_provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID)
|
||||
&& effective_model.is_none()
|
||||
{
|
||||
effective_model = Some(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID.to_string());
|
||||
}
|
||||
let inference = resolve_created_inference_config(
|
||||
snapshot_provider.as_deref().or(input.provider.as_deref()),
|
||||
snapshot_model.as_deref().or(input.model.as_deref()),
|
||||
relay_mesh.clone(),
|
||||
);
|
||||
let effective_provider = inference.provider;
|
||||
let effective_model = inference.model;
|
||||
|
||||
// Mint-time behavioral quad: explicit input wins, then the linked
|
||||
// definition's NIP-AP defaults, then client defaults. The ONLY parse
|
||||
@@ -677,15 +677,7 @@ pub async fn create_managed_agent(
|
||||
definition_respond_to: None,
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: if effective_provider.as_deref()
|
||||
== Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID)
|
||||
{
|
||||
effective_model
|
||||
.clone()
|
||||
.map(|model_ref| RelayMeshConfig { model_ref })
|
||||
} else {
|
||||
relay_mesh.clone()
|
||||
},
|
||||
relay_mesh: inference.relay_mesh,
|
||||
effort_level: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,39 @@
|
||||
|
||||
use crate::managed_agents::{managed_agent_avatar_url, BackendKind, RelayMeshConfig};
|
||||
|
||||
pub(crate) struct CreatedInferenceConfig {
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub relay_mesh: Option<RelayMeshConfig>,
|
||||
}
|
||||
|
||||
/// Normalize the provider/model fields written by Create.
|
||||
///
|
||||
/// Kept as one pure projection so readiness preview and persistence cannot
|
||||
/// disagree about implicit provider defaults such as relay-mesh's `auto` model.
|
||||
pub(crate) fn resolve_created_inference_config(
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
relay_mesh: Option<RelayMeshConfig>,
|
||||
) -> CreatedInferenceConfig {
|
||||
let provider = provider.and_then(trim_to_optional_string);
|
||||
let mut model = model.and_then(trim_to_optional_string);
|
||||
if provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) && model.is_none()
|
||||
{
|
||||
model = Some(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID.to_string());
|
||||
}
|
||||
let relay_mesh = if provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) {
|
||||
model.clone().map(|model_ref| RelayMeshConfig { model_ref })
|
||||
} else {
|
||||
relay_mesh
|
||||
};
|
||||
CreatedInferenceConfig {
|
||||
provider,
|
||||
model,
|
||||
relay_mesh,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_relay_mesh(
|
||||
config: Option<&RelayMeshConfig>,
|
||||
backend: &BackendKind,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::managed_agents::AgentDefinition;
|
||||
use crate::managed_agents::{AgentDefinition, RelayMeshConfig};
|
||||
|
||||
fn bare_agent_record(
|
||||
persona_id: Option<&str>,
|
||||
@@ -271,6 +271,26 @@ fn deploy_resolver_returns_none_for_orphaned_instance() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_inference_config_defaults_relay_mesh_model() {
|
||||
let config = resolve_created_inference_config(
|
||||
Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
config.model.as_deref(),
|
||||
Some(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID)
|
||||
);
|
||||
assert_eq!(
|
||||
config.relay_mesh,
|
||||
Some(RelayMeshConfig {
|
||||
model_ref: crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID.to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_relay_mesh_rejects_empty_model_ref() {
|
||||
let config = RelayMeshConfig {
|
||||
|
||||
@@ -715,7 +715,6 @@ pub fn run() {
|
||||
discover_agent_models,
|
||||
agent_access_owner_only,
|
||||
get_agent_config_surface,
|
||||
evaluate_agent_readiness,
|
||||
evaluate_agent_readiness_draft,
|
||||
get_runtime_file_config,
|
||||
get_baked_build_env_keys,
|
||||
|
||||
@@ -706,6 +706,13 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool {
|
||||
/// On timeout or spawn failure the child is killed and `Unknown` is returned;
|
||||
/// no orphaned threads or processes are left behind (see
|
||||
/// [`bounded_command::output_with_timeout`]).
|
||||
pub(crate) fn bounded_probe_output(
|
||||
command: std::process::Command,
|
||||
timeout: Duration,
|
||||
) -> Option<std::process::Output> {
|
||||
bounded_command::output_with_timeout(command, timeout)
|
||||
}
|
||||
|
||||
fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus {
|
||||
use crate::managed_agents::readiness::cli_probe;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::managed_agents::runtime::build_augmented_path;
|
||||
|
||||
@@ -53,6 +54,11 @@ const CONFIG_PARSE_SIGNALS: &[&str] = &["error loading configuration", "unknown
|
||||
/// bypassed. Injects the same augmented PATH used for launched agents so
|
||||
/// script shims with `/usr/bin/env <interpreter>` shebangs can find runtimes
|
||||
/// such as node/python when the app was launched with a bare GUI PATH.
|
||||
///
|
||||
/// The shared discovery runner owns the process tree and bounds wall-clock time
|
||||
/// and captured output. Draft readiness can run while someone is editing, so a
|
||||
/// broken external CLI must never stall agent operations or grow memory without
|
||||
/// bound.
|
||||
pub(crate) fn login_probe(
|
||||
binary_path: &Path,
|
||||
probe_args: &[&str],
|
||||
@@ -63,13 +69,13 @@ pub(crate) fn login_probe(
|
||||
if let Some(path) = augmented_path {
|
||||
command.env("PATH", path);
|
||||
}
|
||||
crate::util::configure_no_window(&mut command);
|
||||
let Some(output) =
|
||||
crate::managed_agents::discovery::bounded_probe_output(command, Duration::from_secs(10))
|
||||
else {
|
||||
return ProbeOutcome::LoggedOut;
|
||||
};
|
||||
|
||||
match command.output() {
|
||||
Ok(o) if o.status.success() => ProbeOutcome::LoggedIn,
|
||||
Ok(o) => classify_probe_output(&o.stderr, false),
|
||||
Err(_) => ProbeOutcome::LoggedOut,
|
||||
}
|
||||
classify_probe_output(&output.stderr, output.status.success())
|
||||
}
|
||||
|
||||
/// Classify collected probe output into a `ProbeOutcome`.
|
||||
|
||||
@@ -36,14 +36,15 @@ X" flag): add it to `KnownAcpRuntime` first, expose it on
|
||||
`AcpRuntimeCatalogEntry`, then project it through the core. Do not shortcut
|
||||
with a TypeScript lookup table or an id comparison in a component.
|
||||
|
||||
**Draft readiness is a backend question.** Unsaved managed-agent edits are
|
||||
submitted to `evaluate_agent_readiness_draft`, which applies the draft to a
|
||||
saved-record clone and then runs the same `resolve_effective_agent_env` →
|
||||
`agent_readiness` path used by persisted agents. The response is presentation-
|
||||
ready (`ready` plus structured `Requirement` rows with their `surface`
|
||||
discriminators). Frontends may use the old provider table only until their
|
||||
consumer migration lands; no new surface should derive provider/credential/CLI
|
||||
readiness in TypeScript or add another table.
|
||||
**Draft readiness is a backend question.** Unsaved managed-agent configuration
|
||||
is submitted to `evaluate_agent_readiness_draft` as an explicit `new` proposal
|
||||
or an `existing` agent patch. The backend projects it through the same create /
|
||||
update helpers and fallible effective-harness resolver used by Save and Start,
|
||||
then runs `agent_readiness`. The response is presentation-ready (`ready` plus
|
||||
structured `Requirement` rows with their `surface` discriminators). Frontends
|
||||
may use the old provider table only until their consumer migration lands; no
|
||||
new surface should derive provider/credential/CLI readiness in TypeScript or
|
||||
add another table.
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -119,11 +119,8 @@ test("relay rate-limited: prefix check is case-sensitive (Rust always emits lowe
|
||||
// arrives as definitionEnv (camelCase), source "custom" is preserved, and the
|
||||
// env round-trips end-to-end so a save-then-edit cycle cannot erase env.
|
||||
|
||||
const {
|
||||
evaluateAgentReadiness,
|
||||
evaluateAgentReadinessDraft,
|
||||
fromRawAcpRuntimeCatalogEntry,
|
||||
} = await import("./tauri.ts");
|
||||
const { evaluateAgentReadinessDraft, fromRawAcpRuntimeCatalogEntry } =
|
||||
await import("./tauri.ts");
|
||||
|
||||
test("fromRawAcpRuntimeCatalogEntry maps definition_env to definitionEnv", () => {
|
||||
const raw = {
|
||||
@@ -263,30 +260,6 @@ test("fromRawAcpRuntimeCatalogEntry omits maxParallelism when max_parallelism is
|
||||
|
||||
// ── Agent readiness command wrappers ──────────────────────────────────────────
|
||||
|
||||
test("evaluateAgentReadiness invokes the saved readiness command", async () => {
|
||||
const calls = [];
|
||||
const prior = globalThis.window.__TAURI_INTERNALS__;
|
||||
globalThis.window.__TAURI_INTERNALS__ = {
|
||||
invoke: async (command, args) => {
|
||||
calls.push({ command, args });
|
||||
return { ready: false, requirements: [] };
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await evaluateAgentReadiness("agent-pubkey");
|
||||
assert.deepStrictEqual(result, { ready: false, requirements: [] });
|
||||
assert.deepStrictEqual(calls, [
|
||||
{
|
||||
command: "evaluate_agent_readiness",
|
||||
args: { pubkey: "agent-pubkey" },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
globalThis.window.__TAURI_INTERNALS__ = prior;
|
||||
}
|
||||
});
|
||||
|
||||
test("evaluateAgentReadinessDraft forwards draft overrides without persisting", async () => {
|
||||
const calls = [];
|
||||
const prior = globalThis.window.__TAURI_INTERNALS__;
|
||||
@@ -301,28 +274,25 @@ test("evaluateAgentReadinessDraft forwards draft overrides without persisting",
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await evaluateAgentReadinessDraft({
|
||||
pubkey: "agent-pubkey",
|
||||
runtime: "buzz-agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
envVars: { ANTHROPIC_API_KEY: "" },
|
||||
});
|
||||
const draft = {
|
||||
kind: "existing",
|
||||
config: {
|
||||
pubkey: "agent-pubkey",
|
||||
agentCommand: "buzz-agent",
|
||||
harnessOverride: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
envVars: { ANTHROPIC_API_KEY: "" },
|
||||
},
|
||||
};
|
||||
const result = await evaluateAgentReadinessDraft(draft);
|
||||
assert.deepStrictEqual(result.requirements, [
|
||||
{ surface: "env_key", key: "ANTHROPIC_API_KEY" },
|
||||
]);
|
||||
assert.deepStrictEqual(calls, [
|
||||
{
|
||||
command: "evaluate_agent_readiness_draft",
|
||||
args: {
|
||||
draft: {
|
||||
pubkey: "agent-pubkey",
|
||||
runtime: "buzz-agent",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
envVars: { ANTHROPIC_API_KEY: "" },
|
||||
},
|
||||
},
|
||||
args: { draft },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
|
||||
@@ -944,31 +944,13 @@ export async function getAgentConfigSurface(
|
||||
});
|
||||
}
|
||||
|
||||
/** Evaluate a saved managed agent's backend-owned readiness requirements. */
|
||||
export async function evaluateAgentReadiness(
|
||||
pubkey: string,
|
||||
): Promise<AgentReadinessEvaluation> {
|
||||
return invokeTauri<AgentReadinessEvaluation>("evaluate_agent_readiness", {
|
||||
pubkey,
|
||||
});
|
||||
}
|
||||
|
||||
/** Evaluate unsaved config edits without persisting them. */
|
||||
/** Evaluate an unsaved new-agent config or exact saved-agent patch. */
|
||||
export async function evaluateAgentReadinessDraft(
|
||||
draft: AgentReadinessDraft,
|
||||
): Promise<AgentReadinessEvaluation> {
|
||||
const payload: AgentReadinessDraft = {};
|
||||
if (draft.pubkey !== undefined) payload.pubkey = draft.pubkey;
|
||||
if (draft.runtime !== undefined) payload.runtime = draft.runtime;
|
||||
if (draft.agentCommand !== undefined)
|
||||
payload.agentCommand = draft.agentCommand;
|
||||
if (draft.model !== undefined) payload.model = draft.model;
|
||||
if (draft.provider !== undefined) payload.provider = draft.provider;
|
||||
if (draft.envVars !== undefined) payload.envVars = draft.envVars;
|
||||
|
||||
return invokeTauri<AgentReadinessEvaluation>(
|
||||
"evaluate_agent_readiness_draft",
|
||||
{ draft: payload },
|
||||
{ draft },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -309,17 +309,29 @@ export type AgentReadinessEvaluation = {
|
||||
requirements: AgentReadinessRequirement[];
|
||||
};
|
||||
|
||||
export type AgentReadinessDraft = {
|
||||
/** Saved agent baseline for edit drafts. Omit for create drafts. */
|
||||
pubkey?: string;
|
||||
/** Runtime id override. null clears to inherited/default resolution. */
|
||||
runtime?: string | null;
|
||||
/** Custom/raw command override. Prefer runtime for catalog-known harnesses. */
|
||||
agentCommand?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
envVars?: Record<string, string>;
|
||||
};
|
||||
export type AgentReadinessDraft =
|
||||
| {
|
||||
kind: "new";
|
||||
config: {
|
||||
/** Same authoritative harness command submitted by Create. */
|
||||
agentCommand?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
envVars?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: "existing";
|
||||
config: {
|
||||
pubkey: string;
|
||||
/** Same command or inheritance sentinel submitted by Save. */
|
||||
agentCommand?: string;
|
||||
harnessOverride?: boolean;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
envVars?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ManagedAgentRuntimeStatus = {
|
||||
pubkey: string;
|
||||
|
||||
Reference in New Issue
Block a user