nexo-core 0.1.18

Agent runtime: event bus, sessions, plugin trait, heartbeat, A2A delegation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
//! Agent workspace loader — Phase 10.1–10.3.
//!
//! Loads the set of markdown files that make up an agent's "self":
//! IDENTITY.md (parsed), SOUL.md + USER.md + AGENTS.md (raw text), recent
//! daily notes, and MEMORY.md. Modeled on OpenClaw's workspace layout
//! (`docs/concepts/agent-workspace.md`) but scoped to what `LlmAgentBehavior`
//! needs to inject into the system prompt.
//!
//! Design rules:
//! - `MEMORY.md` only loads in **main** sessions (direct DMs). Group/broadcast
//!   sessions must not see it — enforced at load time, not via query filters.
//! - Bootstrap char limits (`max_per_file`, `max_total`) mirror OpenClaw's
//!   defaults (12_000 / 60_000) to keep prompt overhead predictable.
//! - Missing files are silently skipped; a missing workspace is not an error
//!   (agents without workspaces still work — they just have no persona layer).
//! - No writes here — the loader is read-only. Writes belong to memory tools.
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Parsed contents of a workspace directory — everything the LLM turn needs.
#[derive(Debug, Clone, Default)]
pub struct WorkspaceBundle {
    pub identity: Option<AgentIdentity>,
    pub soul: Option<String>,
    pub user: Option<String>,
    pub agents: Option<String>,
    pub daily_notes: Vec<DailyNote>,
    pub long_term_memory: Option<String>,
    /// Extra rule / context MDs from `AgentConfig::extra_docs`. Each
    /// entry is `(filename, content)`; rendered as its own `# RULES —
    /// <filename>` block after the standard sections.
    pub extra_docs: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentIdentity {
    pub name: Option<String>,
    pub creature: Option<String>,
    pub vibe: Option<String>,
    pub emoji: Option<String>,
    pub avatar: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DailyNote {
    pub date: String,
    pub content: String,
}
/// What kind of session is being bootstrapped. Drives the MEMORY.md gate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SessionScope {
    /// Private DM with the primary user. `MEMORY.md` is loaded.
    Main,
    /// Group chat, broadcast, or any shared context. `MEMORY.md` is NOT loaded.
    Shared,
}
/// Bootstrap char limits. OpenClaw defaults: 12_000 per file, 60_000 total.
#[derive(Debug, Clone, Copy)]
pub struct LoadLimits {
    pub max_per_file: usize,
    pub max_total: usize,
}
impl Default for LoadLimits {
    fn default() -> Self {
        // Phase 67 roadmaps grow into the 100KB+ range
        // (PHASES.md, FOLLOWUPS.md) and the 12K/60K legacy
        // defaults silently truncated them to ~10%, hiding the
        // tail from the LLM. Modern Claude 4.x context is 200K
        // tokens (1M on Opus 4.7 / Sonnet 4.6), so loading
        // ~50K extra characters of workspace docs costs us
        // <5% of the window — a much better trade than
        // chopping the roadmap mid-table. Operators with very
        // small models can lower this via a per-agent override
        // (future work: surface as YAML field).
        Self {
            max_per_file: 200_000,
            max_total: 300_000,
        }
    }
}
pub struct WorkspaceLoader {
    root: PathBuf,
    limits: LoadLimits,
}
impl WorkspaceLoader {
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self {
            root: root.into(),
            limits: LoadLimits::default(),
        }
    }
    pub fn with_limits(mut self, limits: LoadLimits) -> Self {
        self.limits = limits;
        self
    }
    pub fn root(&self) -> &Path {
        &self.root
    }
    /// Load everything applicable to the given session scope. Missing files
    /// are skipped; IO errors bubble up so callers can decide whether the
    /// workspace is misconfigured vs genuinely empty.
    pub async fn load(&self, scope: SessionScope) -> anyhow::Result<WorkspaceBundle> {
        self.load_with_extras(scope, &[]).await
    }
    /// Same as `load` but additionally reads each path in `extra_docs`
    /// (workspace-relative) into `WorkspaceBundle::extra_docs`. Intended
    /// for topic-scoped rule files (`SALES_SCRIPT.md`, etc.) that the
    /// agent should treat as hard context alongside IDENTITY/SOUL.
    // Sequential awaits against a shared budget make a single struct-literal
    // init unreadable; per-field mutation is clearer here. Silence the lint
    // for this one function rather than hurt legibility.
    #[allow(clippy::field_reassign_with_default)]
    pub async fn load_with_extras(
        &self,
        scope: SessionScope,
        extra_docs: &[String],
    ) -> anyhow::Result<WorkspaceBundle> {
        let mut budget = self.limits.max_total;
        let mut bundle = WorkspaceBundle::default();
        bundle.identity = self
            .read_opt("IDENTITY.md", &mut budget)
            .await?
            .map(|s| parse_identity(&s));
        bundle.soul = self.read_opt("SOUL.md", &mut budget).await?;
        bundle.user = self.read_opt("USER.md", &mut budget).await?;
        bundle.agents = self.read_opt("AGENTS.md", &mut budget).await?;
        // Daily notes: today and yesterday (UTC). Missing files are skipped.
        let today = Utc::now().date_naive();
        for offset in [0i64, 1] {
            let date = today - Duration::days(offset);
            let rel = format!("memory/{}.md", date.format("%Y-%m-%d"));
            if let Some(content) = self.read_opt(&rel, &mut budget).await? {
                bundle.daily_notes.push(DailyNote {
                    date: date.format("%Y-%m-%d").to_string(),
                    content,
                });
            }
        }
        // MEMORY.md is the privacy boundary — only loaded in main sessions.
        if scope == SessionScope::Main {
            bundle.long_term_memory = self.read_opt("MEMORY.md", &mut budget).await?;
        }
        // Extra rule docs — scoped context the agent must respect. Load
        // after MEMORY so the standard blocks always win the budget in
        // contention. A missing extra doc logs a warning but doesn't fail
        // the turn (could be a typo in config; better to keep the agent
        // running than to hard-stop).
        for rel in extra_docs {
            let rel = rel.trim();
            if rel.is_empty() {
                continue;
            }
            match self.read_opt(rel, &mut budget).await {
                Ok(Some(content)) => bundle.extra_docs.push((rel.to_string(), content)),
                Ok(None) => tracing::warn!(
                    workspace = %self.root.display(),
                    doc = %rel,
                    "extra_doc listed in config not found on disk",
                ),
                Err(e) => tracing::warn!(
                    workspace = %self.root.display(),
                    doc = %rel,
                    error = %e,
                    "failed to read extra_doc; continuing",
                ),
            }
        }
        Ok(bundle)
    }
    /// Read a workspace-relative file, apply per-file truncation, subtract
    /// from the global budget. Returns `Ok(None)` when the file is absent.
    /// Relative paths containing `..` or absolute paths are rejected so
    /// a crafted `extra_docs` entry cannot read files outside the
    /// agent's workspace root.
    async fn read_opt(&self, rel: &str, budget: &mut usize) -> anyhow::Result<Option<String>> {
        if *budget == 0 {
            return Ok(None);
        }
        let rel_path = std::path::Path::new(rel);
        if rel_path.is_absolute() {
            tracing::warn!(
                rel = %rel,
                "workspace read rejected: absolute path not allowed"
            );
            return Ok(None);
        }
        if rel_path
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
        {
            tracing::warn!(
                rel = %rel,
                "workspace read rejected: `..` traversal not allowed"
            );
            return Ok(None);
        }
        let path = self.root.join(rel);
        let limit = self.limits.max_per_file.min(*budget);
        // Read at most `limit + 1` bytes: enough to detect overflow
        // without paying for huge files (SOUL.md can grow to 100KB+).
        // `+1` guarantees `truncate` emits the [truncated] marker when
        // the file exceeds the limit.
        let read_cap = limit.saturating_add(1);
        let bytes = match read_bounded(&path, read_cap).await {
            Ok(b) => b,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(anyhow::anyhow!("failed to read {}: {e}", path.display())),
        };
        // File content must be valid UTF-8; if the cap chopped mid-char,
        // `from_utf8_lossy` handles it gracefully.
        let content = match String::from_utf8(bytes) {
            Ok(s) => s,
            Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
        };
        let trimmed = truncate(content, limit);
        *budget = budget.saturating_sub(trimmed.len());
        Ok(Some(trimmed))
    }
}
/// Read up to `limit` bytes from `path`. Uses `File::take` so huge
/// files don't trigger a full buffered read.
async fn read_bounded(path: &std::path::Path, limit: usize) -> std::io::Result<Vec<u8>> {
    use tokio::io::AsyncReadExt;
    let file = tokio::fs::File::open(path).await?;
    let mut buf = Vec::with_capacity(limit.min(8192));
    file.take(limit as u64).read_to_end(&mut buf).await?;
    Ok(buf)
}
fn truncate(mut s: String, limit: usize) -> String {
    if s.len() <= limit {
        return s;
    }
    // Find a char boundary at or below the limit so we never split a utf-8 scalar.
    let mut cut = limit;
    while cut > 0 && !s.is_char_boundary(cut) {
        cut -= 1;
    }
    s.truncate(cut);
    s.push_str("\n\n[truncated]");
    s
}
/// Parse IDENTITY.md — 5 bullet-list fields: Name, Creature, Vibe, Emoji, Avatar.
/// Values wrapped in parens (placeholder hints from the template) are skipped
/// so the bootstrap template doesn't leak into the agent's persona.
pub fn parse_identity(content: &str) -> AgentIdentity {
    let mut id = AgentIdentity::default();
    for line in content.lines() {
        let Some(stripped) = line.trim_start().strip_prefix('-') else {
            continue;
        };
        let stripped = stripped.trim_start();
        // Expected shape: `**Name:** value` or `Name: value` or with italics.
        let (key, val) = match extract_field(stripped) {
            Some(kv) => kv,
            None => continue,
        };
        let val = val.trim();
        if val.is_empty() || is_placeholder(val) {
            continue;
        }
        let slot = match key.to_ascii_lowercase().as_str() {
            "name" => &mut id.name,
            "creature" => &mut id.creature,
            "vibe" => &mut id.vibe,
            "emoji" => &mut id.emoji,
            "avatar" => &mut id.avatar,
            _ => continue,
        };
        *slot = Some(val.to_string());
    }
    id
}
fn extract_field(line: &str) -> Option<(String, String)> {
    // Strip markdown emphasis around the key: **Name:** or *Name:* or Name:
    let line = line.trim_start_matches('*').trim();
    let colon = line.find(':')?;
    let (raw_key, rest) = line.split_at(colon);
    let key = raw_key.trim_end_matches('*').trim().to_string();
    let value = rest[1..].trim_start_matches('*').trim().to_string();
    if key.is_empty() {
        return None;
    }
    Some((key, value))
}
fn is_placeholder(val: &str) -> bool {
    let v = val.trim();
    // Template placeholders are wrapped in italics + parens: "_(pick something)_".
    (v.starts_with("_(") && v.ends_with(")_")) || (v.starts_with('(') && v.ends_with(')'))
}
impl WorkspaceBundle {
    /// Render the bundle as a set of ordered blocks suitable for prefixing
    /// the system prompt. Each block is tagged so the model can distinguish
    /// persona/identity from memory. Returns `None` when nothing was loaded.
    pub fn render_system_blocks(&self) -> Option<String> {
        let mut out = String::new();
        if let Some(id) = &self.identity {
            let mut fields = Vec::new();
            if let Some(v) = &id.name {
                fields.push(format!("name={v}"));
            }
            if let Some(v) = &id.creature {
                fields.push(format!("creature={v}"));
            }
            if let Some(v) = &id.vibe {
                fields.push(format!("vibe={v}"));
            }
            if let Some(v) = &id.emoji {
                fields.push(format!("emoji={v}"));
            }
            if !fields.is_empty() {
                out.push_str("# IDENTITY\n");
                out.push_str(&fields.join(", "));
                out.push_str("\n\n");
            }
        }
        if let Some(soul) = &self.soul {
            out.push_str("# SOUL\n");
            out.push_str(soul.trim());
            out.push_str("\n\n");
        }
        if let Some(user) = &self.user {
            out.push_str("# USER\n");
            out.push_str(user.trim());
            out.push_str("\n\n");
        }
        if let Some(agents) = &self.agents {
            out.push_str("# AGENTS\n");
            out.push_str(agents.trim());
            out.push_str("\n\n");
        }
        if !self.daily_notes.is_empty() {
            out.push_str("# RECENT NOTES\n");
            for note in &self.daily_notes {
                out.push_str(&format!("## {}\n{}\n\n", note.date, note.content.trim()));
            }
        }
        if let Some(memory) = &self.long_term_memory {
            out.push_str("# MEMORY\n");
            out.push_str(memory.trim());
            out.push_str("\n\n");
        }
        for (filename, content) in &self.extra_docs {
            out.push_str(&format!("# RULES — {}\n", filename));
            out.push_str(content.trim());
            out.push_str("\n\n");
        }
        if out.is_empty() {
            None
        } else {
            Some(out.trim_end().to_string())
        }
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn parses_identity_fields_skipping_placeholders() {
        let src = "\
# IDENTITY.md
- **Name:** Kate
- **Creature:** _(AI? robot? familiar?)_
- **Vibe:** warm but sharp
- **Emoji:** 🐙
- **Avatar:**
";
        let id = parse_identity(src);
        assert_eq!(id.name.as_deref(), Some("Kate"));
        assert!(
            id.creature.is_none(),
            "template placeholder must be skipped"
        );
        assert_eq!(id.vibe.as_deref(), Some("warm but sharp"));
        assert_eq!(id.emoji.as_deref(), Some("🐙"));
        assert!(id.avatar.is_none(), "empty value must be skipped");
    }
    #[test]
    fn parses_identity_without_bold_markers() {
        let id = parse_identity("- Name: Plain\n- Emoji: 🤖\n");
        assert_eq!(id.name.as_deref(), Some("Plain"));
        assert_eq!(id.emoji.as_deref(), Some("🤖"));
    }
    #[test]
    fn truncate_respects_char_boundaries() {
        // 3 bytes per ñ — must not split mid-codepoint.
        let s = "ññññññ".to_string(); // 12 bytes
        let out = truncate(s, 5);
        assert!(out.starts_with('ñ'));
        assert!(out.ends_with("[truncated]"));
    }
    #[tokio::test]
    async fn missing_workspace_yields_empty_bundle() {
        let tmp =
            std::env::temp_dir().join(format!("nexo-core-ws-missing-{}", uuid::Uuid::new_v4()));
        let loader = WorkspaceLoader::new(&tmp);
        let bundle = loader.load(SessionScope::Main).await.unwrap();
        assert!(bundle.identity.is_none());
        assert!(bundle.soul.is_none());
        assert!(bundle.daily_notes.is_empty());
        assert!(bundle.long_term_memory.is_none());
    }
    #[tokio::test]
    async fn shared_scope_omits_long_term_memory() -> anyhow::Result<()> {
        let tmp =
            std::env::temp_dir().join(format!("nexo-core-ws-shared-{}", uuid::Uuid::new_v4()));
        tokio::fs::create_dir_all(&tmp).await?;
        tokio::fs::write(tmp.join("MEMORY.md"), "secret preferences").await?;
        tokio::fs::write(tmp.join("SOUL.md"), "be useful").await?;
        let loader = WorkspaceLoader::new(&tmp);
        let main = loader.load(SessionScope::Main).await?;
        assert!(main.long_term_memory.is_some());
        assert_eq!(main.soul.as_deref(), Some("be useful"));
        let shared = loader.load(SessionScope::Shared).await?;
        assert!(
            shared.long_term_memory.is_none(),
            "MEMORY.md must not leak into shared sessions"
        );
        assert_eq!(shared.soul.as_deref(), Some("be useful"));
        tokio::fs::remove_dir_all(&tmp).await.ok();
        Ok(())
    }
    #[tokio::test]
    async fn render_system_blocks_includes_only_loaded_sections() -> anyhow::Result<()> {
        let tmp =
            std::env::temp_dir().join(format!("nexo-core-ws-render-{}", uuid::Uuid::new_v4()));
        tokio::fs::create_dir_all(&tmp).await?;
        tokio::fs::write(
            tmp.join("IDENTITY.md"),
            "- **Name:** Kate\n- **Emoji:** 🐙\n",
        )
        .await?;
        tokio::fs::write(tmp.join("SOUL.md"), "have opinions").await?;
        let bundle = WorkspaceLoader::new(&tmp).load(SessionScope::Main).await?;
        let rendered = bundle.render_system_blocks().unwrap();
        assert!(rendered.contains("# IDENTITY"));
        assert!(rendered.contains("name=Kate"));
        assert!(rendered.contains("# SOUL"));
        assert!(rendered.contains("have opinions"));
        assert!(!rendered.contains("# USER"), "no USER.md was written");
        assert!(!rendered.contains("# MEMORY"), "no MEMORY.md was written");
        tokio::fs::remove_dir_all(&tmp).await.ok();
        Ok(())
    }
    #[tokio::test]
    async fn extra_docs_render_after_standard_blocks() -> anyhow::Result<()> {
        let tmp = std::env::temp_dir().join(format!("nexo-core-ws-extra-{}", uuid::Uuid::new_v4()));
        tokio::fs::create_dir_all(&tmp).await?;
        tokio::fs::write(tmp.join("SOUL.md"), "be useful").await?;
        tokio::fs::write(
            tmp.join("SALES_SCRIPT.md"),
            "Step 1: greet. Step 2: qualify. Step 3: close.",
        )
        .await?;
        tokio::fs::write(
            tmp.join("PRODUCT_CATALOG.md"),
            "- Widget A: $10\n- Widget B: $20",
        )
        .await?;
        let loader = WorkspaceLoader::new(&tmp);
        let bundle = loader
            .load_with_extras(
                SessionScope::Main,
                &[
                    "SALES_SCRIPT.md".to_string(),
                    "PRODUCT_CATALOG.md".to_string(),
                    "".to_string(),           // empty entry is ignored
                    "MISSING.md".to_string(), // logs warning, doesn't fail
                ],
            )
            .await?;
        let rendered = bundle.render_system_blocks().unwrap();
        let soul_idx = rendered.find("# SOUL").expect("soul present");
        let sales_idx = rendered
            .find("# RULES — SALES_SCRIPT.md")
            .expect("sales block");
        let catalog_idx = rendered
            .find("# RULES — PRODUCT_CATALOG.md")
            .expect("catalog block");
        // Extra blocks render AFTER the core SOUL block.
        assert!(soul_idx < sales_idx, "SOUL must come before RULES");
        assert!(sales_idx < catalog_idx, "RULES order preserved");
        assert!(rendered.contains("Step 1: greet"));
        assert!(rendered.contains("Widget A: $10"));
        assert!(!rendered.contains("MISSING"));
        tokio::fs::remove_dir_all(&tmp).await.ok();
        Ok(())
    }
    #[tokio::test]
    async fn per_file_truncation_applied() -> anyhow::Result<()> {
        let tmp = std::env::temp_dir().join(format!("nexo-core-ws-trunc-{}", uuid::Uuid::new_v4()));
        tokio::fs::create_dir_all(&tmp).await?;
        let big = "a".repeat(20_000);
        tokio::fs::write(tmp.join("SOUL.md"), &big).await?;
        let loader = WorkspaceLoader::new(&tmp).with_limits(LoadLimits {
            max_per_file: 1_000,
            max_total: 60_000,
        });
        let bundle = loader.load(SessionScope::Main).await?;
        let soul = bundle.soul.unwrap();
        assert!(soul.len() <= 1_000 + "\n\n[truncated]".len());
        assert!(soul.ends_with("[truncated]"));
        tokio::fs::remove_dir_all(&tmp).await.ok();
        Ok(())
    }
}