use std::collections::HashMap;
use std::sync::Arc;
use crate::capability::{Capability, SystemPromptContext};
use crate::driver::{ChatDriver, ChatRequest, ChatResponse, DeltaSink, ModelSpec};
use crate::error::Result;
use crate::id::SessionId;
use crate::message::{ContentPart, Message, Role, ToolCall};
use crate::tool::{Tool, ToolContext, ToolDefinition, ToolOutput};
pub struct AssembledTurn {
pub system_prompt: Option<String>,
pub tool_definitions: Vec<ToolDefinition>,
tools: HashMap<String, Arc<dyn Tool>>,
}
impl AssembledTurn {
pub fn tool(&self, name: &str) -> Option<&Arc<dyn Tool>> {
self.tools.get(name)
}
}
pub async fn assemble(
base_system_prompt: &str,
capabilities: &[Arc<dyn Capability>],
session_id: SessionId,
) -> Result<AssembledTurn> {
use crate::tool::DeferrablePolicy;
let mut tools = HashMap::new();
let mut tool_definitions = Vec::new();
for capability in capabilities {
for tool in capability.tools().await? {
let definition = tool.definition();
if tool.policy().deferrable == DeferrablePolicy::Never {
tool_definitions.push(definition.clone());
}
tools.insert(definition.name.clone(), tool);
}
}
let context = SystemPromptContext { session_id };
let mut parts: Vec<String> = Vec::new();
if !base_system_prompt.is_empty() {
parts.push(base_system_prompt.to_string());
}
for capability in capabilities {
if let Some(contribution) = capability.system_prompt_contribution(&context).await {
parts.push(contribution);
}
}
let system_prompt = if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
};
Ok(AssembledTurn {
system_prompt,
tool_definitions,
tools,
})
}
pub fn chat_request(
model: &ModelSpec,
assembled: &AssembledTurn,
mut messages: Vec<Message>,
) -> ChatRequest {
for message in &mut messages {
let Some(actor) = message.external_actor.as_ref() else {
continue;
};
if message.role != Role::User {
continue;
}
let prefix = format!("[{}] ", actor.display_label());
match message
.content
.iter_mut()
.find(|part| matches!(part, ContentPart::Text(_)))
{
Some(ContentPart::Text(part)) => part.text.insert_str(0, &prefix),
_ => message.content.insert(0, ContentPart::text(prefix)),
}
}
ChatRequest {
model: model.clone(),
system_prompt: assembled.system_prompt.clone(),
messages,
tools: assembled.tool_definitions.clone(),
}
}
pub async fn reason(
driver: &dyn ChatDriver,
model: &ModelSpec,
assembled: &AssembledTurn,
messages: Vec<Message>,
) -> Result<ChatResponse> {
driver
.complete(chat_request(model, assembled, messages))
.await
}
pub async fn reason_streaming(
driver: &dyn ChatDriver,
model: &ModelSpec,
assembled: &AssembledTurn,
messages: Vec<Message>,
sink: &mut dyn DeltaSink,
) -> Result<ChatResponse> {
driver
.complete_streaming(chat_request(model, assembled, messages), sink)
.await
}
pub async fn act(assembled: &AssembledTurn, call: &ToolCall, context: &ToolContext) -> ToolOutput {
match assembled.tool(&call.name) {
Some(tool) => tool.execute(call.arguments.clone(), context).await,
None => ToolOutput::error(format!("unknown tool `{}`", call.name)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::{DeferrablePolicy, ToolPolicy};
use serde_json::json;
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::pin::pin;
use std::task::{Context, Poll, Waker};
let mut future = pin!(future);
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
loop {
if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
return output;
}
}
}
struct FixedTool {
name: &'static str,
policy: ToolPolicy,
}
#[async_trait::async_trait]
impl Tool for FixedTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition::new(self.name, "", json!({"type": "object"}))
}
async fn execute(
&self,
_arguments: serde_json::Value,
_context: &ToolContext,
) -> ToolOutput {
ToolOutput::text(self.name)
}
fn policy(&self) -> ToolPolicy {
self.policy
}
}
struct FixedCapability {
tools: Vec<Arc<dyn Tool>>,
}
#[async_trait::async_trait]
impl Capability for FixedCapability {
fn id(&self) -> &str {
"fixed"
}
async fn tools(&self) -> Result<Vec<Arc<dyn Tool>>> {
Ok(self.tools.clone())
}
}
#[test]
fn deferred_tools_are_executable_but_not_offered_to_the_model() {
let capability: Arc<dyn Capability> = Arc::new(FixedCapability {
tools: vec![
Arc::new(FixedTool {
name: "visible",
policy: ToolPolicy::default(),
}),
Arc::new(FixedTool {
name: "hidden",
policy: ToolPolicy {
deferrable: DeferrablePolicy::Deferred,
},
}),
],
});
let assembled = block_on(assemble("", &[capability], SessionId::new())).unwrap();
let offered: Vec<&str> = assembled
.tool_definitions
.iter()
.map(|d| d.name.as_str())
.collect();
assert_eq!(offered, vec!["visible"]);
assert!(assembled.tool("hidden").is_some());
assert!(assembled.tool("visible").is_some());
}
#[test]
fn chat_request_labels_external_user_without_mutating_history() {
let assembled = AssembledTurn {
system_prompt: None,
tool_definitions: Vec::new(),
tools: HashMap::new(),
};
let original = Message::user("hello")
.with_external_actor(crate::message::ExternalActor::new("slack", "U123").name("Alice"));
let request = chat_request(&ModelSpec::llmsim(), &assembled, vec![original.clone()]);
assert_eq!(request.messages[0].text(), "[Alice] hello");
assert_eq!(original.text(), "hello");
assert_eq!(request.messages[0].external_actor, original.external_actor);
}
}