Skip to main content

qualia_client_core/
chat_agents.rs

1//! Group-chat sub-agent hierarchy — local LLM / Webizen agents are bound to human principals.
2//!
3//! Each participant may run a different model/backend. Processed outcomes (not raw prompts)
4//! can be shared with other participants when an explicit `OutcomeSharingPolicy` permits it.
5
6use 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    /// Native in-process GGUF / wgpu Qualia engine (primary).
18    #[default]
19    Local,
20    /// Remote provider path (Nym / MCP); consent + metering.
21    Remote,
22    /// Local first; optional remote or Ollama fallback per settings.
23    Hybrid,
24    /// Optional Ollama HTTP harness (opt-in; not the primary Qualia engine).
25    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/// Permission for sharing Webizen-processed outcomes (summaries, grounded answers) with peers.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct OutcomeSharingPolicy {
78    pub visibility: OutcomeVisibility,
79    /// Include provenance / citation hashes when relaying to peers.
80    pub share_provenance: bool,
81    /// Attribute which model produced the outcome (respects `share_active_model` on profile card).
82    pub share_model_attribution: bool,
83    /// Other participants' sub-agents may use this outcome in their inference context.
84    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/// Local user's sub-agent binding for a chat session.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ParticipantAgentConfig {
104    /// Human principal DID (owner of the sub-agent).
105    pub principal_did: String,
106    /// Derived sub-agent DID — not an independent chat actor.
107    pub sub_agent_did: String,
108    /// Stable roster binding for the agent that most recently answered in this
109    /// session.  Older session files omit it and retain the default local
110    /// assistant behaviour.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub roster_agent_slug: Option<String>,
113    /// User-defined roster name captured with the turn so chat attribution
114    /// does not fall back to the principal's generic Webizen-agent label.
115    #[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/// Summary of a peer's disclosed agent for cooperative multi-LLM briefing.
124#[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
147/// Deterministic sub-agent DID scoped to principal + session.
148pub 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
223/// Bind the local session projection to a named roster agent immediately
224/// before its turn.  The roster remains the source of truth; this small
225/// session record is used solely to attribute the persisted chat message.
226pub 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    // A blank roster model explicitly means "use the active local default";
241    // clear any earlier agent's pin rather than carrying it into attribution.
242    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
282/// Apply sub-agent metadata to an agent-role message from the local principal.
283pub 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
334/// Whether a processed agent outcome may be relayed to group peers.
335pub 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
356/// Whether `viewer_did` may read a peer's shared agent outcome in relay / UI.
357pub 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
376/// Whether another participant's sub-agent may cite this outcome in inference context.
377pub 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
394/// Collect peer agent disclosures from committed, shareable agent messages in the session.
395pub 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}