Skip to content

Commit 3b9e9f5

Browse files
committed
feat(runtime): update memory context integration with skill wiring system
1 parent 3a02568 commit 3b9e9f5

7 files changed

Lines changed: 239 additions & 22 deletions

File tree

Cargo.toml

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace.package]
2-
version = "0.1.2"
2+
version = "0.1.3"
33
edition = "2024"
44
license = "Apache-2.0"
55
authors = ["Bob Liu <akagi201@gmail.com>"]
@@ -15,31 +15,32 @@ resolver = "3"
1515

1616
[workspace.dependencies]
1717
# local crates
18-
alice-adapters = { path = "crates/alice-adapters", version = "0.1.2" }
19-
alice-cli = { path = "crates/alice-cli", version = "0.1.2" }
20-
alice-core = { path = "crates/alice-core", version = "0.1.2" }
21-
alice-runtime = { path = "crates/alice-runtime", version = "0.1.2" }
18+
alice-adapters = { path = "crates/alice-adapters", version = "0.1.3" }
19+
alice-cli = { path = "crates/alice-cli", version = "0.1.3" }
20+
alice-core = { path = "crates/alice-core", version = "0.1.3" }
21+
alice-runtime = { path = "crates/alice-runtime", version = "0.1.3" }
2222

2323
# external crates
2424
agent-client-protocol = "0.10.4"
2525
async-trait = "0.1.89"
26-
bob-adapters = "0.3.1"
27-
bob-chat = "0.3.1"
28-
bob-core = "0.3.1"
29-
bob-runtime = "0.3.1"
30-
bob-skills = "0.3.1"
26+
bob-adapters = "0.3.2"
27+
bob-chat = "0.3.2"
28+
bob-core = "0.3.2"
29+
bob-runtime = "0.3.2"
30+
bob-skills = "0.3.2"
3131
clap = "4.6.0"
3232
config = "0.15.22"
3333
eyre = "0.6.12"
3434
futures-util = "0.3.32"
35-
liter-llm = "1.1.1"
35+
liter-llm = "1.2.0"
3636
parking_lot = "0.12.5"
3737
rusqlite = "0.39.0"
3838
serde = "1.0.228"
3939
serde_json = "1.0.149"
4040
sqlite-vec = "0.1.9"
41+
tempfile = "3.26.0"
4142
thiserror = "2.0.18"
42-
tokio = "1.50.0"
43+
tokio = "1.51.0"
4344
tokio-util = "0.7.18"
4445
tracing = "0.1.44"
4546
tracing-subscriber = "0.3.23"

bin/alice-cli/tests/alice_once_smoke.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ async fn handle_input_with_skills_nl_input() {
385385
assert!(output.is_ok(), "handle_input_with_skills should succeed for NL input");
386386
let Ok(output) = output else { return };
387387
assert!(
388-
matches!(output, AgentLoopOutput::CommandOutput(_)),
389-
"NL input should return AgentLoopOutput::CommandOutput, got: {output:?}"
388+
matches!(output, AgentLoopOutput::Response(_)),
389+
"NL input should return AgentLoopOutput::Response, got: {output:?}"
390390
);
391391
}

crates/alice-adapters/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ tracing.workspace = true
3131
[features]
3232
discord = ["dep:serenity", "dep:eyre"]
3333
telegram = ["dep:teloxide", "dep:eyre"]
34+
35+
[dev-dependencies]
36+
tempfile.workspace = true

crates/alice-adapters/src/memory/sqlite_store.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,137 @@ impl MemoryStorePort for SqliteMemoryStore {
374374
Ok(hits)
375375
}
376376
}
377+
378+
#[cfg(test)]
379+
mod tests {
380+
use alice_core::memory::domain::{HybridWeights, MemoryEntry, MemoryImportance, RecallQuery};
381+
use tempfile::NamedTempFile;
382+
383+
use super::*;
384+
385+
fn create_temp_db() -> SqliteMemoryStore {
386+
let temp_file = NamedTempFile::new().unwrap();
387+
let path = temp_file.path();
388+
SqliteMemoryStore::open(path, 384, true).unwrap()
389+
}
390+
391+
#[test]
392+
fn open_creates_schema() {
393+
let store = create_temp_db();
394+
// Should not panic - schema creation should work
395+
let conn = store.conn.lock();
396+
let count: i64 = conn
397+
.query_row("SELECT COUNT(*) FROM sqlite_master WHERE type='table'", [], |row| {
398+
row.get(0)
399+
})
400+
.unwrap();
401+
assert!(count > 0, "schema should be created");
402+
}
403+
404+
#[test]
405+
fn insert_and_recall_memory() {
406+
let store = create_temp_db();
407+
let entry = MemoryEntry {
408+
id: "test-id".to_string(),
409+
session_id: "test-session".to_string(),
410+
topic: "test topic".to_string(),
411+
summary: "test summary".to_string(),
412+
raw_excerpt: "The quick brown fox jumps over the lazy dog".to_string(),
413+
keywords: vec!["test".to_string(), "fox".to_string()],
414+
importance: MemoryImportance::Medium,
415+
embedding: Some(vec![0.1, 0.2, 0.3]),
416+
created_at_epoch_ms: 1234567890,
417+
};
418+
419+
// Store the entry
420+
store.insert(&entry).unwrap();
421+
422+
// Recall using hybrid search
423+
let query = RecallQuery {
424+
session_id: Some("test-session".to_string()),
425+
text: "fox".to_string(),
426+
query_embedding: None,
427+
limit: 10,
428+
};
429+
430+
let hits = store.recall_hybrid(&query, HybridWeights::default()).unwrap();
431+
assert_eq!(hits.len(), 1);
432+
assert_eq!(hits[0].entry.id, entry.id);
433+
assert_eq!(hits[0].entry.raw_excerpt, entry.raw_excerpt);
434+
}
435+
436+
#[test]
437+
fn recall_with_different_queries() {
438+
let store = create_temp_db();
439+
let entry1 = MemoryEntry {
440+
id: "test-1".to_string(),
441+
session_id: "test-session".to_string(),
442+
topic: "fox story".to_string(),
443+
summary: "A story about a fox".to_string(),
444+
raw_excerpt: "The quick brown fox jumps over the lazy dog".to_string(),
445+
keywords: vec!["fox".to_string(), "dog".to_string()],
446+
importance: MemoryImportance::High,
447+
embedding: None,
448+
created_at_epoch_ms: 1234567890,
449+
};
450+
let entry2 = MemoryEntry {
451+
id: "test-2".to_string(),
452+
session_id: "test-session".to_string(),
453+
topic: "cat story".to_string(),
454+
summary: "A story about cats".to_string(),
455+
raw_excerpt: "A completely different story about cats".to_string(),
456+
keywords: vec!["cat".to_string()],
457+
importance: MemoryImportance::Medium,
458+
embedding: None,
459+
created_at_epoch_ms: 1234567891,
460+
};
461+
462+
store.insert(&entry1).unwrap();
463+
store.insert(&entry2).unwrap();
464+
465+
let query = RecallQuery {
466+
session_id: Some("test-session".to_string()),
467+
text: "fox".to_string(),
468+
query_embedding: None,
469+
limit: 10,
470+
};
471+
472+
let hits = store.recall_hybrid(&query, HybridWeights::default()).unwrap();
473+
assert_eq!(hits.len(), 1);
474+
assert_eq!(hits[0].entry.id, "test-1");
475+
}
476+
477+
#[test]
478+
fn vector_disabled_fallback() {
479+
let temp_path =
480+
std::env::temp_dir().join(format!("alice-test-vector-{}.db", std::process::id()));
481+
let store = SqliteMemoryStore::open(&temp_path, 384, false).unwrap();
482+
483+
let entry = MemoryEntry {
484+
id: "test-vector-disabled".to_string(),
485+
session_id: "test-session".to_string(),
486+
topic: "vector test".to_string(),
487+
summary: "test content".to_string(),
488+
raw_excerpt: "test content".to_string(),
489+
keywords: vec!["test".to_string()],
490+
importance: MemoryImportance::Medium,
491+
embedding: Some(vec![0.1, 0.2, 0.3]), // Embedding provided but vector disabled
492+
created_at_epoch_ms: 1234567890,
493+
};
494+
495+
// Should store successfully even with embedding when vector is disabled
496+
store.insert(&entry).unwrap();
497+
498+
// Test recall still works with FTS only
499+
let query = RecallQuery {
500+
session_id: Some("test-session".to_string()),
501+
text: "test".to_string(),
502+
query_embedding: None,
503+
limit: 10,
504+
};
505+
506+
let hits = store.recall_hybrid(&query, HybridWeights::default()).unwrap();
507+
assert_eq!(hits.len(), 1);
508+
assert_eq!(hits[0].entry.id, "test-vector-disabled");
509+
}
510+
}

crates/alice-runtime/src/handle_input.rs

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
33
use bob_runtime::agent_loop::AgentLoopOutput;
44

5-
use crate::{context::AliceRuntimeContext, memory_context::run_turn_with_memory};
5+
use crate::context::AliceRuntimeContext;
66

77
/// Handle a single user input with full pipeline: slash commands, skills, memory.
88
///
99
/// For slash commands: delegates to `AgentLoop` for deterministic handling.
10-
/// For natural language: runs through `run_turn_with_memory` with skill injection.
10+
/// For natural language: uses `AgentLoop::handle_input_with_context` with injected memory and
11+
/// skills.
1112
///
1213
/// # Errors
1314
///
@@ -30,13 +31,73 @@ pub async fn handle_input_with_skills(
3031
Ok(output)
3132
}
3233
bob_runtime::router::RouteResult::NaturalLanguage(_) => {
33-
// NL input: memory + skills + runtime via agent backend
34-
let response = run_turn_with_memory(context, session_id, trimmed).await?;
35-
if response.is_quit {
36-
return Ok(AgentLoopOutput::Quit);
34+
// NL input: inject memory + skills into RequestContext for AgentLoop
35+
let recalled = match context.memory_service().recall_for_turn(session_id, trimmed) {
36+
Ok(hits) => hits,
37+
Err(error) => {
38+
tracing::warn!("memory recall failed: {error}");
39+
Vec::new()
40+
}
41+
};
42+
let memory_prompt =
43+
alice_core::memory::service::MemoryService::render_recall_context(&recalled);
44+
45+
let skills_bundle = context.skill_composer().map(|composer| {
46+
crate::skill_wiring::inject_skills_context(
47+
composer,
48+
trimmed,
49+
context.skill_token_budget(),
50+
)
51+
});
52+
53+
// Compose system prompt: memory + skills
54+
let mut system_parts = Vec::new();
55+
if let Some(ref mem) = memory_prompt {
56+
system_parts.push(mem.as_str());
57+
}
58+
if let Some(ref bundle) = skills_bundle &&
59+
!bundle.prompt.is_empty()
60+
{
61+
system_parts.push(&bundle.prompt);
62+
}
63+
let system_prompt =
64+
if system_parts.is_empty() { None } else { Some(system_parts.join("\n\n")) };
65+
66+
// Build request context with skills metadata
67+
let (selected_skills, tool_policy) = if let Some(ref bundle) = skills_bundle {
68+
let policy = if bundle.selected_allowed_tools.is_empty() {
69+
bob_core::types::RequestToolPolicy::default()
70+
} else {
71+
bob_core::types::RequestToolPolicy {
72+
allow_tools: Some(bundle.selected_allowed_tools.clone()),
73+
..bob_core::types::RequestToolPolicy::default()
74+
}
75+
};
76+
(bundle.selected_skill_names.clone(), policy)
77+
} else {
78+
(Vec::new(), bob_core::types::RequestToolPolicy::default())
79+
};
80+
81+
let request_context =
82+
bob_core::types::RequestContext { system_prompt, selected_skills, tool_policy };
83+
84+
// Use AgentLoop.handle_input_with_context for per-request context injection
85+
let output = context
86+
.agent_loop()
87+
.handle_input_with_context(trimmed, session_id, request_context)
88+
.await?;
89+
90+
// Handle memory persistence (AgentLoop doesn't do this automatically)
91+
if let AgentLoopOutput::Response(response) = &output {
92+
let bob_core::types::AgentRunResult::Finished(finished) = response;
93+
if let Err(error) =
94+
context.memory_service().persist_turn(session_id, trimmed, &finished.content)
95+
{
96+
tracing::warn!("memory persistence failed: {error}");
97+
}
3798
}
38-
// Convert bob_runtime::AgentResponse → AgentLoopOutput
39-
Ok(AgentLoopOutput::CommandOutput(response.content))
99+
100+
Ok(output)
40101
}
41102
}
42103
}

crates/alice-runtime/src/memory_context.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ use crate::context::AliceRuntimeContext;
77

88
/// Execute one turn with memory-aware + skill-augmented prompt context.
99
///
10+
/// This function is primarily used for CLI commands that need direct agent backend access.
11+
/// For interactive chat scenarios, use `handle_input_with_skills` which properly integrates
12+
/// with AgentLoop and supports slash command routing.
13+
///
1014
/// # Errors
1115
///
1216
/// Returns an error if the agent runtime fails to execute the turn.

crates/alice-runtime/src/skill_wiring.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
//! Skill system wiring: bootstrap + per-turn injection.
2+
//!
3+
//! Skills are integrated using Bob 0.3.2's AgentLoop.handle_input_with_context(),
4+
//! which supports per-request system prompt injection via RequestContext.
5+
//! This allows memory and skill context to be injected into each turn without
6+
//! bypassing the AgentLoop's slash command routing and tape recording features.
27
38
use std::path::PathBuf;
49

@@ -34,6 +39,15 @@ pub fn build_skill_composer(cfg: &SkillsConfig) -> eyre::Result<Option<SkillProm
3439
/// Render skill context for a given user input.
3540
///
3641
/// Returns the rendered prompt, selected skill names, and allowed tool list.
42+
/// This context is used with Bob 0.3.2's AgentLoop.handle_input_with_context()
43+
/// to inject per-request system prompts, selected skills, and tool policies.
44+
///
45+
/// # Returns
46+
///
47+
/// A [`RenderedSkillsPrompt`] containing:
48+
/// - `prompt`: Skill-augmented system prompt text
49+
/// - `selected_skill_names`: Names of skills selected for this input
50+
/// - `selected_allowed_tools`: Tool names that should be allowed for this turn
3751
pub fn inject_skills_context(
3852
composer: &SkillPromptComposer,
3953
input: &str,

0 commit comments

Comments
 (0)