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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ironclaw_host_runtime/src/memory_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ fn map_search_result_to_snippet(result: MemorySearchResult) -> Option<LoopContex
Some(LoopContextSnippet {
snippet_ref,
safe_summary,
metadata: None,
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/ironclaw_loop_support/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ publish = false
async-trait = "0.1"
tracing = "0.1"
tokio = { version = "1", features = ["sync"] }
ironclaw_skills = { path = "../ironclaw_skills", version = "0.3.0", default-features = false }
ironclaw_threads = { path = "../ironclaw_threads", version = "0.1.0" }
ironclaw_turns = { path = "../ironclaw_turns", version = "0.1.0" }
serde = { version = "1", features = ["derive"] }
Expand Down
81 changes: 79 additions & 2 deletions crates/ironclaw_loop_support/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ use std::{
sync::Arc,
};

mod skill_context;

pub use skill_context::{
HostSkillContextBuildError, HostSkillContextCandidate, HostSkillContextSource,
build_skill_run_snapshot,
};

use tokio::sync::Mutex;

use async_trait::async_trait;
Expand Down Expand Up @@ -46,6 +53,7 @@ where
thread_scope: ThreadScope,
run_context: LoopRunContext,
max_messages: usize,
skill_context_source: Option<Arc<dyn HostSkillContextSource>>,
}

impl<S> ThreadBackedLoopContextPort<S>
Expand All @@ -63,8 +71,14 @@ where
thread_scope,
run_context,
max_messages,
skill_context_source: None,
}
}

pub fn with_skill_context_source(mut self, source: Arc<dyn HostSkillContextSource>) -> Self {
self.skill_context_source = Some(source);
self
}
}

impl<S> LoopRunInfoPort for ThreadBackedLoopContextPort<S>
Expand Down Expand Up @@ -98,13 +112,21 @@ where
.await
.map_err(context_read_error)?;

let instruction_snippets = match self.skill_context_source.as_deref() {
Some(source) => {
skill_context::build_skill_instruction_snippets(source, &self.run_context).await?
}
None => Vec::new(),
};

Ok(LoopContextBundle {
identity_messages: Vec::new(),
messages: context
.messages
.into_iter()
.filter_map(context_message_to_loop_message)
.collect(),
instruction_snippets: Vec::new(),
instruction_snippets,
memory_snippets: Vec::new(),
})
}
Expand Down Expand Up @@ -426,6 +448,7 @@ where
gateway: Arc<G>,
max_messages: usize,
milestone_sink: Option<Arc<dyn LoopHostMilestoneSink>>,
skill_context_source: Option<Arc<dyn HostSkillContextSource>>,
}

impl<S, G> ThreadBackedLoopModelPort<S, G>
Expand All @@ -447,6 +470,7 @@ where
gateway,
max_messages,
milestone_sink: None,
skill_context_source: None,
}
}

Expand All @@ -465,8 +489,14 @@ where
gateway,
max_messages,
milestone_sink: Some(milestone_sink),
skill_context_source: None,
}
}

pub fn with_skill_context_source(mut self, source: Arc<dyn HostSkillContextSource>) -> Self {
self.skill_context_source = Some(source);
self
}
}

impl<S, G> LoopRunInfoPort for ThreadBackedLoopModelPort<S, G>
Expand Down Expand Up @@ -591,6 +621,14 @@ where
let needs_history_lookup = requested_messages
.iter()
.any(|message| !messages_by_ref.contains_key(message.content_ref.as_str()));
let snippet_messages_by_ref = if requested_messages
.iter()
.any(|message| skill_context::is_snippet_model_message_ref(&message.content_ref))
{
self.instruction_snippet_messages_by_ref().await?
} else {
HashMap::new()
};
if needs_history_lookup {
let history = self
.thread_service
Expand All @@ -605,6 +643,19 @@ where
}
let mut resolved = Vec::with_capacity(requested_messages.len());
for message in requested_messages {
let requested_role = HostManagedModelMessageRole::from_loop_role(&message.role)?;
if let Some(snippet_message) = snippet_messages_by_ref.get(message.content_ref.as_str())
{
if requested_role != snippet_message.role {
return Err(AgentLoopHostError::new(
AgentLoopHostErrorKind::InvalidInvocation,
"model message role does not match skill context snippet",
));
}
resolved.push(snippet_message.clone());
continue;
}

let context_message = messages_by_ref
.get(message.content_ref.as_str())
.ok_or_else(|| {
Expand All @@ -613,7 +664,6 @@ where
"model message reference is unavailable",
)
})?;
let requested_role = HostManagedModelMessageRole::from_loop_role(&message.role)?;
let durable_role = model_role_for_kind(context_message.kind);
if requested_role != durable_role {
return Err(AgentLoopHostError::new(
Expand All @@ -629,6 +679,33 @@ where
}
Ok(resolved)
}

async fn instruction_snippet_messages_by_ref(
&self,
) -> Result<HashMap<String, HostManagedModelMessage>, AgentLoopHostError> {
let Some(source) = self.skill_context_source.as_deref() else {
return Ok(HashMap::new());
};
let snippets =
skill_context::build_skill_instruction_snippets(source, &self.run_context).await?;
let mut messages = HashMap::with_capacity(snippets.len());
for (ordinal, snippet) in snippets.into_iter().enumerate() {
let content_ref = skill_context::snippet_model_message_ref(
&snippet.snippet_ref,
&snippet.safe_summary,
ordinal,
)?;
messages.insert(
content_ref.as_str().to_string(),
HostManagedModelMessage {
role: HostManagedModelMessageRole::System,
content: snippet.safe_summary,
content_ref,
},
);
}
Ok(messages)
}
}

/// Host-managed text-only model gateway. Implementations own provider selection,
Expand Down
Loading
Loading