fix(agents): apply live instructions on local restart

Resolve shared-instruction authorization from the current linked definition for both local and remote starts, and stamp the exact spawned list so restart-required clears after revocation.

Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-25 04:18:29 -07:00
committed by Rizz
parent 11b1861aa3
commit fb787b8ed2
7 changed files with 132 additions and 20 deletions

View File

@@ -50,7 +50,10 @@ pub(super) fn build_launch_block(
effective_model: Option<&str>,
owner_pubkey: &str,
) -> Result<serde_json::Value, String> {
let assigned_relay_skills = live_assigned_relay_skills(record, personas)?;
let assigned_relay_skills =
crate::managed_agents::effective_config::resolve_effective_assigned_relay_skills(
record, personas,
)?;
Ok(serialize_launch_block(
record,
descriptor,
@@ -193,20 +196,6 @@ pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result
Ok(())
}
fn live_assigned_relay_skills<'a>(
record: &'a ManagedAgentRecord,
personas: &'a [AgentDefinition],
) -> Result<&'a [String], String> {
match record.persona_id.as_deref() {
Some(persona_id) => personas
.iter()
.find(|persona| persona.id == persona_id)
.map(|persona| persona.assigned_relay_skills.as_slice())
.ok_or_else(|| format!("linked agent definition {persona_id:?} is missing")),
None => Ok(record.assigned_relay_skills.as_slice()),
}
}
/// Build the standard agent JSON payload for provider deploy calls.
pub(crate) fn build_deploy_payload(
app: &AppHandle,

View File

@@ -261,6 +261,22 @@ pub fn resolve_effective_config(
}
}
/// Resolve shared-instruction authorization from the live linked definition.
/// Definition-less records retain their own assignments; orphaned links are refused.
pub fn resolve_effective_assigned_relay_skills<'a>(
record: &'a ManagedAgentRecord,
definitions: &'a [AgentDefinition],
) -> Result<&'a [String], String> {
match record.persona_id.as_deref() {
Some(persona_id) => definitions
.iter()
.find(|definition| definition.id == persona_id)
.map(|definition| definition.assigned_relay_skills.as_slice())
.ok_or_else(|| ORPHANED_INSTANCE_ERROR.to_string()),
None => Ok(record.assigned_relay_skills.as_slice()),
}
}
pub fn resolve_effective_model_provider_pair(
record: &ManagedAgentRecord,
definitions: &[AgentDefinition],

View File

@@ -699,6 +699,58 @@ mod tests {
assert_eq!(env, Some(coordinate.as_str()));
}
#[test]
fn effective_assigned_relay_skill_env_uses_live_definition() {
let stale = format!("30023:{}:revoked", "a".repeat(64));
let current = format!("30023:{}:kept", "b".repeat(64));
let record: super::super::ManagedAgentRecord = serde_json::from_value(serde_json::json!({
"pubkey": "agent",
"name": "agent",
"persona_id": "persona",
"private_key_nsec": "nsec1fake",
"relay_url": "wss://relay.example",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 300,
"parallelism": 1,
"assigned_relay_skills": [stale.clone()],
"created_at": "now",
"updated_at": "now"
}))
.unwrap();
let definition: super::super::AgentDefinition = serde_json::from_value(serde_json::json!({
"id": "persona",
"display_name": "Persona",
"system_prompt": "prompt",
"name_pool": [],
"is_builtin": false,
"is_active": true,
"assigned_relay_skills": [current.clone()],
"created_at": "now",
"updated_at": "now"
}))
.unwrap();
let mut command = std::process::Command::new("buzz-acp");
let definitions = [definition];
let assigned =
crate::managed_agents::effective_config::resolve_effective_assigned_relay_skills(
&record,
&definitions,
)
.unwrap();
apply_assigned_relay_skills_env(&mut command, assigned);
let env = command
.get_envs()
.find(|(key, _)| *key == ASSIGNED_RELAY_SKILLS_ENV)
.and_then(|(_, value)| value)
.and_then(|value| value.to_str());
assert_eq!(env, Some(current.as_str()));
}
#[test]
fn accepts_exact_coordinate_and_deduplicates() {
let key = "a".repeat(64);

View File

@@ -694,10 +694,9 @@ pub fn spawn_agent_child(
} else {
command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS");
}
super::relay_skills::apply_assigned_relay_skills_env(
&mut command,
&record.assigned_relay_skills,
);
let assigned_relay_skills =
super::effective_config::resolve_effective_assigned_relay_skills(record, &personas)?;
super::relay_skills::apply_assigned_relay_skills_env(&mut command, assigned_relay_skills);
// Prompt, model, and provider all come from the single `effective_cfg`
// resolved at the top of this function — the SAME resolve the spawn-config
@@ -864,6 +863,7 @@ pub fn spawn_agent_child(
system_prompt: effective_prompt.as_deref(),
model: effective_model.as_deref(),
provider: effective_provider.as_deref(),
assigned_relay_skills,
enforced_owner_only: super::owner_only_access_build(),
},
);

View File

@@ -73,6 +73,8 @@ pub(crate) struct SpawnConfigInputs<'a> {
pub system_prompt: Option<&'a str>,
pub model: Option<&'a str>,
pub provider: Option<&'a str>,
/// Exact live authorization list applied to the spawned process.
pub assigned_relay_skills: &'a [String],
/// Compile-time distribution capability projected at this runtime boundary.
/// The stored record remains portable; only effective spawned access is stamped.
pub enforced_owner_only: bool,
@@ -167,6 +169,7 @@ impl SpawnConfigSnapshot {
system_prompt,
model,
provider,
assigned_relay_skills,
enforced_owner_only,
} = inputs;
let (respond_to, respond_to_allowlist) =
@@ -195,7 +198,7 @@ impl SpawnConfigSnapshot {
system_prompt: system_prompt.map(str::to_string),
model: model.map(str::to_string),
provider: provider.map(str::to_string),
assigned_relay_skills: record.assigned_relay_skills.clone(),
assigned_relay_skills: assigned_relay_skills.to_vec(),
session_title: (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR))
.then(|| resolve_session_title(record.display_name.as_deref(), &record.name))
.flatten(),
@@ -301,6 +304,10 @@ pub(crate) fn prospective_spawn_config_snapshot(
EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None),
};
let assigned_relay_skills =
super::effective_config::resolve_effective_assigned_relay_skills(record, personas)
.unwrap_or_default();
SpawnConfigSnapshot::from_inputs(SpawnConfigInputs {
record,
descriptor: &descriptor,
@@ -311,6 +318,7 @@ pub(crate) fn prospective_spawn_config_snapshot(
system_prompt: prompt.as_deref(),
model: model.as_deref(),
provider: provider.as_deref(),
assigned_relay_skills,
enforced_owner_only,
})
}

View File

@@ -2,6 +2,8 @@ use super::*;
use crate::managed_agents::types::RespondTo;
use std::collections::BTreeMap;
mod assigned_relay_skills;
/// Canonical projection of a prospective snapshot — the exact value the drift
/// comparison reads, so these tests assert on drift itself rather than on a
/// proxy for it.

View File

@@ -0,0 +1,45 @@
use super::*;
#[test]
fn restart_stamp_uses_live_shared_instructions_and_clears_diff() {
let mut rec = record();
rec.persona_id = Some("pers".into());
rec.assigned_relay_skills = vec![
format!("30023:{}:kept", "b".repeat(64)),
format!("30023:{}:revoked", "a".repeat(64)),
];
let mut live = persona("pers", Some("goose"), "prompt");
live.assigned_relay_skills = vec![format!("30023:{}:kept", "b".repeat(64))];
let personas = [live];
let global = GlobalAgentConfig::default();
let descriptor =
crate::managed_agents::resolve_effective_harness_descriptor(&rec, &personas, &global)
.unwrap();
let effective = resolve_effective_config(&rec, &personas, &global)
.require_resolved()
.unwrap();
let assigned =
crate::managed_agents::effective_config::resolve_effective_assigned_relay_skills(
&rec, &personas,
)
.unwrap();
let stamped = SpawnConfigSnapshot::from_inputs(SpawnConfigInputs {
record: &rec,
descriptor: &descriptor,
relay_url: "wss://ws.example",
team_instructions: None,
system_prompt: effective.system_prompt.value.as_deref(),
model: effective.model.value.as_deref(),
provider: effective.provider.value.as_deref(),
assigned_relay_skills: assigned,
enforced_owner_only: false,
});
let current =
prospective_spawn_config_snapshot(&rec, &personas, &[], "wss://ws.example", &global, false);
assert_eq!(
stamped.assigned_relay_skills,
personas[0].assigned_relay_skills
);
assert_eq!(stamped.canonical(), current.canonical());
}