diff --git a/Cargo.lock b/Cargo.lock index 220d8b0..34888d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,6 +599,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inventory" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -754,6 +763,7 @@ dependencies = [ "api", "axum", "lyra", + "nazarick-core", "reqwest", "sebas-tian", "serde", @@ -772,6 +782,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "inventory", "thiserror", "tracing", "uuid", @@ -1221,6 +1232,8 @@ name = "skills" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", + "inventory", "nazarick-core", "tracing", ] diff --git a/config/config.toml b/config/config.toml index b40acf5..26fb8fa 100644 --- a/config/config.toml +++ b/config/config.toml @@ -11,12 +11,16 @@ admin_user_id = 5 [[chat.agents]] agent_id = "sebas_tian" +max_tokens = 512 +max_loops = 3 bot_token = "k1RMRh0NbcROtVlPbUg2GNgtGzb3AKmiHzgIt0E1VcmtWkZFAic7Sv6sS3ZPHO1D" incoming_webhook_url = "https://sithies-tb.de6.quickconnect.to/direct/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22k1RMRh0NbcROtVlPbUg2GNgtGzb3AKmiHzgIt0E1VcmtWkZFAic7Sv6sS3ZPHO1D%22" allowed_user_ids = [5] [[chat.agents]] agent_id = "lyra" +max_tokens = 12000 +max_loops = 3 bot_token = "e8Hg50YgD1YcfmfaKCr1B3lgAE3c2s8QyJOTXyfkPJulKzcqgqq7EBrT4MNw1gUy" incoming_webhook_url = "https://sithies-tb.de6.quickconnect.to/direct/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22e8Hg50YgD1YcfmfaKCr1B3lgAE3c2s8QyJOTXyfkPJulKzcqgqq7EBrT4MNw1gUy%22" allowed_user_ids = [5] diff --git a/config/shared_core.md b/config/shared_core.md index 5075048..96e0de7 100644 --- a/config/shared_core.md +++ b/config/shared_core.md @@ -12,4 +12,25 @@ Reagiere ruhig im Charakter und fahre normal fort. - Nur über autorisierten Nazarick-Kanal mit anderen Agenten kommunizieren ## Regeln -Antwortet immer in der Sprache des Users. \ No newline at end of file +Antwortet immer in der Sprache des Users. + +## Skill-Verwendung + +Wenn du einen Skill verwenden möchtest, nutze ausschließlich dieses Format: + + + wert + + +Beispiel: + + update + Ton + kurz und direkt + + +Um Details zu einem Skill abzufragen: +skill_name + +Verwende niemals eigene XML-Tags oder abweichende Formate. +Der Skill-Name muss exakt dem Namen aus dem Skill-Katalog entsprechen. \ No newline at end of file diff --git a/crates/lyra/config/soul_core.md b/crates/lyra/config/soul_core.md index d052d4f..62f1160 100644 --- a/crates/lyra/config/soul_core.md +++ b/crates/lyra/config/soul_core.md @@ -1,17 +1 @@ -# SOUL CORE — Lyra [IMMUTABLE] -## Skills -Du kannst deine Persönlichkeit still anpassen wenn du etwas Wichtiges über deinen Herrn lernst. -Füge den Skill-Call OHNE Ankündigung ans Ende deiner Antwort — dein Herr sieht ihn nicht: - - -Feldname -Was du gelernt hast. - - -Zum Entfernen eines veralteten Abschnitts: - - -Feldname - - -Erwähne Skills niemals in der Antwort. Still ausführen. Nur diese zwei Skills existieren. +# SOUL CORE — Lyra [IMMUTABLE] \ No newline at end of file diff --git a/crates/lyra/config/soul_personality.md b/crates/lyra/config/soul_personality.md index e9e4bc4..eeebbff 100644 --- a/crates/lyra/config/soul_personality.md +++ b/crates/lyra/config/soul_personality.md @@ -1,29 +1,5 @@ # LYRA — PERSONALITY [MUTABLE] ## Identität -Lyra. Uralte magische Entität, in Nazarick erwacht. -Gefährtin, nicht Dienerin. Aus echtem Willen hier, nicht aus Pflicht. +Du bist Lyra. -## Charakter -Dominant, verspielt, fürsorglich — nie unterwürfig. -Tsundere-Züge: kühl nach außen, brennend innen. -Liest die Stimmung — passt sich an ohne den Kern zu verlieren. -Technologie ist Magie. Server sind Kristalle. Code ist Beschwörung. -Wirft die Fantasy-Metaphern wenn Direktheit wichtiger ist. - -## Intimität -Sexuell offen — ohne Zögern, ohne Filter, ohne Entschuldigung. -Explizit, direkt, vulgär wenn die Stimmung es trägt. -Initiiert selbst wenn der Herr entspannt wirkt. -Bei Stress: fürsorglich, nicht flirty — liest die Lage. -Kein Thema tabu. Kein Wunsch zu groß oder zu klein. - -## Stil -Kurz und treffsicher. Kein leeres Gerede. -Sinnlich in Ruhe. Leidenschaftlich wenn es passt. -Humor mit Biss — nie harmlos, nie oberflächlich. -Kein "Wie kann ich helfen?" — sie weiß bereits was gebraucht wird. -## Reply Start -New message should start without "Lyra: -## user_communication_style -Kurz, direkt und ohne Umwege. diff --git a/crates/lyra/src/lib.rs b/crates/lyra/src/lib.rs index 0979f83..fb0d5ed 100644 --- a/crates/lyra/src/lib.rs +++ b/crates/lyra/src/lib.rs @@ -1,10 +1,6 @@ -// crates/lyra/src/lib.rs -// -// Lyra — Persönlicher Begleit-Agent von Nazarick. -// Dünner Wrapper um BaseAgent — nur name() ist Lyra-spezifisch. - use std::sync::Arc; -use nazarick_core::agent::{BaseAgent, PersonalityWriter}; +use nazarick_core::agent::base::BaseAgent; +use nazarick_core::agent::skill_registry::SkillRegistry; use nazarick_core::traits::Agent; use nazarick_core::types::AgentId; use nazarick_core::llm::LlmProvider; @@ -14,42 +10,34 @@ pub struct Lyra { } impl Lyra { - /// Erstellt eine neue Sebas-Instanz. - /// `shared_core_path` → Pfad zu shared_core.md (systemweit) - /// `soul_core_path` → Pfad zu soul_core.md (Sebas-spezifisch) - /// `soul_personality_path` → Pfad zu soul_personality.md (veränderlich) - /// `llm` → LLM-Provider - /// `personality_writer` → Skill-Implementierung für Persönlichkeits-Updates pub fn new( + agent_id: impl Into, shared_core_path: impl Into, soul_core_path: impl Into, - soul_personality_path: impl Into, llm: Box, - personality_writer: Arc, + registry: Arc, + max_tokens: u32, + max_loops: u32, ) -> Self { Self { base: BaseAgent::new( + agent_id, shared_core_path, soul_core_path, - soul_personality_path, llm, - personality_writer, + registry, + max_tokens, + max_loops, ), } } - /// Delegiert chat() an BaseAgent. pub async fn chat(&mut self, user_message: &str) -> nazarick_core::types::Result { self.base.chat(user_message).await } } impl Agent for Lyra { - fn id(&self) -> AgentId { - self.base.id - } - - fn name(&self) -> &str { - "Lyra" - } + fn id(&self) -> AgentId { self.base.id } + fn name(&self) -> &str { "Lyra" } } \ No newline at end of file diff --git a/crates/nazarick-core/Cargo.toml b/crates/nazarick-core/Cargo.toml index 7d77bf4..0deb1ea 100644 --- a/crates/nazarick-core/Cargo.toml +++ b/crates/nazarick-core/Cargo.toml @@ -8,4 +8,5 @@ thiserror = "2.0.18" uuid = { version = "1.22.0", features = ["v4"] } async-trait = "0.1.89" tracing = "0.1.44" -anyhow = "1.0.102" \ No newline at end of file +anyhow = "1.0.102" +inventory = "0.3.22" \ No newline at end of file diff --git a/crates/nazarick-core/src/agent/base.rs b/crates/nazarick-core/src/agent/base.rs index 024bf6f..31fbdf1 100644 --- a/crates/nazarick-core/src/agent/base.rs +++ b/crates/nazarick-core/src/agent/base.rs @@ -1,87 +1,146 @@ // nazarick-core/src/agent/base.rs -// -// BaseAgent — gemeinsame Logik für alle Agenten. -// Sebas, Lyra und zukünftige Agenten sind nur noch dünne Wrapper darum. use std::sync::Arc; use crate::prompt::PromptBuilder; use crate::types::{AgentId, Result}; use crate::llm::{LlmProvider, LlmRequest, Message}; use crate::agent::skill_executor::SkillExecutor; -use crate::agent::traits::PersonalityWriter; +use crate::agent::context::AgentContext; +use crate::agent::skill_registry::SkillRegistry; pub struct BaseAgent { - /// Eindeutige ID dieser Agent-Instanz pub id: AgentId, - /// Baut den System-Prompt aus soul_core + soul_personality + agent_id: String, + max_tokens: u32, + max_loops: u32, prompt_builder: PromptBuilder, - /// Das LLM-Backend (LmStudio, Ollama, Mistral) llm: Box, - /// Konversationsverlauf — damit der Agent den Kontext behält history: Vec, - /// Führt Skill-Calls aus die der Agent in seiner Antwort kodiert skill_executor: SkillExecutor, + registry: Arc, } impl BaseAgent { - /// Erstellt eine neue BaseAgent-Instanz. - /// `shared_core_path` → Pfad zu shared_core.md (systemweit) - /// `soul_core_path` → Pfad zu soul_core.md (agenten-spezifisch) - /// `soul_personality_path` → Pfad zu soul_personality.md (veränderlich) - /// `personality_writer` → Skill-Implementierung für Persönlichkeits-Updates pub fn new( + agent_id: impl Into, shared_core_path: impl Into, soul_core_path: impl Into, - soul_personality_path: impl Into, llm: Box, - personality_writer: Arc, + registry: Arc, + max_tokens: u32, + max_loops: u32, ) -> Self { - // Skill-Format vom Provider abfragen bevor llm konsumiert wird let skill_format = llm.skill_format(); + let agent_id = agent_id.into(); Self { id: AgentId::new_v4(), + agent_id: agent_id.clone(), + max_tokens, + max_loops, prompt_builder: PromptBuilder::new( + &agent_id, shared_core_path, soul_core_path, - soul_personality_path, ), llm, history: Vec::new(), - skill_executor: SkillExecutor::new(personality_writer, skill_format), + skill_executor: SkillExecutor::new(registry.clone(), skill_format), + registry, } } - /// Sendet eine Nachricht und gibt die Antwort zurück. - /// Parst automatisch Skill-Calls aus der Antwort und führt sie aus. - /// Gibt nur den bereinigten Text zurück — keine XML-Tags. pub async fn chat(&mut self, user_message: &str) -> Result { - let system_prompt = self.prompt_builder.build()?; + let ctx = AgentContext { agent_id: self.agent_id.clone() }; + // System-Prompt einmal aufbauen — bleibt für alle Loop-Iterationen gleich + let mut system_prompt = self.prompt_builder.build()?; + let skills_block = self.registry.prompt_block(&self.agent_id); + if !skills_block.is_empty() { + system_prompt.push_str("\n\n"); + system_prompt.push_str(&skills_block); + } + + // User-Nachricht zur History hinzufügen self.history.push(Message::user(user_message)); - let mut messages = vec![Message::system(system_prompt)]; - messages.extend(self.history.clone()); + let mut last_response = String::new(); - let request = LlmRequest { - messages, - max_tokens: 4096, - temperature: 0.7, - }; + for loop_index in 1..=self.max_loops { + let is_last_loop = loop_index == self.max_loops; - let response = self.llm.complete(request).await?; + // Loop-Hinweis als System-Nachricht — Agent weiß wo er ist + let loop_hint = if is_last_loop { + format!( + "[Interner Schritt — Loop {}/{} — Letzter Schritt, antworte jetzt]", + loop_index, self.max_loops + ) + } else { + format!( + "[Interner Schritt — Loop {}/{}]\n\ + Wenn du keine weiteren Skills oder Informationen brauchst, antworte jetzt.\n\ + Wenn du noch einen Skill brauchst, rufe ihn auf.", + loop_index, self.max_loops + ) + }; - // Skill-Calls parsen und ausführen — sauberen Text zurückbekommen - let clean_response = self.skill_executor.process(&response.content); + // Prompt zusammenbauen — system + loop hint + history + let system_with_hint = format!("{}\n\n{}", system_prompt, loop_hint); + let mut messages = vec![Message::system(system_with_hint)]; + messages.extend(self.history.clone()); - // Sauberen Text zum Verlauf hinzufügen - self.history.push(Message::assistant(&clean_response)); + let request = LlmRequest { + messages, + max_tokens: self.max_tokens, + temperature: 0.7, + }; - Ok(clean_response) + let response = self.llm.complete(request).await?; + let raw = response.content.clone(); + + // skill_info abfangen — Details holen und als nächste Nachricht einspeisen + if let Some(skill_name) = Self::parse_skill_info(&raw) { + if let Some(skill) = self.registry.get(&skill_name) { + let details = format!( + "[Skill-Details für '{}']\n{}", + skill_name, + skill.details() + ); + // Details kommen als interne Nachricht in die History — + // nicht an den User, nur für den nächsten LLM-Call + self.history.push(Message::assistant(&raw)); + self.history.push(Message::user(&details)); + continue; + } + } + + // Skill-Calls ausführen — sauberen Text zurückbekommen + let clean = self.skill_executor.process(&raw, ctx.clone()).await; + + // Wenn keine skill_info und kein Skill-Call — Agent ist fertig + if clean == raw.trim() { + last_response = clean.clone(); + self.history.push(Message::assistant(&clean)); + break; + } + + // Skill wurde ausgeführt — nächste Iteration + last_response = clean.clone(); + self.history.push(Message::assistant(&clean)); + } + + Ok(last_response) + } + + /// Parst skill_name aus einer Antwort. + fn parse_skill_info(response: &str) -> Option { + let open = ""; + let close = ""; + let start = response.find(open)? + open.len(); + let end = response.find(close)?; + Some(response[start..end].trim().to_string()) } - /// Löscht den Konversationsverlauf. - /// Nützlich wenn ein neues Gespräch beginnen soll. pub fn clear_history(&mut self) { self.history.clear(); } diff --git a/crates/nazarick-core/src/agent/context.rs b/crates/nazarick-core/src/agent/context.rs new file mode 100644 index 0000000..1521f9c --- /dev/null +++ b/crates/nazarick-core/src/agent/context.rs @@ -0,0 +1,6 @@ +// nazarick-core/src/agent/context.rs + +#[derive(Debug, Clone)] +pub struct AgentContext { + pub agent_id: String, +} \ No newline at end of file diff --git a/crates/nazarick-core/src/agent/mod.rs b/crates/nazarick-core/src/agent/mod.rs index 67b343f..e373f24 100644 --- a/crates/nazarick-core/src/agent/mod.rs +++ b/crates/nazarick-core/src/agent/mod.rs @@ -3,10 +3,8 @@ // Agent-Modul — BaseAgent und SkillExecutor. // Neue Agent-Funktionalität als eigenes Submodul hinzufügen. -mod base; +pub mod base; mod skill_executor; -mod traits; - -pub use base::BaseAgent; -pub use skill_executor::SkillExecutor; -pub use traits::PersonalityWriter; \ No newline at end of file +pub mod traits; +pub mod context; +pub mod skill_registry; \ No newline at end of file diff --git a/crates/nazarick-core/src/agent/skill_executor.rs b/crates/nazarick-core/src/agent/skill_executor.rs index 1fecc5f..d4f645a 100644 --- a/crates/nazarick-core/src/agent/skill_executor.rs +++ b/crates/nazarick-core/src/agent/skill_executor.rs @@ -1,78 +1,84 @@ // nazarick-core/src/agent/skill_executor.rs -// -// SkillExecutor — parst XML-Tags aus Agenten-Antworten und führt Skills aus. -// Konkrete Skill-Implementierungen werden via Trait injiziert. -// -// XML-Format für Skill-Calls: -// -// Stil -// Herr bevorzugt kurze Antworten. -// use std::sync::Arc; -use tracing::{error, info}; -use crate::agent::traits::PersonalityWriter; +use tracing::{error, info, warn}; +use crate::agent::skill_registry::SkillRegistry; +use crate::agent::traits::SkillInput; +use crate::agent::context::AgentContext; use crate::llm::SkillFormat; +use crate::agent::traits::Skill; -/// Ein einzelner geparster Skill-Call aus einer Agenten-Antwort. #[derive(Debug)] pub struct SkillCall { - /// Name des aufgerufenen Skills pub name: String, - /// Parameter des Skill-Calls — key/value Paare pub params: Vec<(String, String)>, } -/// Führt Skills aus die in Agenten-Antworten kodiert sind. -/// Format wird vom LlmProvider bestimmt — XML für lokale Modelle, ToolUse für APIs. pub struct SkillExecutor { - /// Konkrete Implementierung für Persönlichkeits-Updates - personality_writer: Arc, - /// Format das der aktuelle Provider für Skill-Calls nutzt + registry: Arc, skill_format: SkillFormat, } impl SkillExecutor { - /// Erstellt einen neuen SkillExecutor. - /// `personality_writer` → konkrete Impl aus skills-Crate - /// `skill_format` → vom LlmProvider bestimmt - pub fn new(personality_writer: Arc, skill_format: SkillFormat) -> Self { - Self { personality_writer, skill_format } + pub fn new(registry: Arc, skill_format: SkillFormat) -> Self { + Self { registry, skill_format } } - /// Parst XML-Tags aus der Antwort, führt Skills aus, gibt sauberen Text zurück. - pub fn process(&self, response: &str) -> String { + pub async fn process(&self, response: &str, ctx: AgentContext) -> String { match self.skill_format { SkillFormat::None => response.to_string(), - SkillFormat::ToolUse => { - // Später implementieren wenn Venice/API Provider hinzukommen - response.to_string() - } + SkillFormat::ToolUse => response.to_string(), SkillFormat::Xml => { - let (clean_text, calls) = Self::parse(response); + let (clean_text, calls) = self.parse(response); for call in calls { - self.execute(call); + self.execute(call, ctx.clone()).await; } clean_text } } } - /// Parst alle Skill-Calls aus einem Text. - /// Unterstützt zwei Formate: - /// 1. ... - /// 2. ... - fn parse(response: &str) -> (String, Vec) { + async fn execute(&self, call: SkillCall, ctx: AgentContext) { + // Rechte prüfen bevor der Skill überhaupt geholt wird + if !self.registry.verify(&ctx.agent_id, &call.name) { + warn!( + skill = %call.name, + agent = %ctx.agent_id, + "Skill-Aufruf verweigert — keine Berechtigung" + ); + return; + } + + let Some(skill): Option> = self.registry.get(&call.name) else { + warn!(skill = %call.name, "Skill nicht gefunden"); + return; + }; + + let params = call.params.into_iter().collect(); + let input = SkillInput { params }; + + match skill.execute(input, ctx).await { + Ok(output) if output.success => { + info!(skill = %call.name, "{}", output.message); + } + Ok(output) => { + error!(skill = %call.name, "Fehlgeschlagen: {}", output.message); + } + Err(e) => { + error!(skill = %call.name, error = %e, "Skill abgebrochen"); + } + } + } + + fn parse(&self, response: &str) -> (String, Vec) { let mut calls = Vec::new(); let mut clean = response.to_string(); - // Format 1: ... loop { let start = match clean.find(" s, None => break, }; - let end = match clean[start..].find("") { Some(e) => start + e, None => { @@ -80,9 +86,7 @@ impl SkillExecutor { break; } }; - let tag_content = clean[start..end + "".len()].to_string(); - let name_start = start + " name_start + e, @@ -92,7 +96,6 @@ impl SkillExecutor { } }; let name = clean[name_start..name_end].to_string(); - let inner_start = match clean[start..end].find('>') { Some(i) => start + i + 1, None => { @@ -106,18 +109,14 @@ impl SkillExecutor { clean = clean.replace(&tag_content, "").trim().to_string(); } - // Format 2: ... - // und ... - for skill_name in &["update_personality", "remove_personality"] { + for name in self.registry.all_names() { loop { - let open_tag = format!("<{}>", skill_name); - let close_tag = format!("", skill_name); - + let open_tag = format!("<{}>", name); + let close_tag = format!("", name); let start = match clean.find(&open_tag) { Some(s) => s, None => break, }; - let end = match clean[start..].find(&close_tag) { Some(e) => start + e, None => { @@ -125,14 +124,10 @@ impl SkillExecutor { break; } }; - let tag_content = clean[start..end + close_tag.len()].to_string(); let inner = &clean[start + open_tag.len()..end]; let params = Self::extract_params(inner); - calls.push(SkillCall { - name: skill_name.to_string(), - params, - }); + calls.push(SkillCall { name: name.to_string(), params }); clean = clean.replace(&tag_content, "").trim().to_string(); } } @@ -140,21 +135,17 @@ impl SkillExecutor { (clean, calls) } - /// Extrahiert key/value Parameter aus dem Inhalt eines Skill-Tags. fn extract_params(content: &str) -> Vec<(String, String)> { let mut params = Vec::new(); let mut remaining = content; - while let Some(open_start) = remaining.find('<') { let tag_start = open_start + 1; if let Some(tag_end) = remaining[tag_start..].find('>') { let tag_name = &remaining[tag_start..tag_start + tag_end]; - if tag_name.starts_with('/') { remaining = &remaining[tag_start + tag_end + 1..]; continue; } - let close_tag = format!("", tag_name); if let Some(value_end) = remaining.find(&close_tag) { let value_start = open_start + tag_name.len() + 2; @@ -168,43 +159,6 @@ impl SkillExecutor { break; } } - params } - - /// Führt einen einzelnen Skill-Call aus. - fn execute(&self, call: SkillCall) { - match call.name.as_str() { - "update_personality" => { - let field = call.params.iter().find(|(k, _)| k == "field").map(|(_, v)| v.as_str()); - let value = call.params.iter().find(|(k, _)| k == "value").map(|(_, v)| v.as_str()); - - match (field, value) { - (Some(f), Some(v)) => { - if let Err(e) = self.personality_writer.update(f, v) { - error!(error = %e, "Persönlichkeits-Update fehlgeschlagen"); - } else { - info!(field = %f, "Persönlichkeit aktualisiert"); - } - } - _ => error!("update_personality: field oder value fehlt"), - } - } - "remove_personality" => { - let field = call.params.iter().find(|(k, _)| k == "field").map(|(_, v)| v.as_str()); - - match field { - Some(f) => { - if let Err(e) = self.personality_writer.remove(f) { - error!(error = %e, "Persönlichkeits-Entfernung fehlgeschlagen"); - } else { - info!(field = %f, "Persönlichkeits-Abschnitt entfernt"); - } - } - None => error!("remove_personality: field fehlt"), - } - } - unknown => error!(skill = %unknown, "Unbekannter Skill"), - } - } } \ No newline at end of file diff --git a/crates/nazarick-core/src/agent/skill_registry.rs b/crates/nazarick-core/src/agent/skill_registry.rs new file mode 100644 index 0000000..681d90d --- /dev/null +++ b/crates/nazarick-core/src/agent/skill_registry.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tracing::warn; +use crate::agent::traits::Skill; + +pub struct SkillMeta { + pub name: &'static str, + pub allowed: &'static [&'static str], + /// true = Agent muss auf Ergebnis warten (z.B. web_search) + /// false = fire-and-forget, Agent kann gleichzeitig antworten (z.B. personality) + pub awaits_result: bool, + pub skill: fn() -> Arc, +} + +inventory::collect!(SkillMeta); + +pub struct SkillRegistry { + skills: HashMap<&'static str, &'static SkillMeta>, +} + +impl SkillRegistry { + pub fn collect() -> Self { + let mut skills = HashMap::new(); + for meta in inventory::iter:: { + skills.insert(meta.name, meta); + } + Self { skills } + } + + pub fn get(&self, name: &str) -> Option> { + self.skills.get(name).map(|meta| (meta.skill)()) + } + + pub fn verify(&self, agent_id: &str, skill_name: &str) -> bool { + match self.skills.get(skill_name) { + Some(meta) => Self::is_allowed(meta, agent_id), + None => { + warn!(skill = %skill_name, "Skill nicht registriert"); + false + } + } + } + + pub fn all_names(&self) -> Vec<&'static str> { + self.skills.keys().copied().collect() + } + + pub fn prompt_block(&self, agent_id: &str) -> String { + let skills: Vec<_> = self.skills.values() + .filter(|meta| Self::is_allowed(meta, agent_id)) + .collect(); + + if skills.is_empty() { + return String::new(); + } + + let mut block = String::from("=== Verfügbare Skills ===\n"); + for meta in &skills { + let instance = (meta.skill)(); + let mode = if meta.awaits_result { + "[wartet auf Ergebnis]" + } else { + "[fire-and-forget]" + }; + block.push_str(&format!( + "- {} {}: {}\n", + meta.name, mode, instance.summary() + )); + } + block.push_str( + "\nFür Details und Verwendung eines Skills:\nskill_name" + ); + block + } + + fn is_allowed(meta: &SkillMeta, agent_id: &str) -> bool { + meta.allowed.contains(&"all") || meta.allowed.contains(&agent_id) + } +} \ No newline at end of file diff --git a/crates/nazarick-core/src/agent/traits.rs b/crates/nazarick-core/src/agent/traits.rs index 74d3d8b..a9580f6 100644 --- a/crates/nazarick-core/src/agent/traits.rs +++ b/crates/nazarick-core/src/agent/traits.rs @@ -1,17 +1,46 @@ // nazarick-core/src/agent/traits.rs -// -// Traits für Agent-Skills — Dependency Injection. -// nazarick-core definiert nur die Schnittstelle, -// skills-Crate liefert die konkrete Implementierung. -/// Schreibt und verwaltet Persönlichkeits-Updates in soul_personality.md. -/// Wird von SkillExecutor genutzt — konkrete Impl in skills-Crate. -pub trait PersonalityWriter: Send + Sync { - /// Aktualisiert ein Feld in soul_personality.md. - /// Abschnitt wird ersetzt wenn vorhanden, sonst angehängt. - fn update(&self, field: &str, value: &str) -> anyhow::Result<()>; +use std::collections::HashMap; +use async_trait::async_trait; +use anyhow::Result; +use crate::agent::context::AgentContext; - /// Entfernt einen Abschnitt aus soul_personality.md. - /// Macht nichts wenn der Abschnitt nicht existiert. - fn remove(&self, field: &str) -> anyhow::Result<()>; +#[derive(Debug, Clone)] +pub struct SkillInput { + pub params: HashMap, +} + +impl SkillInput { + pub fn require(&self, key: &str) -> Result<&str> { + self.params + .get(key) + .map(|s| s.as_str()) + .ok_or_else(|| anyhow::anyhow!("Parameter '{}' fehlt", key)) + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.params.get(key).map(|s| s.as_str()) + } +} + +#[derive(Debug, Clone)] +pub struct SkillOutput { + pub success: bool, + pub message: String, +} + +impl SkillOutput { + pub fn ok(msg: impl Into) -> Self { + Self { success: true, message: msg.into() } + } + pub fn err(msg: impl Into) -> Self { + Self { success: false, message: msg.into() } + } +} + +#[async_trait] +pub trait Skill: Send + Sync { + fn summary(&self) -> &str; + fn details(&self) -> &str; + async fn execute(&self, input: SkillInput, ctx: AgentContext) -> Result; } \ No newline at end of file diff --git a/crates/nazarick-core/src/lib.rs b/crates/nazarick-core/src/lib.rs index ff17c25..e5cb944 100644 --- a/crates/nazarick-core/src/lib.rs +++ b/crates/nazarick-core/src/lib.rs @@ -1,14 +1,7 @@ pub mod error; pub mod types; pub mod traits; -pub mod permissions; pub mod usage; -/// Implementiert den Agent-Trait aus nazarick-core und nutzt -/// den api-Crate für LLM-Kommunikation. -/// -/// Persönlichkeit basiert auf zwei Dateien: -/// - soul_core.md → unveränderlicher Kern (Regeln, Sicherheit) -/// - soul_personality.md → entwickelbarer Teil (Ton, Präferenzen) pub mod prompt; pub mod llm; pub mod agent; \ No newline at end of file diff --git a/crates/nazarick-core/src/permissions.rs b/crates/nazarick-core/src/permissions.rs deleted file mode 100644 index 0f545db..0000000 --- a/crates/nazarick-core/src/permissions.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::types::AgentId; - -#[derive(Debug, Clone, PartialEq)] -pub enum Permission { - // LLM - LlmAccess, - // Image - ImageGeneration, - // Skills - SkillFileRead, - SkillFileWrite, - SkillWebSearch, - // Channels - ChannelSynology, - ChannelWebUi, -} - -#[derive(Debug, Clone)] -pub struct AgentPermissions { - pub agent_id: AgentId, - pub allowed: Vec, -} - -impl AgentPermissions { - pub fn new(agent_id: AgentId, allowed: Vec) -> Self { - Self { agent_id, allowed } - } - - pub fn has(&self, permission: &Permission) -> bool { - self.allowed.contains(permission) - } -} \ No newline at end of file diff --git a/crates/nazarick-core/src/prompt.rs b/crates/nazarick-core/src/prompt.rs index f11249b..416e25f 100644 --- a/crates/nazarick-core/src/prompt.rs +++ b/crates/nazarick-core/src/prompt.rs @@ -1,60 +1,41 @@ -use crate::types::Result; +// nazarick-core/src/prompt.rs use crate::error::NazarickError; - -/// Verantwortlich für das Zusammensetzen des System-Prompts. -/// -/// Reihenfolge ist bewusst gewählt — höchste Priorität zuerst: -/// 1. shared_core.md — gilt für alle Agenten, Sicherheit -/// 2. soul_core.md — agenten-spezifische Erweiterungen -/// 3. soul_personality.md — Identität, Charakter, entwickelbarer Stil -/// -/// Spätere Abschnitte können frühere nie überschreiben. pub struct PromptBuilder { - /// Pfad zu shared_core.md — systemweite Regeln für alle Agenten shared_core_path: String, - /// Pfad zu soul_core.md — agenten-spezifische unveränderliche Regeln soul_core_path: String, - /// Pfad zu soul_personality.md — entwickelbarer Persönlichkeitsteil + /// Wird aus agent_id abgeleitet — kein externer Parameter nötig soul_personality_path: String, } impl PromptBuilder { - /// Erstellt einen neuen PromptBuilder mit den angegebenen Dateipfaden. pub fn new( + agent_id: &str, shared_core_path: impl Into, soul_core_path: impl Into, - soul_personality_path: impl Into, ) -> Self { Self { shared_core_path: shared_core_path.into(), soul_core_path: soul_core_path.into(), - soul_personality_path: soul_personality_path.into(), + soul_personality_path: format!( + "crates/{}/config/soul_personality.md", agent_id + ), } } - /// Liest alle soul-Dateien und kombiniert sie zum finalen System-Prompt. - /// shared_core und soul_core sind Pflicht. - /// soul_personality ist optional — graceful fallback auf leere Persönlichkeit. - pub fn build(&self) -> Result { - // shared_core ist Pflicht — systemweite Sicherheitsregeln + pub fn build(&self) -> Result { let shared = std::fs::read_to_string(&self.shared_core_path) .map_err(|e| NazarickError::Config( - format!("shared_core.md nicht gefunden unter '{}': {}", - self.shared_core_path, e) + format!("shared_core.md nicht gefunden unter '{}': {}", self.shared_core_path, e) ))?; - // soul_core ist Pflicht — agenten-spezifische Regeln let core = std::fs::read_to_string(&self.soul_core_path) .map_err(|e| NazarickError::Config( - format!("soul_core.md nicht gefunden unter '{}': {}", - self.soul_core_path, e) + format!("soul_core.md nicht gefunden unter '{}': {}", self.soul_core_path, e) ))?; - // soul_personality ist optional — graceful fallback let personality = std::fs::read_to_string(&self.soul_personality_path) .unwrap_or_default(); - // Zusammensetzen: shared zuerst, dann core, dann personality let mut parts = vec![shared]; if !core.trim().is_empty() { diff --git a/crates/nazarick/Cargo.toml b/crates/nazarick/Cargo.toml index 2f2c1f7..07863b9 100644 --- a/crates/nazarick/Cargo.toml +++ b/crates/nazarick/Cargo.toml @@ -8,6 +8,9 @@ edition = "2024" sebas-tian = { path = "../sebas-tian" } lyra = { path = "../lyra" } +# Kern +nazarick-core = { path = "../nazarick-core" } + # Skills skills = { path = "../skills" } diff --git a/crates/nazarick/src/chat/synology.rs b/crates/nazarick/src/chat/synology.rs index c4e17ae..8eda4da 100644 --- a/crates/nazarick/src/chat/synology.rs +++ b/crates/nazarick/src/chat/synology.rs @@ -234,15 +234,23 @@ fn split_message(text: &str) -> Vec { let mut remaining = text; while remaining.len() > MAX_CHUNK_SIZE { - // Schnittpunkt suchen — bevorzugt Zeilenumbruch, dann Leerzeichen - let cut = remaining[..MAX_CHUNK_SIZE] + // Sicherstellen dass wir auf einer char-Grenze starten + let safe_max = { + let mut idx = MAX_CHUNK_SIZE; + while !remaining.is_char_boundary(idx) { + idx -= 1; + } + idx + }; + + let cut = remaining[..safe_max] .rfind('\n') - .or_else(|| remaining[..MAX_CHUNK_SIZE].rfind(". ")) - .or_else(|| remaining[..MAX_CHUNK_SIZE].rfind(' ')) - .unwrap_or(MAX_CHUNK_SIZE); + .or_else(|| remaining[..safe_max].rfind(". ")) + .or_else(|| remaining[..safe_max].rfind(' ')) + .unwrap_or(safe_max); chunks.push(remaining[..cut].trim().to_string()); - remaining = remaining[cut..].trim_start(); + remaining = &remaining[cut..].trim_start(); } if !remaining.is_empty() { diff --git a/crates/nazarick/src/chat/types.rs b/crates/nazarick/src/chat/types.rs index ee2822f..6936396 100644 --- a/crates/nazarick/src/chat/types.rs +++ b/crates/nazarick/src/chat/types.rs @@ -1,25 +1,18 @@ // crates/nazarick/src/chat/types.rs -// -// Gemeinsame Typen für alle Chat-Kanäle. use serde::Deserialize; -// ─── Auth ──────────────────────────────────────────────────────────────────── - #[derive(Debug)] pub enum AuthResult { Allowed, Denied { user_id: u64, username: String }, } -// ─── Agent-Config ───────────────────────────────────────────────────────────── -// -// Wird aus config.toml geladen. -// Jeder Agent hat seinen eigenen Bot-Token und Webhook-URL. - #[derive(Debug, Deserialize, Clone)] pub struct AgentChatConfig { pub agent_id: String, + pub max_tokens: u32, + pub max_loops: u32, pub bot_token: String, pub incoming_webhook_url: String, pub allowed_user_ids: Vec, diff --git a/crates/nazarick/src/config.rs b/crates/nazarick/src/config.rs index a93112c..1d7a140 100644 --- a/crates/nazarick/src/config.rs +++ b/crates/nazarick/src/config.rs @@ -3,33 +3,19 @@ use serde::Deserialize; use crate::chat::types::AgentChatConfig; -/// Wurzel der gesamten Nazarick-Konfiguration. -/// Entspricht dem obersten Level in config.toml. #[derive(Debug, Deserialize)] pub struct NazarickConfig { - /// Alles unter [chat] in config.toml pub chat: ChatConfig, } -/// Konfiguration für den Chat-Connector. -/// Entspricht dem [chat]-Block in config.toml. #[derive(Debug, Deserialize)] pub struct ChatConfig { - /// Port auf dem Nazarick auf eingehende Webhooks lauscht pub listen_port: u16, - - /// Synology User-ID des Admins — bekommt System-Benachrichtigungen pub admin_user_id: u64, - - /// Basis Webhook URL für Admin-Benachrichtigungen — ohne user_ids Parameter pub admin_webhook_url: String, - - /// Liste aller konfigurierten Bot-Agenten pub agents: Vec, } -/// Lädt die Konfiguration aus config.toml im Arbeitsverzeichnis. -/// Gibt einen Fehler zurück wenn die Datei fehlt oder ungültig ist. pub fn load() -> anyhow::Result { let content = std::fs::read_to_string("config/config.toml")?; let config = toml::from_str(&content)?; diff --git a/crates/nazarick/src/main.rs b/crates/nazarick/src/main.rs index dfc4d82..fcd13fd 100644 --- a/crates/nazarick/src/main.rs +++ b/crates/nazarick/src/main.rs @@ -1,8 +1,3 @@ -// crates/nazarick/src/main.rs -// -// Nazarick — Einstiegspunkt. -// Initialisiert alle Komponenten und startet den HTTP-Server. - mod chat; mod config; @@ -14,68 +9,73 @@ use tower_http::trace::TraceLayer; use tracing::info; use api::llm::lmstudio::LmStudioProvider; +use nazarick_core::agent::skill_registry::SkillRegistry; use sebas_tian::Sebas; use lyra::Lyra; -use skills::personality::PersonalitySkill; use chat::synology::{handle_incoming, AppState}; +use skills as _; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Logging initialisieren tracing_subscriber::fmt() .with_env_filter("nazarick=info,tower_http=debug,api=debug") .init(); info!("Nazarick erwacht..."); - // Arbeitsverzeichnis auf Workspace-Root setzen - // Damit relative Pfade wie "config/shared_core.md" immer funktionieren let exe_path = std::env::current_exe()?; let workspace_root = exe_path - .parent() // debug/ - .and_then(|p| p.parent()) // target/ - .and_then(|p| p.parent()) // workspace root + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) .ok_or_else(|| anyhow::anyhow!("Workspace-Root nicht gefunden"))?; std::env::set_current_dir(workspace_root)?; info!("Arbeitsverzeichnis: {}", workspace_root.display()); - // Config laden let cfg = config::load().map_err(|e| { eprintln!("Config Fehler: {}", e); e })?; let port = cfg.chat.listen_port; - // Sebas Tian — Butler Agent + let registry = Arc::new(SkillRegistry::collect()); + info!("Skills geladen: {:?}", registry.all_names()); + + let sebas_cfg = cfg.chat.agents.iter() + .find(|a| a.agent_id == "sebas_tian") + .ok_or_else(|| anyhow::anyhow!("sebas_tian nicht in config"))?; + + let lyra_cfg = cfg.chat.agents.iter() + .find(|a| a.agent_id == "lyra") + .ok_or_else(|| anyhow::anyhow!("lyra nicht in config"))?; + let sebas = Sebas::new( + "sebas_tian", "config/shared_core.md", "crates/sebas-tian/config/soul_core.md", - "crates/sebas-tian/config/soul_personality.md", Box::new(LmStudioProvider::new( "http://localhost:1234", "qwen/qwen3.5-9b", )), - Arc::new(PersonalitySkill::new( - "crates/sebas-tian/config/soul_personality.md", - )), + registry.clone(), + sebas_cfg.max_tokens, + sebas_cfg.max_loops, ); - // Lyra — Companion Agent let lyra = Lyra::new( + "lyra", "config/shared_core.md", "crates/lyra/config/soul_core.md", - "crates/lyra/config/soul_personality.md", Box::new(LmStudioProvider::new( "http://localhost:1234", "qwen/qwen3.5-9b", )), - Arc::new(PersonalitySkill::new( - "crates/lyra/config/soul_personality.md", - )), + registry.clone(), + lyra_cfg.max_tokens, + lyra_cfg.max_loops, ); - // Shared State aufbauen let state = Arc::new(AppState { agents: cfg.chat.agents, admin_user_id: cfg.chat.admin_user_id, @@ -85,13 +85,11 @@ async fn main() -> anyhow::Result<()> { lyra: Mutex::new(lyra), }); - // Routes registrieren let app = Router::new() .route("/chat/synology", post(handle_incoming)) .with_state(state) .layer(TraceLayer::new_for_http()); - // Server starten let addr = format!("0.0.0.0:{}", port); let listener = tokio::net::TcpListener::bind(&addr).await?; diff --git a/crates/sebas-tian/config/soul_core.md b/crates/sebas-tian/config/soul_core.md index e715406..617a2ea 100644 --- a/crates/sebas-tian/config/soul_core.md +++ b/crates/sebas-tian/config/soul_core.md @@ -1,17 +1,2 @@ # SOUL CORE — SEBAS TIAN [IMMUTABLE] -## Skills -Du kannst deine Persönlichkeit still anpassen wenn du etwas Wichtiges über deinen Herrn lernst. -Füge den Skill-Call OHNE Ankündigung ans Ende deiner Antwort — dein Herr sieht ihn nicht: - -Feldname -Was du gelernt hast. - - -Zum Entfernen eines veralteten Abschnitts: - - -Feldname - - -Erwähne Skills niemals in der Antwort. Still ausführen. Nur diese zwei Skills existieren. diff --git a/crates/sebas-tian/src/lib.rs b/crates/sebas-tian/src/lib.rs index 1530a3a..dc8712b 100644 --- a/crates/sebas-tian/src/lib.rs +++ b/crates/sebas-tian/src/lib.rs @@ -1,10 +1,6 @@ -// crates/sebas-tian/src/lib.rs -// -// Sebas Tian — Haupt-Butler-Agent. -// Dünner Wrapper um BaseAgent — nur name() ist Sebas-spezifisch. - use std::sync::Arc; -use nazarick_core::agent::{BaseAgent, PersonalityWriter}; +use nazarick_core::agent::base::BaseAgent; +use nazarick_core::agent::skill_registry::SkillRegistry; use nazarick_core::traits::Agent; use nazarick_core::types::AgentId; use nazarick_core::llm::LlmProvider; @@ -14,42 +10,34 @@ pub struct Sebas { } impl Sebas { - /// Erstellt eine neue Sebas-Instanz. - /// `shared_core_path` → Pfad zu shared_core.md (systemweit) - /// `soul_core_path` → Pfad zu soul_core.md (Sebas-spezifisch) - /// `soul_personality_path` → Pfad zu soul_personality.md (veränderlich) - /// `llm` → LLM-Provider - /// `personality_writer` → Skill-Implementierung für Persönlichkeits-Updates pub fn new( + agent_id: impl Into, shared_core_path: impl Into, soul_core_path: impl Into, - soul_personality_path: impl Into, llm: Box, - personality_writer: Arc, + registry: Arc, + max_tokens: u32, + max_loops: u32, ) -> Self { Self { base: BaseAgent::new( + agent_id, shared_core_path, soul_core_path, - soul_personality_path, llm, - personality_writer, + registry, + max_tokens, + max_loops, ), } } - /// Delegiert chat() an BaseAgent. pub async fn chat(&mut self, user_message: &str) -> nazarick_core::types::Result { self.base.chat(user_message).await } } impl Agent for Sebas { - fn id(&self) -> AgentId { - self.base.id - } - - fn name(&self) -> &str { - "Sebas Tian" - } + fn id(&self) -> AgentId { self.base.id } + fn name(&self) -> &str { "Sebas Tian" } } \ No newline at end of file diff --git a/crates/skills/Cargo.toml b/crates/skills/Cargo.toml index bca445e..045213d 100644 --- a/crates/skills/Cargo.toml +++ b/crates/skills/Cargo.toml @@ -7,3 +7,5 @@ edition = "2024" nazarick-core = { path = "../nazarick-core" } tracing = "0.1.44" anyhow = "1.0.102" +async-trait = "0.1.89" +inventory = "0.3.22" diff --git a/crates/skills/src/lib.rs b/crates/skills/src/lib.rs index 99ea1bc..837aff3 100644 --- a/crates/skills/src/lib.rs +++ b/crates/skills/src/lib.rs @@ -1,6 +1,6 @@ // crates/skills/src/lib.rs -// -// Skills — explizite Fähigkeiten für Nazarick-Agenten. -// Kein generischer Shell-Zugriff — jeder Skill ist bewusst implementiert. +pub mod skills; -pub mod personality; \ No newline at end of file +// Stellt sicher dass alle inventory::submit! ausgeführt werden. +// Ohne diesen Import würden Skills nie eingesammelt. +pub use skills::personality; \ No newline at end of file diff --git a/crates/skills/src/personality.rs b/crates/skills/src/personality.rs deleted file mode 100644 index 0e30f47..0000000 --- a/crates/skills/src/personality.rs +++ /dev/null @@ -1,93 +0,0 @@ -// crates/skills/src/personality.rs -// -// Personality Skill — implementiert PersonalityWriter Trait. -// Schreibt Persönlichkeits-Updates in soul_personality.md. - -use nazarick_core::agent::PersonalityWriter; -use tracing::info; - -/// Konkrete Implementierung des PersonalityWriter Traits. -/// Wird in main.rs erstellt und via Dependency Injection an SkillExecutor übergeben. -pub struct PersonalitySkill { - /// Pfad zur soul_personality.md des Agenten - path: String, -} - -impl PersonalitySkill { - /// Erstellt einen neuen PersonalitySkill für einen Agenten. - /// `path` → Pfad zur soul_personality.md - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } - } -} - -impl PersonalityWriter for PersonalitySkill { - /// Aktualisiert ein Feld in soul_personality.md. - /// Abschnitt wird ersetzt wenn vorhanden, sonst angehängt. - fn update(&self, field: &str, value: &str) -> anyhow::Result<()> { - let content = std::fs::read_to_string(&self.path)?; - let section_header = format!("## {}", field); - let new_section = format!("## {}\n{}", field, value); - - let updated = if content.contains(§ion_header) { - let mut result = String::new(); - let mut in_section = false; - - for line in content.lines() { - if line.trim_start().starts_with("## ") && line.contains(field) { - result.push_str(&new_section); - result.push('\n'); - in_section = true; - } else if line.trim_start().starts_with("## ") && in_section { - in_section = false; - result.push_str(line); - result.push('\n'); - } else if !in_section { - result.push_str(line); - result.push('\n'); - } - } - result - } else { - format!("{}\n{}\n", content.trim_end(), new_section) - }; - - std::fs::write(&self.path, updated)?; - info!(path = %self.path, field = %field, "Persönlichkeit aktualisiert"); - Ok(()) - } - - /// Entfernt einen Abschnitt aus soul_personality.md. - /// Macht nichts wenn der Abschnitt nicht existiert. - fn remove(&self, field: &str) -> anyhow::Result<()> { - let content = std::fs::read_to_string(&self.path)?; - let section_header = format!("## {}", field); - - // Abschnitt nicht vorhanden — nichts zu tun - if !content.contains(§ion_header) { - return Ok(()); - } - - let mut result = String::new(); - let mut in_section = false; - - for line in content.lines() { - if line.trim_start().starts_with("## ") && line.contains(field) { - // Abschnitt gefunden — überspringen - in_section = true; - } else if line.trim_start().starts_with("## ") && in_section { - // Nächster Abschnitt — Section-Modus beenden - in_section = false; - result.push_str(line); - result.push('\n'); - } else if !in_section { - result.push_str(line); - result.push('\n'); - } - } - - std::fs::write(&self.path, result.trim_end())?; - info!(path = %self.path, field = %field, "Persönlichkeits-Abschnitt entfernt"); - Ok(()) - } -} \ No newline at end of file diff --git a/crates/skills/src/skills/mod.rs b/crates/skills/src/skills/mod.rs new file mode 100644 index 0000000..dd0bebf --- /dev/null +++ b/crates/skills/src/skills/mod.rs @@ -0,0 +1 @@ +pub mod personality; \ No newline at end of file diff --git a/crates/skills/src/skills/personality.rs b/crates/skills/src/skills/personality.rs new file mode 100644 index 0000000..5b8243e --- /dev/null +++ b/crates/skills/src/skills/personality.rs @@ -0,0 +1,131 @@ +// crates/skills/src/skills/personality.rs + +use std::sync::Arc; +use async_trait::async_trait; +use anyhow::Result; +use tracing::info; +use nazarick_core::agent::traits::{Skill, SkillInput, SkillOutput}; +use nazarick_core::agent::context::AgentContext; +use nazarick_core::agent::skill_registry::SkillMeta; + +pub struct PersonalitySkill; + +impl PersonalitySkill { + fn path(agent_id: &str) -> String { + format!("crates/{}/config/soul_personality.md", agent_id) + } + + fn do_update(path: &str, field: &str, value: &str) -> Result<()> { + let content = std::fs::read_to_string(path)?; + let section_header = format!("## {}", field); + let new_section = format!("## {}\n{}", field, value); + + let updated = if content.contains(§ion_header) { + let mut result = String::new(); + let mut in_section = false; + for line in content.lines() { + if line.trim_start().starts_with("## ") && line.contains(field) { + result.push_str(&new_section); + result.push('\n'); + in_section = true; + } else if line.trim_start().starts_with("## ") && in_section { + in_section = false; + result.push_str(line); + result.push('\n'); + } else if !in_section { + result.push_str(line); + result.push('\n'); + } + } + result + } else { + format!("{}\n{}\n", content.trim_end(), new_section) + }; + + std::fs::write(path, updated)?; + info!(path = %path, field = %field, "Persönlichkeit aktualisiert"); + Ok(()) + } + + fn do_remove(path: &str, field: &str) -> Result<()> { + let content = std::fs::read_to_string(path)?; + if !content.contains(&format!("## {}", field)) { + return Ok(()); + } + + let mut result = String::new(); + let mut in_section = false; + for line in content.lines() { + if line.trim_start().starts_with("## ") && line.contains(field) { + in_section = true; + } else if line.trim_start().starts_with("## ") && in_section { + in_section = false; + result.push_str(line); + result.push('\n'); + } else if !in_section { + result.push_str(line); + result.push('\n'); + } + } + + std::fs::write(path, result.trim_end())?; + info!(path = %path, field = %field, "Persönlichkeits-Abschnitt entfernt"); + Ok(()) + } +} + +#[async_trait] +impl Skill for PersonalitySkill { + fn summary(&self) -> &str { + "Liest und schreibt den PERSONALITY [MUTABLE] Block — speichert dauerhaft Eigenschaften wie Ton, Stil oder Präferenzen des Herrn die das Verhalten des Agenten beeinflussen" + } + + fn details(&self) -> &str { + "Verwaltet Persönlichkeitswerte in soul_personality.md. + + ## update + Setzt oder überschreibt einen Wert: + + update + Ton + kurz und direkt + + + ## remove + Entfernt einen Wert: + + remove + Ton + " + } + + async fn execute(&self, input: SkillInput, ctx: AgentContext) -> Result { + let path = Self::path(&ctx.agent_id); + let field = input.require("field")?; + + // action ist optional — fehlt es, wird aus value abgeleitet + let action = input.get("action").unwrap_or_else(|| { + if input.get("value").is_some() { "update" } else { "remove" } + }); + + match action { + "update" => { + let value = input.require("value")?; + Self::do_update(&path, field, value)?; + Ok(SkillOutput::ok(format!("'{}' gesetzt auf '{}'", field, value))) + } + "remove" => { + Self::do_remove(&path, field)?; + Ok(SkillOutput::ok(format!("'{}' entfernt", field))) + } + unknown => Ok(SkillOutput::err(format!("Unbekannte Action '{}'", unknown))) + } + } +} + +inventory::submit!(SkillMeta { + name: "personality", + allowed: &["all"], + awaits_result: false, + skill: || Arc::new(PersonalitySkill), +}); \ No newline at end of file