Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/adapters/core/src/channels/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ struct ChannelRuntimeContext {
event_tx: Option<tokio::sync::broadcast::Sender<serde_json::Value>>,
/// Current conversation context for tools.
conversation_context: Option<Arc<dyn synapse_domain::ports::conversation_context::ConversationContextPort>>,
/// Dialogue state store for session-scoped working memory.
dialogue_state_store: Option<Arc<synapse_domain::application::services::dialogue_state_service::DialogueStateStore>>,
show_tool_calls: bool,
session_store: Option<Arc<dyn LocalSessionBackend>>,
summary_config: Arc<synapse_domain::config::schema::SummaryConfig>,
Expand Down Expand Up @@ -1460,6 +1462,7 @@ async fn handle_message_via_orchestrator(
memory: memory_port,
event_tx: ctx.event_tx.clone(),
conversation_context: ctx.conversation_context.clone(),
dialogue_state_store: ctx.dialogue_state_store.clone(),
};

// ── Phase 4.1: Check if message should trigger a pipeline ─────
Expand Down Expand Up @@ -3479,6 +3482,9 @@ pub async fn start_channels(
prompt_budget_config: config.memory.prompt_budget.clone(),
event_tx,
conversation_context: Some(shared_conversation_context.clone()),
dialogue_state_store: Some(Arc::new(
synapse_domain::application::services::dialogue_state_service::DialogueStateStore::new(),
)),
show_tool_calls: config.channels_config.show_tool_calls,
session_store: if config.channels_config.session_persistence {
if let Some(ref db) = shared_surreal {
Expand Down Expand Up @@ -3993,6 +3999,7 @@ mod tests {
prompt_budget_config: synapse_domain::config::schema::PromptBudgetConfig::default(),
event_tx: None,
conversation_context: None,
dialogue_state_store: None,
show_tool_calls: true,
session_store: None,
summary_config: Arc::new(synapse_domain::config::schema::SummaryConfig::default()),
Expand Down Expand Up @@ -4099,6 +4106,7 @@ mod tests {
prompt_budget_config: synapse_domain::config::schema::PromptBudgetConfig::default(),
event_tx: None,
conversation_context: None,
dialogue_state_store: None,
show_tool_calls: true,
session_store: None,
summary_config: Arc::new(synapse_domain::config::schema::SummaryConfig::default()),
Expand Down Expand Up @@ -4213,6 +4221,7 @@ mod tests {
prompt_budget_config: synapse_domain::config::schema::PromptBudgetConfig::default(),
event_tx: None,
conversation_context: None,
dialogue_state_store: None,
show_tool_calls: true,
session_store: None,
summary_config: Arc::new(synapse_domain::config::schema::SummaryConfig::default()),
Expand Down Expand Up @@ -4338,6 +4347,7 @@ mod tests {
prompt_budget_config: synapse_domain::config::schema::PromptBudgetConfig::default(),
event_tx: None,
conversation_context: None,
dialogue_state_store: None,
show_tool_calls: true,
session_store: None,
summary_config: Arc::new(synapse_domain::config::schema::SummaryConfig::default()),
Expand Down
262 changes: 262 additions & 0 deletions crates/domain/src/application/services/dialogue_state_service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
//! Dialogue state service — updates session-scoped working memory.
//!
//! Extracts focus entities, comparison sets, and slots from user messages
//! and tool results. Mostly deterministic (regex/pattern), no LLM calls.

use crate::domain::dialogue_state::{DialogueSlot, DialogueState, FocusEntity};
use parking_lot::RwLock;
use std::collections::HashMap;

/// TTL for dialogue state entries (30 minutes).
const STATE_TTL_SECS: u64 = 1800;

/// In-memory store for dialogue state, keyed by conversation_ref.
pub struct DialogueStateStore {
states: RwLock<HashMap<String, DialogueState>>,
}

impl DialogueStateStore {
pub fn new() -> Self {
Self {
states: RwLock::new(HashMap::new()),
}
}

/// Get current state for a conversation (None if absent or stale).
pub fn get(&self, conversation_ref: &str) -> Option<DialogueState> {
let states = self.states.read();
states.get(conversation_ref).and_then(|s| {
if s.is_stale(STATE_TTL_SECS) {
None
} else {
Some(s.clone())
}
})
}

/// Update state for a conversation.
pub fn set(&self, conversation_ref: &str, state: DialogueState) {
let mut states = self.states.write();
states.insert(conversation_ref.to_string(), state);
}

/// Evict stale entries (call periodically).
pub fn evict_stale(&self) {
let mut states = self.states.write();
states.retain(|_, s| !s.is_stale(STATE_TTL_SECS));
}
}

impl Default for DialogueStateStore {
fn default() -> Self {
Self::new()
}
}

/// Update dialogue state after a user turn.
///
/// Extracts focus entities and comparison patterns from the user message
/// and tool output summary. Deterministic — no LLM call.
pub fn update_state_from_turn(
state: &mut DialogueState,
user_message: &str,
tool_summary: &str,
assistant_response: &str,
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
state.updated_at = now;

// Extract entities from tool results (most reliable source)
if !tool_summary.is_empty() {
state.last_tool_subjects = extract_tool_subjects(tool_summary);
}

// Extract focus entities from user message + response
let new_entities = extract_entities(user_message, assistant_response);
if !new_entities.is_empty() {
// Detect comparison pattern: if 2+ entities of same kind mentioned together
let kinds: Vec<&str> = new_entities.iter().map(|e| e.kind.as_str()).collect();
let has_comparison = kinds.len() >= 2
&& kinds.windows(2).any(|w| w[0] == w[1]);

if has_comparison {
state.comparison_set = new_entities.clone();
state.focus_entities = new_entities;
} else {
// Replace focus (new topic supersedes old)
state.focus_entities = new_entities;
state.comparison_set.clear();
}
}

// Extract slots from user message
extract_slots(user_message, &mut state.slots);
}

/// Extract entity-like mentions from message text.
fn extract_entities(user_message: &str, assistant_response: &str) -> Vec<FocusEntity> {
let mut entities = Vec::new();
let combined = format!("{user_message} {assistant_response}").to_lowercase();

// Service patterns: "X.service", "service X", "restart X"
for word in combined.split_whitespace() {
if word.ends_with(".service") {
let name = word.trim_end_matches(".service");
if !name.is_empty() {
entities.push(FocusEntity {
kind: "service".into(),
name: name.to_string(),
metadata: None,
});
}
}
}

// City/location detection via common weather/location patterns
let location_triggers = ["weather in ", "weather for ", "temperature in ", "located in "];
let stop_words = ["is", "are", "was", "or", "the", "today", "tomorrow", "now"];
for trigger in &location_triggers {
// Find ALL occurrences of this trigger
let mut search_from = 0;
while let Some(pos) = combined[search_from..].find(trigger) {
let abs_pos = search_from + pos;
let after = &combined[abs_pos + trigger.len()..];
// Take words until a stop word or punctuation
let words: Vec<&str> = after
.split_whitespace()
.take_while(|w| !stop_words.contains(w) && !w.contains(',') && !w.contains('.'))
.take(3)
.collect();
// Split on "and" to handle "Berlin and Tbilisi"
let raw = words.join(" ");
for part in raw.split(" and ") {
let city = part.trim().to_string();
if !city.is_empty() && city.len() < 50 {
entities.push(FocusEntity {
kind: "city".into(),
name: city,
metadata: None,
});
}
}
search_from = abs_pos + trigger.len() + 1;
if search_from >= combined.len() { break; }
}
}

// Environment patterns: "staging", "production", "prod", "dev"
let envs = ["staging", "production", "prod", "dev", "development"];
for env in &envs {
if combined.contains(env) {
entities.push(FocusEntity {
kind: "environment".into(),
name: env.to_string(),
metadata: None,
});
}
}

entities
}

/// Extract named slots from user message.
fn extract_slots(message: &str, slots: &mut Vec<DialogueSlot>) {
let lower = message.to_lowercase();

// Timezone slot
if lower.contains("utc") || lower.contains("timezone") {
if let Some(tz) = extract_timezone(&lower) {
upsert_slot(slots, "timezone", &tz);
}
}
}

fn extract_timezone(text: &str) -> Option<String> {
// Match "UTC+3", "UTC-5", "UTC+03:00"
if let Some(pos) = text.find("utc") {
let after = &text[pos..];
let tz: String = after
.chars()
.take(10)
.take_while(|c| c.is_alphanumeric() || *c == '+' || *c == '-' || *c == ':')
.collect();
if tz.len() > 3 {
return Some(tz.to_uppercase());
}
}
None
}

fn upsert_slot(slots: &mut Vec<DialogueSlot>, name: &str, value: &str) {
if let Some(slot) = slots.iter_mut().find(|s| s.name == name) {
slot.value = value.to_string();
} else {
slots.push(DialogueSlot {
name: name.to_string(),
value: value.to_string(),
});
}
}

fn extract_tool_subjects(summary: &str) -> Vec<String> {
// Parse "[Used tools: X, Y]" format
summary
.trim_start_matches("[Used tools: ")
.trim_end_matches(']')
.split(", ")
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn extract_service_entity() {
let entities = extract_entities("restart synapseclaw.service", "");
assert!(entities.iter().any(|e| e.kind == "service" && e.name == "synapseclaw"));
}

#[test]
fn extract_city_from_weather() {
let entities = extract_entities("", "The weather in Berlin is rainy");
assert!(entities.iter().any(|e| e.kind == "city" && e.name == "berlin"));
}

#[test]
fn detect_comparison_set() {
let mut state = DialogueState::default();
update_state_from_turn(
&mut state,
"compare weather in Berlin and Tbilisi",
"",
"Weather in Berlin: 12C. Weather in Tbilisi: 25C.",
);
assert!(state.has_comparison());
assert!(state.comparison_set.len() >= 2, "expected 2+ cities, got {}", state.comparison_set.len());
}

#[test]
fn store_get_set() {
let store = DialogueStateStore::new();
let mut state = DialogueState::default();
state.focus_entities.push(FocusEntity {
kind: "city".into(),
name: "Moscow".into(),
metadata: None,
});
state.updated_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
store.set("conv1", state);
let loaded = store.get("conv1");
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().focus_entities[0].name, "Moscow");
}
}
1 change: 1 addition & 0 deletions crates/domain/src/application/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod bootstrap_core_memory;
pub mod channel_presentation;
pub mod conversation_service;
pub mod delivery_service;
pub mod dialogue_state_service;
pub mod history_compaction;
pub mod inbound_message_service;
pub mod ipc_service;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ pub struct InboundMessagePorts {
pub event_tx: Option<tokio::sync::broadcast::Sender<serde_json::Value>>,
/// Current conversation context for tools that need "here".
pub conversation_context: Option<Arc<dyn crate::ports::conversation_context::ConversationContextPort>>,
/// Dialogue state store for session-scoped working memory.
pub dialogue_state_store: Option<Arc<crate::application::services::dialogue_state_service::DialogueStateStore>>,
}

/// Result of handling an inbound message.
Expand Down Expand Up @@ -612,6 +614,18 @@ async fn execute_agent_turn(
.history
.append_turn(conversation_key, ChatMessage::assistant(&history_response));

// ── Update dialogue state (session-scoped working memory) ──
if let Some(ref store) = ports.dialogue_state_store {
let mut state = store.get(conversation_key).unwrap_or_default();
crate::application::services::dialogue_state_service::update_state_from_turn(
&mut state,
content,
&turn_result.tool_summary,
&response_text,
);
store.set(conversation_key, state);
}

// ── #18/#19: Post-turn learning (via orchestrator, fire-and-forget) ──
if let Some(ref mem) = ports.memory {
let tools: Vec<String> = turn_result
Expand Down Expand Up @@ -992,6 +1006,7 @@ mod tests {
memory: None,
event_tx: None,
conversation_context: None,
dialogue_state_store: None,
}
}

Expand Down
Loading
Loading