1use std::fs;
7use std::path::{Path, PathBuf};
8
9use qualia_core_db::q_hash;
10use serde::{Deserialize, Serialize};
11
12use crate::chat_session::{self, ChatMessage, Role, SessionKind};
13
14#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum AgentBackendKind {
17 #[default]
19 Local,
20 Remote,
22 Hybrid,
24 Ollama,
26}
27
28impl AgentBackendKind {
29 pub fn as_str(self) -> &'static str {
30 match self {
31 AgentBackendKind::Local => "local",
32 AgentBackendKind::Remote => "remote",
33 AgentBackendKind::Hybrid => "hybrid",
34 AgentBackendKind::Ollama => "ollama",
35 }
36 }
37
38 pub fn from_str(s: &str) -> Self {
39 match s.to_lowercase().as_str() {
40 "remote" => AgentBackendKind::Remote,
41 "hybrid" => AgentBackendKind::Hybrid,
42 "ollama" => AgentBackendKind::Ollama,
43 _ => AgentBackendKind::Local,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(rename_all = "snake_case")]
50pub enum OutcomeVisibility {
51 OwnerOnly,
52 SessionParticipants,
53 SpecificDids,
54}
55
56impl OutcomeVisibility {
57 pub fn as_str(&self) -> &'static str {
58 match self {
59 OutcomeVisibility::OwnerOnly => "owner_only",
60 OutcomeVisibility::SessionParticipants => "session_participants",
61 OutcomeVisibility::SpecificDids => "specific_dids",
62 }
63 }
64
65 pub fn from_str(s: &str) -> Result<Self, String> {
66 match s {
67 "owner_only" => Ok(OutcomeVisibility::OwnerOnly),
68 "session_participants" => Ok(OutcomeVisibility::SessionParticipants),
69 "specific_dids" => Ok(OutcomeVisibility::SpecificDids),
70 _ => Err(format!("unknown outcome visibility: {s}")),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct OutcomeSharingPolicy {
78 pub visibility: OutcomeVisibility,
79 pub share_provenance: bool,
81 pub share_model_attribution: bool,
83 pub allow_peer_llm_context: bool,
85 #[serde(default)]
86 pub allowed_dids: Vec<String>,
87}
88
89impl Default for OutcomeSharingPolicy {
90 fn default() -> Self {
91 Self {
92 visibility: OutcomeVisibility::OwnerOnly,
93 share_provenance: true,
94 share_model_attribution: false,
95 allow_peer_llm_context: false,
96 allowed_dids: vec![],
97 }
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ParticipantAgentConfig {
104 pub principal_did: String,
106 pub sub_agent_did: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub roster_agent_slug: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub roster_agent_display_name: Option<String>,
117 pub model_id: Option<String>,
118 pub backend: AgentBackendKind,
119 pub outcome_sharing: OutcomeSharingPolicy,
120 pub updated_at: u64,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct PeerAgentSummary {
126 pub principal_did: String,
127 pub principal_name: Option<String>,
128 pub sub_agent_did: String,
129 pub model_id: Option<String>,
130 pub backend: AgentBackendKind,
131 pub shares_outcomes: bool,
132}
133
134fn agent_config_path(storage_root: &Path, session_id: &str) -> PathBuf {
135 chat_session::chats_dir(storage_root)
136 .join(session_id)
137 .join("agent_config.json")
138}
139
140fn unix_now() -> u64 {
141 std::time::SystemTime::now()
142 .duration_since(std::time::UNIX_EPOCH)
143 .unwrap_or_default()
144 .as_secs()
145}
146
147pub fn compile_sub_agent_did(principal_did: &str, session_id: &str) -> String {
149 let p = q_hash(principal_did);
150 let s = q_hash(&format!("qualia:chat:subagent:{session_id}"));
151 format!("did:qualia:subagent:{p:016x}:{s:016x}")
152}
153
154pub fn default_outcome_sharing(kind: SessionKind) -> OutcomeSharingPolicy {
155 let profile = crate::user_profile::load_profile();
156 default_outcome_sharing_for_profile(kind, &profile)
157}
158
159pub fn default_outcome_sharing_for_profile(
160 kind: SessionKind,
161 profile: &crate::user_profile::UserProfile,
162) -> OutcomeSharingPolicy {
163 match kind {
164 SessionKind::Solo => OutcomeSharingPolicy::default(),
165 SessionKind::Group => {
166 if profile.sharing.share_llm_outcomes {
167 OutcomeSharingPolicy {
168 visibility: OutcomeVisibility::SessionParticipants,
169 share_provenance: true,
170 share_model_attribution: profile.sharing.share_active_model,
171 allow_peer_llm_context: true,
172 allowed_dids: vec![],
173 }
174 } else {
175 OutcomeSharingPolicy::default()
176 }
177 }
178 }
179}
180
181pub fn load_local_agent_config(
182 storage_root: &Path,
183 session_id: &str,
184) -> Result<ParticipantAgentConfig, String> {
185 let path = agent_config_path(storage_root, session_id);
186 if path.is_file() {
187 let text = fs::read_to_string(&path).map_err(|e| e.to_string())?;
188 return serde_json::from_str(&text).map_err(|e| e.to_string());
189 }
190
191 let profile = crate::user_profile::load_profile();
192 let session =
193 chat_session::load_session(storage_root, session_id).map_err(|e| e.to_string())?;
194 let active = crate::context_binding::load_active_model_record();
195 Ok(fresh_local_agent_config(
196 &profile.public_did,
197 session_id,
198 session.meta.session_kind,
199 active.as_ref().map(|r| r.model_id.as_str()),
200 &profile,
201 ))
202}
203
204pub fn fresh_local_agent_config(
205 principal_did: &str,
206 session_id: &str,
207 kind: SessionKind,
208 model_id: Option<&str>,
209 profile: &crate::user_profile::UserProfile,
210) -> ParticipantAgentConfig {
211 ParticipantAgentConfig {
212 principal_did: principal_did.to_string(),
213 sub_agent_did: compile_sub_agent_did(principal_did, session_id),
214 roster_agent_slug: None,
215 roster_agent_display_name: None,
216 model_id: model_id.map(|s| s.to_string()),
217 backend: crate::inference_backend::load_inference_backend_settings().backend,
218 outcome_sharing: default_outcome_sharing_for_profile(kind, profile),
219 updated_at: unix_now(),
220 }
221}
222
223pub fn bind_local_roster_agent(
227 storage_root: &Path,
228 session_id: &str,
229 agent: &crate::agent_registry::AgentDefinition,
230) -> Result<(), String> {
231 let model_id = match &agent.backend {
232 crate::agent_registry::AgentBackendSpec::LocalEngine { model_id } => model_id.clone(),
233 crate::agent_registry::AgentBackendSpec::RemoteMcp { .. } => {
234 return Err("remote roster agents are not local session bindings".to_string());
235 }
236 };
237 let mut config = load_local_agent_config(storage_root, session_id)?;
238 config.roster_agent_slug = Some(agent.slug.clone());
239 config.roster_agent_display_name = Some(agent.display_name.clone());
240 config.model_id = model_id;
243 config.backend = AgentBackendKind::Local;
244 save_local_agent_config(storage_root, session_id, &config)
245}
246
247pub fn ensure_local_agent_config(storage_root: &Path, session_id: &str) -> Result<(), String> {
248 let path = agent_config_path(storage_root, session_id);
249 if path.is_file() {
250 return Ok(());
251 }
252 let cfg = load_local_agent_config(storage_root, session_id)?;
253 save_local_agent_config(storage_root, session_id, &cfg)
254}
255
256pub fn save_local_agent_config(
257 storage_root: &Path,
258 session_id: &str,
259 config: &ParticipantAgentConfig,
260) -> Result<(), String> {
261 let path = agent_config_path(storage_root, session_id);
262 if let Some(parent) = path.parent() {
263 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
264 }
265 let mut cfg = config.clone();
266 cfg.updated_at = unix_now();
267 let text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
268 fs::write(path, text).map_err(|e| e.to_string())
269}
270
271pub fn update_outcome_sharing(
272 storage_root: &Path,
273 session_id: &str,
274 policy: OutcomeSharingPolicy,
275) -> Result<ParticipantAgentConfig, String> {
276 let mut cfg = load_local_agent_config(storage_root, session_id)?;
277 cfg.outcome_sharing = policy;
278 save_local_agent_config(storage_root, session_id, &cfg)?;
279 Ok(cfg)
280}
281
282pub fn decorate_local_agent_message(
284 storage_root: &Path,
285 session_id: &str,
286 msg: &mut ChatMessage,
287) -> Result<(), String> {
288 if msg.role != Role::Agent {
289 return Ok(());
290 }
291 if msg.sub_agent_of.is_some() {
292 return Ok(());
293 }
294
295 let cfg = load_local_agent_config(storage_root, session_id)?;
296 msg.sub_agent_of = Some(cfg.principal_did.clone());
297 msg.agent_did = Some(cfg.sub_agent_did.clone());
298 msg.model_id = cfg.model_id.clone();
299 msg.agent_backend = Some(cfg.backend.as_str().to_string());
300 msg.outcome_sharing = Some(cfg.outcome_sharing.clone());
301 msg.author_did = Some(cfg.sub_agent_did.clone());
302 msg.author_name = Some(local_agent_display_name(&cfg));
303 Ok(())
304}
305
306pub fn local_agent_display_name(cfg: &ParticipantAgentConfig) -> String {
307 if let Some(name) = cfg.roster_agent_display_name.as_deref() {
308 if let Some(model) = cfg.model_id.as_deref() {
309 return format!("{name} ({model})");
310 }
311 return name.to_string();
312 }
313 let profile = crate::user_profile::load_profile();
314 let base = if profile.public_did == cfg.principal_did {
315 profile.display_name.clone()
316 } else {
317 cfg.principal_did.clone()
318 };
319 if let Some(ref model) = cfg.model_id {
320 format!("{base}'s agent ({model})")
321 } else {
322 format!("{base}'s Webizen agent")
323 }
324}
325
326pub fn participant_dids(participants: &[chat_session::ChatParticipant]) -> Vec<String> {
327 participants.iter().map(|p| p.did.clone()).collect()
328}
329
330pub fn is_participant(did: &str, participants: &[chat_session::ChatParticipant]) -> bool {
331 participants.iter().any(|p| p.did == did)
332}
333
334pub fn can_relay_agent_outcome(
336 msg: &ChatMessage,
337 participants: &[chat_session::ChatParticipant],
338) -> bool {
339 if msg.role != Role::Agent {
340 return true;
341 }
342 let Some(ref principal) = msg.sub_agent_of else {
343 return false;
344 };
345 if !is_participant(principal, participants) {
346 return false;
347 }
348 let policy = msg.outcome_sharing.as_ref().cloned().unwrap_or_default();
349 match policy.visibility {
350 OutcomeVisibility::OwnerOnly => false,
351 OutcomeVisibility::SessionParticipants => true,
352 OutcomeVisibility::SpecificDids => !policy.allowed_dids.is_empty(),
353 }
354}
355
356pub fn can_view_agent_outcome(
358 msg: &ChatMessage,
359 viewer_did: &str,
360 participants: &[chat_session::ChatParticipant],
361) -> bool {
362 if msg.role != Role::Agent {
363 return true;
364 }
365 if msg.sub_agent_of.as_deref() == Some(viewer_did) {
366 return true;
367 }
368 let policy = msg.outcome_sharing.as_ref().cloned().unwrap_or_default();
369 match policy.visibility {
370 OutcomeVisibility::OwnerOnly => false,
371 OutcomeVisibility::SessionParticipants => is_participant(viewer_did, participants),
372 OutcomeVisibility::SpecificDids => policy.allowed_dids.iter().any(|d| d == viewer_did),
373 }
374}
375
376pub fn can_use_in_peer_llm_context(
378 msg: &ChatMessage,
379 viewer_did: &str,
380 participants: &[chat_session::ChatParticipant],
381) -> bool {
382 if msg.role != Role::Agent {
383 return true;
384 }
385 if !can_view_agent_outcome(msg, viewer_did, participants) {
386 return false;
387 }
388 msg.outcome_sharing
389 .as_ref()
390 .map(|p| p.allow_peer_llm_context)
391 .unwrap_or(false)
392}
393
394pub fn collect_peer_agent_summaries(
396 messages: &[ChatMessage],
397 participants: &[chat_session::ChatParticipant],
398 local_principal_did: &str,
399) -> Vec<PeerAgentSummary> {
400 let mut out = Vec::new();
401 let mut seen = std::collections::HashSet::new();
402
403 for msg in messages {
404 if msg.role != Role::Agent {
405 continue;
406 }
407 let Some(ref principal) = msg.sub_agent_of else {
408 continue;
409 };
410 if principal == local_principal_did {
411 continue;
412 }
413 if !can_view_agent_outcome(msg, local_principal_did, participants) {
414 continue;
415 }
416 if !seen.insert(principal.clone()) {
417 continue;
418 }
419 let name = participants
420 .iter()
421 .find(|p| p.did == *principal)
422 .map(|p| p.display_name.clone());
423 let shares = msg
424 .outcome_sharing
425 .as_ref()
426 .map(|p| p.visibility != OutcomeVisibility::OwnerOnly)
427 .unwrap_or(false);
428 out.push(PeerAgentSummary {
429 principal_did: principal.clone(),
430 principal_name: name,
431 sub_agent_did: msg.agent_did.clone().unwrap_or_default(),
432 model_id: msg.model_id.clone(),
433 backend: msg
434 .agent_backend
435 .as_deref()
436 .map(AgentBackendKind::from_str)
437 .unwrap_or_default(),
438 shares_outcomes: shares,
439 });
440 }
441 out
442}
443
444pub fn build_cooperative_agents_block(
445 storage_root: &Path,
446 session_id: &str,
447 messages: &[ChatMessage],
448 participants: &[chat_session::ChatParticipant],
449) -> String {
450 let profile = crate::user_profile::load_profile();
451 let local = match load_local_agent_config(storage_root, session_id) {
452 Ok(c) => c,
453 Err(_) => return String::new(),
454 };
455
456 let mut lines = vec![
457 "[Cooperative group agents]".to_string(),
458 format!(
459 "local_sub_agent: {} (principal={}, backend={}, model={})",
460 local.sub_agent_did,
461 local.principal_did,
462 local.backend.as_str(),
463 local.model_id.as_deref().unwrap_or("none")
464 ),
465 format!(
466 "local_outcome_sharing: {}",
467 local.outcome_sharing.visibility.as_str()
468 ),
469 "note: Sub-agents are not independent participants — they act on behalf of their human principal.".to_string(),
470 ];
471
472 let peers = collect_peer_agent_summaries(messages, participants, &profile.public_did);
473 if peers.is_empty() {
474 lines.push("peer_agents: none disclosed".to_string());
475 } else {
476 lines.push("peer_agents:".to_string());
477 for p in peers {
478 let label = p.principal_name.as_deref().unwrap_or(&p.principal_did);
479 let model = p.model_id.as_deref().unwrap_or("hidden");
480 lines.push(format!(
481 " - {label} → sub_agent={} backend={} model={} shares_outcomes={}",
482 p.sub_agent_did,
483 p.backend.as_str(),
484 model,
485 p.shares_outcomes
486 ));
487 }
488 }
489
490 let shareable: Vec<_> = messages
491 .iter()
492 .filter(|m| {
493 m.role == Role::Agent
494 && can_use_in_peer_llm_context(m, &profile.public_did, participants)
495 })
496 .collect();
497 if !shareable.is_empty() {
498 lines.push("shared_peer_outcomes (for your agent context only):".to_string());
499 for m in shareable.iter().take(8) {
500 let principal = m.sub_agent_of.as_deref().unwrap_or("unknown");
501 let preview: String = m.content.chars().take(240).collect();
502 lines.push(format!(" - [{principal}]: {preview}"));
503 }
504 }
505
506 lines.join("\n")
507}
508
509pub fn validate_ingested_agent_message(
510 msg: &ChatMessage,
511 participants: &[chat_session::ChatParticipant],
512) -> Result<(), String> {
513 if msg.role != Role::Agent {
514 return Ok(());
515 }
516 let principal = msg
517 .sub_agent_of
518 .as_deref()
519 .ok_or_else(|| "Agent message missing sub_agent_of principal".to_string())?;
520 if !is_participant(principal, participants) {
521 return Err(format!(
522 "Agent principal {principal} is not a session participant"
523 ));
524 }
525 if let Some(ref agent_did) = msg.agent_did {
526 if !agent_did.starts_with("did:qualia:subagent:") {
527 return Err(format!("Invalid sub-agent DID: {agent_did}"));
528 }
529 }
530 Ok(())
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::chat_session::ChatParticipant;
537
538 fn tmp_storage() -> std::path::PathBuf {
539 let path = std::env::temp_dir().join(format!("qualia-chat-agents-test-{}", unix_now()));
540 let _ = fs::create_dir_all(&path);
541 path
542 }
543
544 #[test]
545 fn sub_agent_did_is_deterministic() {
546 let a = compile_sub_agent_did("did:qualia:root:abc", "sess-1");
547 let b = compile_sub_agent_did("did:qualia:root:abc", "sess-1");
548 assert_eq!(a, b);
549 assert!(a.starts_with("did:qualia:subagent:"));
550 }
551
552 #[test]
553 fn outcome_sharing_defaults_private_in_group() {
554 let mut profile = crate::user_profile::UserProfile::default();
555 profile.sharing.share_llm_outcomes = false;
556 let policy = default_outcome_sharing_for_profile(SessionKind::Group, &profile);
557 assert_eq!(policy.visibility, OutcomeVisibility::OwnerOnly);
558 }
559
560 #[test]
561 fn outcome_sharing_opens_when_profile_allows() {
562 let mut profile = crate::user_profile::UserProfile::default();
563 profile.sharing.share_llm_outcomes = true;
564 let policy = default_outcome_sharing_for_profile(SessionKind::Group, &profile);
565 assert_eq!(policy.visibility, OutcomeVisibility::SessionParticipants);
566 assert!(policy.allow_peer_llm_context);
567 }
568
569 #[test]
570 fn relay_gate_blocks_owner_only_agent_outcomes() {
571 let participants = vec![ChatParticipant {
572 did: "did:p1".to_string(),
573 display_name: "Alice".to_string(),
574 actor_id: "a1".to_string(),
575 role: "owner".to_string(),
576 joined_at: 0,
577 }];
578 let msg = ChatMessage {
579 lamport: 1,
580 role: Role::Agent,
581 content: "answer".to_string(),
582 timestamp: 0,
583 content_hash: 0,
584 author_did: None,
585 author_name: None,
586 reply_to_fragment: None,
587 source: None,
588 sub_agent_of: Some("did:p1".to_string()),
589 agent_did: Some(compile_sub_agent_did("did:p1", "s1")),
590 model_id: None,
591 agent_backend: None,
592 outcome_sharing: Some(OutcomeSharingPolicy::default()),
593 };
594 assert!(!can_relay_agent_outcome(&msg, &participants));
595 }
596
597 #[test]
598 fn peer_context_requires_allow_flag() {
599 let participants = vec![
600 ChatParticipant {
601 did: "did:p1".to_string(),
602 display_name: "Alice".to_string(),
603 actor_id: "a1".to_string(),
604 role: "owner".to_string(),
605 joined_at: 0,
606 },
607 ChatParticipant {
608 did: "did:p2".to_string(),
609 display_name: "Bob".to_string(),
610 actor_id: "b1".to_string(),
611 role: "member".to_string(),
612 joined_at: 0,
613 },
614 ];
615 let msg = ChatMessage {
616 lamport: 1,
617 role: Role::Agent,
618 content: "shared insight".to_string(),
619 timestamp: 0,
620 content_hash: 0,
621 author_did: None,
622 author_name: None,
623 reply_to_fragment: None,
624 source: None,
625 sub_agent_of: Some("did:p1".to_string()),
626 agent_did: None,
627 model_id: None,
628 agent_backend: None,
629 outcome_sharing: Some(OutcomeSharingPolicy {
630 visibility: OutcomeVisibility::SessionParticipants,
631 share_provenance: true,
632 share_model_attribution: false,
633 allow_peer_llm_context: false,
634 allowed_dids: vec![],
635 }),
636 };
637 assert!(can_view_agent_outcome(&msg, "did:p2", &participants));
638 assert!(!can_use_in_peer_llm_context(&msg, "did:p2", &participants));
639 }
640
641 #[test]
642 fn local_agent_config_roundtrip() {
643 let storage = tmp_storage();
644 let session_id = chat_session::create_session(&storage, Some("t".into()), None).unwrap();
645 let cfg = load_local_agent_config(&storage, &session_id).unwrap();
646 assert!(cfg.sub_agent_did.starts_with("did:qualia:subagent:"));
647 save_local_agent_config(&storage, &session_id, &cfg).unwrap();
648 let again = load_local_agent_config(&storage, &session_id).unwrap();
649 assert_eq!(again.sub_agent_did, cfg.sub_agent_did);
650 }
651}