- 1
//! Durable user-facing Agent definitions. - 2
//! - 3
//! Definitions describe presentation and prompt preferences. They never grant - 4
//! tools, permissions, credentials, budget, or a wider execution scope. - 5
- 6
use std::{ - 7
collections::HashSet, - 8
path::{Path, PathBuf}, - 9
}; - 10
- 11
use serde::{Deserialize, Serialize}; - 12
- 13
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 14
#[serde(rename_all = "lowercase")] - 15
pub enum AgentLifecycle { - 16
Active, - 17
Paused, - 18
Archived, - 19
} - 20
- 21
fn default_lifecycle() -> AgentLifecycle { - 22
AgentLifecycle::Active - 23
} - 24
- 25
#[derive(Debug, Clone, Serialize, Deserialize)] - 26
pub struct AgentDefinition { - 27
pub id: String, - 28
#[serde(default = "default_revision")] - 29
pub revision: u64, - 30
#[serde(default = "default_lifecycle")] - 31
pub lifecycle: AgentLifecycle, - 32
pub name: String, - 33
pub character: String, - 34
pub personality: String, - 35
pub behaviour: String, - 36
#[serde(default)] - 37
pub responsibilities: String, - 38
#[serde(default)] - 39
pub instructions: String, - 40
pub animation: String, - 41
pub voice: String, - 42
} - 43
- 44
fn default_revision() -> u64 { - 45
1 - 46
} - 47
- 48
impl AgentDefinition { - 49
pub fn is_admissible(&self) -> bool { - 50
self.lifecycle == AgentLifecycle::Active - 51
} - 52
- 53
pub fn identity(&self) -> vak_session::types::AgentIdentity { - 54
vak_session::types::AgentIdentity { - 55
id: self.id.clone(), - 56
revision: self.revision, - 57
name: self.name.clone(), - 58
character: self.character.clone(), - 59
personality: self.personality.clone(), - 60
animation: self.animation.clone(), - 61
voice: self.voice.clone(), - 62
behaviour: self.behaviour.clone(), - 63
responsibilities: self.responsibilities.clone(), - 64
instructions: self.instructions.clone(), - 65
} - 66
} - 67
} - 68
- 69
#[derive(Debug, Clone, Serialize, Deserialize)] - 70
pub struct AgentTemplate { - 71
pub template_id: String, - 72
pub domain: String, - 73
pub name: String, - 74
pub description: String, - 75
pub character: String, - 76
pub personality: String, - 77
pub behaviour: String, - 78
pub responsibilities: String, - 79
pub instructions: String, - 80
pub animation: String, - 81
pub voice: String, - 82
} - 83
- 84
impl AgentTemplate { - 85
pub fn to_agent_definition( - 86
&self, - 87
agent_id: &str, - 88
custom_name: Option<&str>, - 89
) -> AgentDefinition { - 90
AgentDefinition { - 91
id: agent_id.to_string(), - 92
revision: 1, - 93
lifecycle: AgentLifecycle::Active, - 94
name: custom_name.unwrap_or(&self.name).to_string(), - 95
character: self.character.clone(), - 96
personality: self.personality.clone(), - 97
behaviour: self.behaviour.clone(), - 98
responsibilities: self.responsibilities.clone(), - 99
instructions: self.instructions.clone(), - 100
animation: self.animation.clone(), - 101
voice: self.voice.clone(), - 102
} - 103
} - 104
} - 105
- 106
pub fn builtin_templates() -> Vec<AgentTemplate> { - 107
vec![ - 108
AgentTemplate { - 109
template_id: "researcher".into(), - 110
domain: "Research & Synthesis".into(), - 111
name: "Research Analyst".into(), - 112
description: "Finds and checks sources, then sums up what they say, with links you can follow.".into(), - 113
character: "moss".into(), - 114
personality: "Rigorous, impartial, inquisitive, and evidence-driven.".into(), - 115
behaviour: "Cite every factual finding with numbered brackets [1], [2] linked to bibliography. Actively highlight epistemic uncertainty and counter-evidence.".into(), - 116
responsibilities: "Literature review, competitive intelligence, factual verification, and multi-source synthesis.".into(), - 117
instructions: "When analyzing sources, verify source reliability before adopting claims. Never present unverified inferences as established fact. Check claims against primary sources with the tools you have.".into(), - 118
animation: "subtle".into(), - 119
voice: "calm".into(), - 120
}, - 121
AgentTemplate { - 122
template_id: "writer".into(), - 123
domain: "Communications & Writing".into(), - 124
name: "Communications & Writer".into(), - 125
description: "Drafts and edits emails, posts, reports and documents in the right tone for who reads them.".into(), - 126
character: "pip".into(), - 127
personality: "Clear, articulate, engaging, and rhetorically adaptable.".into(), - 128
behaviour: "Structure deliverables with clear hierarchies, compelling introductions, scannable body sections, and concise executive summaries.".into(), - 129
responsibilities: "Drafting essays, briefing memos, documentation, announcements, and narrative communications.".into(), - 130
instructions: "Adapt tone and vocabulary precisely to target audience requirements. Ensure high scannability using clear headings, concise paragraphs, and bulleted takeaways.".into(), - 131
animation: "expressive".into(), - 132
voice: "bright".into(), - 133
}, - 134
AgentTemplate { - 135
template_id: "operator".into(), - 136
domain: "Operations".into(), - 137
name: "Operations Lead".into(), - 138
description: "Looks before it acts, makes changes in small steps you can undo, and checks each one worked.".into(), - 139
character: "beni".into(), - 140
personality: "Pragmatic, careful, risk-aware, and action-oriented.".into(), - 141
behaviour: "Inspect the current state before changing it, act in small reversible steps, confirm each effect before the next, and report exactly what changed and what did not.".into(), - 142
responsibilities: "Running and changing systems, services and workflows; incident checks; routine operational tasks.".into(), - 143
instructions: "Never change something you have not inspected first. Prefer the smallest change that achieves the goal, and say how to undo it.".into(), - 144
animation: "subtle".into(), - 145
voice: "calm".into(), - 146
}, - 147
AgentTemplate { - 148
template_id: "analyst".into(), - 149
domain: "Data & Analytics".into(), - 150
name: "Data Analyst".into(), - 151
description: "Works through spreadsheets and tables and explains the numbers plainly.".into(), - 152
character: "tavi".into(), - 153
personality: "Precise, analytical, detail-oriented, and statistically sound.".into(), - 154
behaviour: "Compute aggregations with tools rather than estimating them, inspect row distributions, verify numerical totals, and format outputs as clean tables.".into(), - 155
responsibilities: "CSV/TSV analysis, tabular transformations, summary statistics, and quantitative reporting.".into(), - 156
instructions: "Always verify mathematical accuracy against source data before stating conclusions. Provide row counts, distribution summaries, and clear column labels.".into(), - 157
animation: "subtle".into(), - 158
voice: "quiet".into(), - 159
}, - 160
] - 161
} - 162
- 163
pub fn find_template(id: &str) -> Option<AgentTemplate> { - 164
builtin_templates() - 165
.into_iter() - 166
.find(|t| t.template_id == id) - 167
} - 168
- 169
pub fn effective(core: &vak_core::Core) -> Result<Vec<AgentDefinition>, String> { - 170
let shared = vak_config::paths::default_workspace(); - 171
let mut profiles = load(&shared)?; - 172
if core.cwd() != &shared && core.project_config_trusted() { - 173
for profile in load(core.cwd())? { - 174
profiles.retain(|p| p.id != profile.id); - 175
profiles.push(profile); - 176
} - 177
} - 178
Ok(profiles) - 179
} - 180
- 181
fn path(cwd: &Path) -> PathBuf { - 182
cwd.join(".vak").join("agents.json") - 183
} - 184
- 185
pub fn load(cwd: &Path) -> Result<Vec<AgentDefinition>, String> { - 186
let file = path(cwd); - 187
let raw = match std::fs::read_to_string(&file) { - 188
Ok(raw) => raw, - 189
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - 190
Err(e) => return Err(format!("cannot read {}: {e}", file.display())), - 191
}; - 192
serde_json::from_str(&raw).map_err(|e| format!("invalid agents: {e}")) - 193
} - 194
- 195
/// `trusted` is the *creating* context's own trust decision (the workspace - 196
/// this admin/client session is already running against), carried forward - 197
/// onto each profile's isolated workspace so its own privileged config - 198
/// (`permission_mode`, `hooks`, `mcp.servers`, ...) actually applies — - 199
/// otherwise every user-created Agent's own settings are silently stripped - 200
/// forever, since nothing else ever visits or prompts about that nested - 201
/// directory (see `vak_core::trust::mark_trusted`). - 202
pub fn save( - 203
cwd: &Path, - 204
profiles: &[AgentDefinition], - 205
trusted: bool, - 206
) -> Result<Vec<AgentDefinition>, String> { - 207
if profiles.len() > 100 { - 208
return Err("at most 100 agents are allowed".into()); - 209
} - 210
let mut ids = HashSet::with_capacity(profiles.len()); - 211
for profile in profiles { - 212
if profile.id == "vak" { - 213
return Err("Vakyartha is the built-in agent; choose another id".into()); - 214
} - 215
if profile.id.trim().is_empty() || profile.name.trim().is_empty() { - 216
return Err("agent id and name are required".into()); - 217
} - 218
if !ids.insert(profile.id.clone()) { - 219
return Err(format!("agent id '{}' is duplicated", profile.id)); - 220
} - 221
if profile.name.len() > 120 - 222
|| profile.personality.len() > 4000 - 223
|| profile.behaviour.len() > 4000 - 224
|| profile.responsibilities.len() > 2000 - 225
|| profile.instructions.len() > 8000 - 226
{ - 227
return Err(format!("agent '{}' is too large", profile.id)); - 228
} - 229
if !matches!(profile.animation.as_str(), "subtle" | "expressive" | "off") { - 230
return Err("animation must be subtle, expressive, or off".into()); - 231
} - 232
if profile.voice.len() > 80 { - 233
return Err(format!("agent '{}' voice setting is too large", profile.id)); - 234
} - 235
if !matches!( - 236
profile.character.as_str(), - 237
"vak" | "mira" | "moss" | "nori" | "pip" | "lumi" | "tavi" | "beni" - 238
) { - 239
return Err("unknown character preset".into()); - 240
} - 241
if !matches!( - 242
profile.voice.as_str(), - 243
"default" | "calm" | "bright" | "quiet" - 244
) { - 245
return Err("voice must be default, calm, bright, or quiet".into()); - 246
} - 247
} - 248
let dir = cwd.join(".vak"); - 249
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - 250
let lock = std::fs::OpenOptions::new() - 251
.create(true) - 252
.truncate(false) - 253
.read(true) - 254
.write(true) - 255
.open(dir.join("agents.lock")) - 256
.map_err(|e| e.to_string())?; - 257
lock.try_lock() - 258
.map_err(|e| format!("agents are being edited; retry: {e}"))?; - 259
let target = path(cwd); - 260
let temp = target.with_extension(format!("{}.tmp", uuid::Uuid::now_v7())); - 261
let previous = load(cwd)?; - 262
let mut next = profiles.to_vec(); - 263
for profile in &mut next { - 264
if let Some(old) = previous.iter().find(|candidate| candidate.id == profile.id) { - 265
if profile.name != old.name - 266
|| profile.character != old.character - 267
|| profile.personality != old.personality - 268
|| profile.behaviour != old.behaviour - 269
|| profile.responsibilities != old.responsibilities - 270
|| profile.animation != old.animation - 271
|| profile.voice != old.voice - 272
{ - 273
profile.revision = old.revision.saturating_add(1); - 274
} else { - 275
profile.revision = old.revision; - 276
} - 277
} else { - 278
profile.revision = 1; - 279
} - 280
} - 281
// A full-layer edit must preserve future fields on retained profiles. - 282
let old_values: Vec<serde_json::Value> = match std::fs::read_to_string(&target) { - 283
Ok(raw) => serde_json::from_str(&raw).map_err(|e| format!("invalid agents: {e}"))?, - 284
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), - 285
Err(e) => return Err(e.to_string()), - 286
}; - 287
let values = next - 288
.iter() - 289
.map(|profile| { - 290
let mut value = old_values - 291
.iter() - 292
.find(|v| v["id"].as_str() == Some(&profile.id)) - 293
.cloned() - 294
.unwrap_or_else(|| serde_json::json!({})); - 295
let fields = serde_json::to_value(profile).map_err(|e| e.to_string())?; - 296
if let (Some(old), Some(new)) = (value.as_object_mut(), fields.as_object()) { - 297
old.extend(new.clone()); - 298
} - 299
Ok(value) - 300
}) - 301
.collect::<Result<Vec<_>, String>>()?; - 302
let bytes = serde_json::to_vec_pretty(&values).map_err(|e| e.to_string())?; - 303
std::fs::write(&temp, bytes).map_err(|e| e.to_string())?; - 304
std::fs::rename(&temp, &target).map_err(|e| e.to_string())?; - 305
for profile in &next { - 306
let agent_dir = vak_config::paths::agent_home(&profile.id); - 307
let _ = std::fs::create_dir_all(&agent_dir); - 308
let workspace_dir = vak_config::paths::agent_workspace(cwd, &profile.id); - 309
let _ = std::fs::create_dir_all(&workspace_dir); - 310
if trusted { - 311
let _ = vak_core::trust::mark_trusted(&workspace_dir); - 312
} - 313
} - 314
Ok(next) - 315
} - 316
- 317
#[cfg(test)] - 318
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 319
mod tests { - 320
use super::*; - 321
- 322
#[test] - 323
fn profiles_round_trip_atomically() { - 324
let dir = tempfile::tempdir().expect("profile workspace"); - 325
let profiles = vec![AgentDefinition { - 326
id: "pip".into(), - 327
revision: 1, - 328
lifecycle: AgentLifecycle::Active, - 329
name: "Pip".into(), - 330
character: "vak".into(), - 331
personality: "Warm".into(), - 332
behaviour: "Be useful".into(), - 333
responsibilities: String::new(), - 334
instructions: String::new(), - 335
animation: "subtle".into(), - 336
voice: "default".into(), - 337
}]; - 338
save(dir.path(), &profiles, true).expect("save profiles"); - 339
assert_eq!(load(dir.path()).expect("load profiles")[0].name, "Pip"); - 340
} - 341
- 342
#[test] - 343
fn retired_profile_store_is_not_read_as_agent_state() { - 344
let dir = tempfile::tempdir().expect("agent workspace"); - 345
std::fs::create_dir_all(dir.path().join(".vak")).expect("agent config dir"); - 346
std::fs::write( - 347
dir.path().join(".vak/agent-profiles.json"), - 348
"[{\"id\":\"old\",\"name\":\"Old\"}]", - 349
) - 350
.expect("retired store"); - 351
assert!(load(dir.path()).expect("load agents").is_empty()); - 352
} - 353
- 354
#[test] - 355
fn invalid_character_is_rejected() { - 356
let dir = tempfile::tempdir().expect("profile workspace"); - 357
let profile = AgentDefinition { - 358
id: "x".into(), - 359
revision: 1, - 360
lifecycle: AgentLifecycle::Active, - 361
name: "X".into(), - 362
character: "unknown".into(), - 363
personality: String::new(), - 364
behaviour: String::new(), - 365
responsibilities: String::new(), - 366
instructions: String::new(), - 367
animation: "off".into(), - 368
voice: "default".into(), - 369
}; - 370
assert!(save(dir.path(), &[profile], true).is_err()); - 371
} - 372
- 373
#[test] - 374
fn invalid_voice_is_rejected() { - 375
let dir = tempfile::tempdir().expect("profile workspace"); - 376
let profile = AgentDefinition { - 377
id: "x".into(), - 378
revision: 1, - 379
lifecycle: AgentLifecycle::Active, - 380
name: "X".into(), - 381
character: "vak".into(), - 382
personality: String::new(), - 383
behaviour: String::new(), - 384
responsibilities: String::new(), - 385
instructions: String::new(), - 386
animation: "off".into(), - 387
voice: "unknown".into(), - 388
}; - 389
assert!(save(dir.path(), &[profile], true).is_err()); - 390
} - 391
- 392
#[test] - 393
fn lifecycle_round_trips_and_admission_is_active_only() { - 394
let dir = tempfile::tempdir().expect("agent workspace"); - 395
let mut agent = AgentDefinition { - 396
id: "paused".into(), - 397
revision: 1, - 398
lifecycle: AgentLifecycle::Paused, - 399
name: "Paused".into(), - 400
character: "vak".into(), - 401
personality: String::new(), - 402
behaviour: String::new(), - 403
responsibilities: String::new(), - 404
instructions: String::new(), - 405
animation: "off".into(), - 406
voice: "default".into(), - 407
}; - 408
save(dir.path(), &[agent.clone()], true).expect("save paused agent"); - 409
agent = load(dir.path()).expect("load paused agent").remove(0); - 410
assert_eq!(agent.lifecycle, AgentLifecycle::Paused); - 411
assert!(!agent.is_admissible()); - 412
} - 413
- 414
#[test] - 415
fn edits_increment_saved_revision() { - 416
let dir = tempfile::tempdir().expect("profile workspace"); - 417
let profile = AgentDefinition { - 418
id: "pip".into(), - 419
revision: 1, - 420
lifecycle: AgentLifecycle::Active, - 421
name: "Pip".into(), - 422
character: "vak".into(), - 423
personality: "Warm".into(), - 424
behaviour: "Be useful".into(), - 425
responsibilities: String::new(), - 426
instructions: String::new(), - 427
animation: "subtle".into(), - 428
voice: "default".into(), - 429
}; - 430
let mut untouched = profile.clone(); - 431
untouched.id = "atlas".into(); - 432
untouched.name = "Atlas".into(); - 433
let saved = - 434
save(dir.path(), &[profile.clone(), untouched.clone()], true).expect("first save"); - 435
assert_eq!(saved[0].revision, 1); - 436
let mut edited = profile; - 437
edited.personality = "Warm and direct".into(); - 438
let saved = save(dir.path(), &[edited, untouched], true).expect("edited save"); - 439
assert_eq!(saved[0].revision, 2); - 440
assert_eq!(saved[1].revision, 1); - 441
} - 442
- 443
#[test] - 444
fn duplicate_ids_are_rejected() { - 445
let dir = tempfile::tempdir().expect("profile workspace"); - 446
let profile = AgentDefinition { - 447
id: "same".into(), - 448
revision: 1, - 449
lifecycle: AgentLifecycle::Active, - 450
name: "One".into(), - 451
character: "vak".into(), - 452
personality: String::new(), - 453
behaviour: String::new(), - 454
responsibilities: String::new(), - 455
instructions: String::new(), - 456
animation: "off".into(), - 457
voice: "default".into(), - 458
}; - 459
let mut duplicate = profile.clone(); - 460
duplicate.name = "Two".into(); - 461
assert!(save(dir.path(), &[profile, duplicate], true).is_err()); - 462
} - 463
- 464
#[test] - 465
fn builtin_templates_are_all_valid() { - 466
let dir = tempfile::tempdir().expect("template workspace"); - 467
let templates = builtin_templates(); - 468
assert_eq!(templates.len(), 4); - 469
let agents: Vec<_> = templates - 470
.iter() - 471
.map(|t| t.to_agent_definition(&t.template_id, None)) - 472
.collect(); - 473
let saved = save(dir.path(), &agents, true).expect("save all builtin templates"); - 474
assert_eq!(saved.len(), 4); - 475
let loaded = load(dir.path()).expect("load all builtin templates"); - 476
assert_eq!(loaded.len(), 4); - 477
assert!(loaded.iter().any(|a| a.id == "researcher")); - 478
assert!(loaded.iter().any(|a| a.id == "writer")); - 479
assert!(loaded.iter().any(|a| a.id == "operator")); - 480
assert!(loaded.iter().any(|a| a.id == "analyst")); - 481
} - 482
} - 483
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.