fix(agents): share readiness config projection

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-09-06 21:57:05 -07:00
parent 78f268d2e1
commit a03278afc8
14 changed files with 633 additions and 725 deletions

View File

@@ -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}"))?

View 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;

View 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);
}

View File

@@ -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).

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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`.

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 },
);
}

View File

@@ -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;