ACP Wrap up
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxy_agent_core::{
|
||||
AgentEvent, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities, StopReason, Usage,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event::stream_finished;
|
||||
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
|
||||
|
||||
use crate::ai::agent::runtime_activity;
|
||||
use crate::ai::bedrock::response_translator::{
|
||||
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
|
||||
build_user_query_message,
|
||||
};
|
||||
use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage};
|
||||
|
||||
pub(crate) struct RuntimeResponseConfig {
|
||||
pub(crate) task_id: String,
|
||||
pub(crate) conversation_id: String,
|
||||
pub(crate) needs_create_task: bool,
|
||||
pub(crate) user_query: Option<String>,
|
||||
pub(crate) model_id: String,
|
||||
pub(crate) max_context_tokens: Option<u32>,
|
||||
pub(crate) capabilities: RuntimeCapabilities,
|
||||
pub(crate) empty_output_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Converts the provider-neutral runtime lifecycle into Galaxy's existing
|
||||
/// transcript protocol. Runtime implementations never need to know about the
|
||||
/// protobuf messages consumed by the UI.
|
||||
pub(crate) struct RuntimeResponseTranslator {
|
||||
config: RuntimeResponseConfig,
|
||||
request_id: String,
|
||||
initialized: bool,
|
||||
text_message_id: Option<String>,
|
||||
reasoning_message_id: Option<String>,
|
||||
activity_message_ids: HashMap<String, String>,
|
||||
activities: HashMap<String, RuntimeActivity>,
|
||||
has_visible_output: bool,
|
||||
usage: Usage,
|
||||
context_usage: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
impl RuntimeResponseTranslator {
|
||||
pub(crate) fn new(config: RuntimeResponseConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
request_id: Uuid::new_v4().to_string(),
|
||||
initialized: false,
|
||||
text_message_id: None,
|
||||
reasoning_message_id: None,
|
||||
activity_message_ids: HashMap::new(),
|
||||
activities: HashMap::new(),
|
||||
has_visible_output: false,
|
||||
usage: Usage::default(),
|
||||
context_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn translate(&mut self, event: AgentEvent) -> Result<Vec<ResponseEvent>, String> {
|
||||
let mut events = Vec::new();
|
||||
match event {
|
||||
AgentEvent::TurnStarted { .. } => self.initialize(&mut events),
|
||||
AgentEvent::TextDelta { text } => {
|
||||
self.initialize(&mut events);
|
||||
self.add_or_append_text(&text, &mut events);
|
||||
}
|
||||
AgentEvent::ReasoningDelta { text } => {
|
||||
self.initialize(&mut events);
|
||||
self.add_or_append_reasoning(&text, &mut events);
|
||||
}
|
||||
AgentEvent::ReasoningCompleted { text, .. } => {
|
||||
self.initialize(&mut events);
|
||||
if self.reasoning_message_id.is_none() && !text.is_empty() {
|
||||
self.add_or_append_reasoning(&text, &mut events);
|
||||
}
|
||||
}
|
||||
AgentEvent::RuntimeActivityUpdated { activity } => {
|
||||
if self.config.capabilities.host_tool_execution {
|
||||
return Err(
|
||||
"a host-tool runtime emitted runtime-owned tool activity".to_owned()
|
||||
);
|
||||
}
|
||||
self.initialize(&mut events);
|
||||
self.text_message_id = None;
|
||||
self.upsert_runtime_activity(activity, &mut events)?;
|
||||
}
|
||||
AgentEvent::ContextUsageUpdated {
|
||||
used_tokens,
|
||||
context_size,
|
||||
} => {
|
||||
if self.config.capabilities.host_managed_history {
|
||||
return Err(
|
||||
"a host-history runtime reported session context occupancy".to_owned()
|
||||
);
|
||||
}
|
||||
self.context_usage = Some((used_tokens, context_size));
|
||||
}
|
||||
AgentEvent::UserInputAccepted { text } => {
|
||||
if !self.config.capabilities.steering {
|
||||
return Err("a non-steerable runtime accepted live user input".to_owned());
|
||||
}
|
||||
self.initialize(&mut events);
|
||||
events.push(build_user_query_message(&self.config.task_id, &text));
|
||||
self.text_message_id = None;
|
||||
self.reasoning_message_id = None;
|
||||
}
|
||||
AgentEvent::RuntimeNotice { message } => {
|
||||
self.initialize(&mut events);
|
||||
self.text_message_id = None;
|
||||
self.add_or_append_text(&message, &mut events);
|
||||
self.text_message_id = None;
|
||||
}
|
||||
AgentEvent::UsageUpdated { usage } => {
|
||||
if !self.config.capabilities.host_managed_history {
|
||||
return Err("a session runtime reported provider request usage".to_owned());
|
||||
}
|
||||
self.usage = usage;
|
||||
}
|
||||
AgentEvent::TurnStopped { reason } => {
|
||||
self.initialize(&mut events);
|
||||
if !self.has_visible_output && reason != StopReason::Cancelled {
|
||||
if let Some(message) = self.config.empty_output_message.clone() {
|
||||
self.add_or_append_text(&message, &mut events);
|
||||
}
|
||||
}
|
||||
events.push(self.finished(reason));
|
||||
}
|
||||
AgentEvent::Tool { .. } => {
|
||||
let owner = if self.config.capabilities.host_tool_execution {
|
||||
"host-tool runtime emitted an unhandled tool lifecycle event"
|
||||
} else {
|
||||
"session runtime handed runtime-owned tool execution to Galaxy"
|
||||
};
|
||||
return Err(owner.to_owned());
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub(crate) fn startup_error(&mut self, message: &str) -> Vec<ResponseEvent> {
|
||||
let mut events = Vec::new();
|
||||
self.initialize(&mut events);
|
||||
self.text_message_id = None;
|
||||
self.add_or_append_text(message, &mut events);
|
||||
events.push(self.finished(StopReason::Refusal));
|
||||
events
|
||||
}
|
||||
|
||||
fn initialize(&mut self, events: &mut Vec<ResponseEvent>) {
|
||||
if self.initialized {
|
||||
return;
|
||||
}
|
||||
events.push(build_stream_init(
|
||||
&self.request_id,
|
||||
&self.config.conversation_id,
|
||||
));
|
||||
if self.config.needs_create_task {
|
||||
events.push(build_create_task(&self.config.task_id));
|
||||
}
|
||||
if let Some(user_query) = &self.config.user_query {
|
||||
events.push(build_user_query_message(&self.config.task_id, user_query));
|
||||
}
|
||||
self.initialized = true;
|
||||
}
|
||||
|
||||
fn add_or_append_text(&mut self, text: &str, events: &mut Vec<ResponseEvent>) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.has_visible_output = true;
|
||||
if let Some(message_id) = &self.text_message_id {
|
||||
events.push(build_append_text(&self.config.task_id, message_id, text));
|
||||
} else {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
events.push(build_add_agent_output_message(
|
||||
&self.config.task_id,
|
||||
&message_id,
|
||||
text,
|
||||
));
|
||||
self.text_message_id = Some(message_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_or_append_reasoning(&mut self, text: &str, events: &mut Vec<ResponseEvent>) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.has_visible_output = true;
|
||||
if let Some(message_id) = &self.reasoning_message_id {
|
||||
events.push(build_reasoning_message(
|
||||
&self.config.task_id,
|
||||
message_id,
|
||||
text,
|
||||
true,
|
||||
));
|
||||
} else {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
events.push(build_reasoning_message(
|
||||
&self.config.task_id,
|
||||
&message_id,
|
||||
text,
|
||||
false,
|
||||
));
|
||||
self.reasoning_message_id = Some(message_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_runtime_activity(
|
||||
&mut self,
|
||||
activity: RuntimeActivity,
|
||||
events: &mut Vec<ResponseEvent>,
|
||||
) -> Result<(), String> {
|
||||
let activity_id = activity.id.clone();
|
||||
let merged_activity = self
|
||||
.activities
|
||||
.entry(activity_id.clone())
|
||||
.or_insert_with(|| activity.clone());
|
||||
if !activity.title.trim().is_empty() {
|
||||
merged_activity.title = activity.title;
|
||||
}
|
||||
if activity.status.is_some() {
|
||||
merged_activity.status = activity.status;
|
||||
}
|
||||
if activity.output.is_some() {
|
||||
merged_activity.output = activity.output;
|
||||
}
|
||||
|
||||
let server_message_data = runtime_activity::encode(merged_activity)
|
||||
.map_err(|error| format!("failed to encode runtime activity: {error}"))?;
|
||||
let fallback_text = runtime_activity_fallback_text(merged_activity);
|
||||
if let Some(message_id) = self.activity_message_ids.get(&activity_id) {
|
||||
events.push(build_update_runtime_activity_message(
|
||||
&self.config.task_id,
|
||||
message_id,
|
||||
&fallback_text,
|
||||
&server_message_data,
|
||||
));
|
||||
} else {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
events.push(build_add_runtime_activity_message(
|
||||
&self.config.task_id,
|
||||
&message_id,
|
||||
&fallback_text,
|
||||
&server_message_data,
|
||||
));
|
||||
self.activity_message_ids.insert(activity_id, message_id);
|
||||
}
|
||||
self.has_visible_output = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finished(&self, reason: StopReason) -> ResponseEvent {
|
||||
let reason = map_stop_reason(reason);
|
||||
if !self.config.capabilities.host_managed_history {
|
||||
let (used_tokens, context_size) = self.context_usage.unwrap_or_default();
|
||||
return build_context_finished(
|
||||
reason,
|
||||
&self.config.model_id,
|
||||
used_tokens,
|
||||
context_size,
|
||||
);
|
||||
}
|
||||
build_stream_finished(
|
||||
reason,
|
||||
StreamUsage {
|
||||
input_tokens: saturating_i32(self.usage.input_tokens),
|
||||
output_tokens: saturating_i32(self.usage.output_tokens),
|
||||
cache_read_tokens: saturating_i32(self.usage.cached_input_tokens),
|
||||
cache_write_tokens: saturating_i32(self.usage.cache_creation_input_tokens),
|
||||
cost_in_cents: 0.0,
|
||||
model_id: self.config.model_id.clone(),
|
||||
max_context_tokens: self.config.max_context_tokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_reasoning_message(
|
||||
task_id: &str,
|
||||
message_id: &str,
|
||||
text: &str,
|
||||
append: bool,
|
||||
) -> ResponseEvent {
|
||||
let message = api::Message {
|
||||
id: message_id.to_owned(),
|
||||
task_id: task_id.to_owned(),
|
||||
request_id: String::new(),
|
||||
timestamp: None,
|
||||
server_message_data: String::new(),
|
||||
citations: Vec::new(),
|
||||
fetched_memories: Vec::new(),
|
||||
message: Some(api::message::Message::AgentReasoning(
|
||||
api::message::AgentReasoning {
|
||||
reasoning: text.to_owned(),
|
||||
finished_duration: None,
|
||||
},
|
||||
)),
|
||||
};
|
||||
let action = if append {
|
||||
api::client_action::Action::AppendToMessageContent(
|
||||
api::client_action::AppendToMessageContent {
|
||||
task_id: task_id.to_owned(),
|
||||
message: Some(message),
|
||||
mask: Some(prost_types::FieldMask {
|
||||
paths: vec!["agent_reasoning.reasoning".to_owned()],
|
||||
}),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
api::client_action::Action::AddMessagesToTask(api::client_action::AddMessagesToTask {
|
||||
task_id: task_id.to_owned(),
|
||||
messages: vec![message],
|
||||
})
|
||||
};
|
||||
runtime_client_action(action)
|
||||
}
|
||||
|
||||
fn runtime_activity_fallback_text(activity: &RuntimeActivity) -> String {
|
||||
let title = &activity.title;
|
||||
let status = activity.status.as_ref().map(|status| match status {
|
||||
RuntimeActivityStatus::Pending => "waiting",
|
||||
RuntimeActivityStatus::InProgress => "running",
|
||||
RuntimeActivityStatus::Completed => "completed",
|
||||
RuntimeActivityStatus::Failed => "failed",
|
||||
RuntimeActivityStatus::Other(_) => "updated",
|
||||
});
|
||||
let mut text = match status {
|
||||
Some(status) => format!("> **{title}** — {status}"),
|
||||
None => format!("> **{title}**"),
|
||||
};
|
||||
if let Some(output) = &activity.output {
|
||||
text.push_str("\n\n");
|
||||
for line in output.lines() {
|
||||
text.push_str(" ");
|
||||
text.push_str(line);
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn build_add_runtime_activity_message(
|
||||
task_id: &str,
|
||||
message_id: &str,
|
||||
fallback_text: &str,
|
||||
server_message_data: &str,
|
||||
) -> ResponseEvent {
|
||||
let message = runtime_activity_message(task_id, message_id, fallback_text, server_message_data);
|
||||
runtime_client_action(api::client_action::Action::AddMessagesToTask(
|
||||
api::client_action::AddMessagesToTask {
|
||||
task_id: task_id.to_owned(),
|
||||
messages: vec![message],
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn build_update_runtime_activity_message(
|
||||
task_id: &str,
|
||||
message_id: &str,
|
||||
fallback_text: &str,
|
||||
server_message_data: &str,
|
||||
) -> ResponseEvent {
|
||||
let message = runtime_activity_message(task_id, message_id, fallback_text, server_message_data);
|
||||
runtime_client_action(api::client_action::Action::UpdateTaskMessage(
|
||||
api::client_action::UpdateTaskMessage {
|
||||
task_id: task_id.to_owned(),
|
||||
message: Some(message),
|
||||
mask: Some(prost_types::FieldMask {
|
||||
paths: vec![
|
||||
"agent_output.text".to_owned(),
|
||||
"server_message_data".to_owned(),
|
||||
],
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_activity_message(
|
||||
task_id: &str,
|
||||
message_id: &str,
|
||||
fallback_text: &str,
|
||||
server_message_data: &str,
|
||||
) -> api::Message {
|
||||
api::Message {
|
||||
id: message_id.to_owned(),
|
||||
task_id: task_id.to_owned(),
|
||||
request_id: String::new(),
|
||||
timestamp: None,
|
||||
server_message_data: server_message_data.to_owned(),
|
||||
citations: Vec::new(),
|
||||
fetched_memories: Vec::new(),
|
||||
message: Some(api::message::Message::AgentOutput(
|
||||
api::message::AgentOutput {
|
||||
text: fallback_text.to_owned(),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_client_action(action: api::client_action::Action) -> ResponseEvent {
|
||||
ResponseEvent {
|
||||
r#type: Some(api::response_event::Type::ClientActions(
|
||||
api::response_event::ClientActions {
|
||||
actions: vec![ClientAction {
|
||||
action: Some(action),
|
||||
}],
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_context_finished(
|
||||
reason: stream_finished::Reason,
|
||||
model_id: &str,
|
||||
used_tokens: u64,
|
||||
context_size: u64,
|
||||
) -> ResponseEvent {
|
||||
let total_input_tokens = u32::try_from(used_tokens).unwrap_or(u32::MAX);
|
||||
let context_window_usage = if context_size == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(used_tokens as f32 / context_size as f32).clamp(0.0, 1.0)
|
||||
};
|
||||
#[allow(deprecated)]
|
||||
let usage_metadata = stream_finished::ConversationUsageMetadata {
|
||||
context_window_usage,
|
||||
summarized: false,
|
||||
credits_spent: 0.0,
|
||||
platform_credits_spent: 0.0,
|
||||
total_input_tokens,
|
||||
token_usage: Vec::new(),
|
||||
tool_usage_metadata: None,
|
||||
warp_token_usage: HashMap::new(),
|
||||
byok_token_usage: HashMap::new(),
|
||||
custom_endpoint_token_usage: HashMap::new(),
|
||||
context_window_segments: Vec::new(),
|
||||
};
|
||||
ResponseEvent {
|
||||
r#type: Some(api::response_event::Type::Finished(
|
||||
api::response_event::StreamFinished {
|
||||
reason: Some(reason),
|
||||
token_usage: vec![stream_finished::TokenUsage {
|
||||
model_id: model_id.to_owned(),
|
||||
// Session runtimes report current occupancy, not tokens
|
||||
// consumed by this individual request.
|
||||
total_input: 0,
|
||||
output: 0,
|
||||
input_cache_read: 0,
|
||||
input_cache_write: 0,
|
||||
cost_in_cents: 0.0,
|
||||
}],
|
||||
should_refresh_model_config: false,
|
||||
request_cost: None,
|
||||
conversation_usage_metadata: Some(usage_metadata),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_stop_reason(reason: StopReason) -> stream_finished::Reason {
|
||||
match reason {
|
||||
StopReason::Completed | StopReason::Cancelled => {
|
||||
stream_finished::Reason::Done(stream_finished::Done {})
|
||||
}
|
||||
StopReason::MaxTokens => {
|
||||
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
|
||||
}
|
||||
StopReason::ContextWindowExceeded => stream_finished::Reason::ContextWindowExceeded(
|
||||
stream_finished::ContextWindowExceeded {},
|
||||
),
|
||||
StopReason::Refusal | StopReason::ToolLoopLimit | StopReason::Other(_) => {
|
||||
stream_finished::Reason::Other(stream_finished::Other {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn saturating_i32(value: u64) -> i32 {
|
||||
i32::try_from(value).unwrap_or(i32::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "event_translator_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,210 @@
|
||||
use galaxy_agent_core::{
|
||||
AgentEvent, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities, StopReason, Usage,
|
||||
};
|
||||
use warp_multi_agent_api::{client_action, message, response_event};
|
||||
|
||||
use super::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||
use crate::ai::agent::runtime_activity;
|
||||
|
||||
fn provider_translator() -> RuntimeResponseTranslator {
|
||||
RuntimeResponseTranslator::new(RuntimeResponseConfig {
|
||||
task_id: "task".to_owned(),
|
||||
conversation_id: "conversation".to_owned(),
|
||||
needs_create_task: false,
|
||||
user_query: None,
|
||||
model_id: "model".to_owned(),
|
||||
max_context_tokens: Some(1_000),
|
||||
capabilities: RuntimeCapabilities::provider(),
|
||||
empty_output_message: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn session_translator() -> RuntimeResponseTranslator {
|
||||
RuntimeResponseTranslator::new(RuntimeResponseConfig {
|
||||
task_id: "task".to_owned(),
|
||||
conversation_id: String::new(),
|
||||
needs_create_task: false,
|
||||
user_query: None,
|
||||
model_id: "session-runtime".to_owned(),
|
||||
max_context_tokens: None,
|
||||
capabilities: RuntimeCapabilities::session_runtime(),
|
||||
empty_output_message: Some("> runtime completed without text".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_and_session_runtimes_share_text_translation() {
|
||||
for mut translator in [provider_translator(), session_translator()] {
|
||||
let first = translator
|
||||
.translate(AgentEvent::TextDelta {
|
||||
text: "one".to_owned(),
|
||||
})
|
||||
.expect("first delta");
|
||||
let second = translator
|
||||
.translate(AgentEvent::TextDelta {
|
||||
text: " two".to_owned(),
|
||||
})
|
||||
.expect("second delta");
|
||||
|
||||
let Some(response_event::Type::ClientActions(first_actions)) = &first[1].r#type else {
|
||||
panic!("expected first client action");
|
||||
};
|
||||
assert!(matches!(
|
||||
first_actions.actions[0].action,
|
||||
Some(client_action::Action::AddMessagesToTask(_))
|
||||
));
|
||||
let Some(response_event::Type::ClientActions(second_actions)) = &second[0].r#type else {
|
||||
panic!("expected append client action");
|
||||
};
|
||||
assert!(matches!(
|
||||
second_actions.actions[0].action,
|
||||
Some(client_action::Action::AppendToMessageContent(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_uses_the_native_reasoning_message_contract() {
|
||||
let mut translator = provider_translator();
|
||||
let first = translator
|
||||
.translate(AgentEvent::ReasoningDelta {
|
||||
text: "think".to_owned(),
|
||||
})
|
||||
.expect("reasoning");
|
||||
let second = translator
|
||||
.translate(AgentEvent::ReasoningDelta {
|
||||
text: " more".to_owned(),
|
||||
})
|
||||
.expect("reasoning append");
|
||||
|
||||
let Some(response_event::Type::ClientActions(actions)) = &first[1].r#type else {
|
||||
panic!("expected reasoning action");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
|
||||
panic!("expected reasoning message");
|
||||
};
|
||||
assert!(matches!(
|
||||
add.messages[0].message,
|
||||
Some(message::Message::AgentReasoning(_))
|
||||
));
|
||||
let Some(response_event::Type::ClientActions(actions)) = &second[0].r#type else {
|
||||
panic!("expected reasoning append");
|
||||
};
|
||||
assert!(matches!(
|
||||
actions.actions[0].action,
|
||||
Some(client_action::Action::AppendToMessageContent(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_activity_updates_the_same_structured_message() {
|
||||
let mut translator = session_translator();
|
||||
let started = translator
|
||||
.translate(AgentEvent::RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: Some(RuntimeActivityStatus::InProgress),
|
||||
output: None,
|
||||
},
|
||||
})
|
||||
.expect("started");
|
||||
let Some(response_event::Type::ClientActions(actions)) = &started[1].r#type else {
|
||||
panic!("expected activity action");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
|
||||
panic!("expected activity message");
|
||||
};
|
||||
let message_id = add.messages[0].id.clone();
|
||||
|
||||
let completed = translator
|
||||
.translate(AgentEvent::RuntimeActivityUpdated {
|
||||
activity: RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: String::new(),
|
||||
status: Some(RuntimeActivityStatus::Completed),
|
||||
output: Some("ok".to_owned()),
|
||||
},
|
||||
})
|
||||
.expect("completed");
|
||||
let Some(response_event::Type::ClientActions(actions)) = &completed[0].r#type else {
|
||||
panic!("expected activity update");
|
||||
};
|
||||
let Some(client_action::Action::UpdateTaskMessage(update)) = &actions.actions[0].action else {
|
||||
panic!("expected in-place update");
|
||||
};
|
||||
let message = update.message.as_ref().expect("updated message");
|
||||
assert_eq!(message.id, message_id);
|
||||
assert_eq!(
|
||||
runtime_activity::decode(&message.server_message_data),
|
||||
Some(RuntimeActivity {
|
||||
id: "tool-1".to_owned(),
|
||||
title: "Run tests".to_owned(),
|
||||
status: Some(RuntimeActivityStatus::Completed),
|
||||
output: Some("ok".to_owned()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_shape_follows_history_ownership_capability() {
|
||||
let mut provider = provider_translator();
|
||||
provider
|
||||
.translate(AgentEvent::UsageUpdated {
|
||||
usage: Usage {
|
||||
input_tokens: 250,
|
||||
output_tokens: 10,
|
||||
..Usage::default()
|
||||
},
|
||||
})
|
||||
.expect("provider usage");
|
||||
let provider_finished = provider
|
||||
.translate(AgentEvent::TurnStopped {
|
||||
reason: StopReason::Completed,
|
||||
})
|
||||
.expect("provider finished");
|
||||
let Some(response_event::Type::Finished(finished)) = &provider_finished[1].r#type else {
|
||||
panic!("expected provider finish");
|
||||
};
|
||||
assert_eq!(finished.token_usage[0].total_input, 250);
|
||||
|
||||
let mut session = session_translator();
|
||||
session
|
||||
.translate(AgentEvent::ContextUsageUpdated {
|
||||
used_tokens: 25,
|
||||
context_size: 100,
|
||||
})
|
||||
.expect("context usage");
|
||||
let session_finished = session
|
||||
.translate(AgentEvent::TurnStopped {
|
||||
reason: StopReason::Completed,
|
||||
})
|
||||
.expect("session finished");
|
||||
let Some(response_event::Type::Finished(finished)) = &session_finished[2].r#type else {
|
||||
panic!("expected session finish");
|
||||
};
|
||||
assert_eq!(finished.token_usage[0].total_input, 0);
|
||||
assert_eq!(
|
||||
finished
|
||||
.conversation_usage_metadata
|
||||
.as_ref()
|
||||
.expect("context metadata")
|
||||
.context_window_usage,
|
||||
0.25
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_reject_events_owned_by_the_other_runtime_shape() {
|
||||
assert!(provider_translator()
|
||||
.translate(AgentEvent::ContextUsageUpdated {
|
||||
used_tokens: 1,
|
||||
context_size: 2,
|
||||
})
|
||||
.is_err());
|
||||
assert!(session_translator()
|
||||
.translate(AgentEvent::UsageUpdated {
|
||||
usage: Usage::default(),
|
||||
})
|
||||
.is_err());
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod event_translator;
|
||||
mod provider;
|
||||
mod rig;
|
||||
mod rig_request;
|
||||
mod rig_tool;
|
||||
|
||||
pub(crate) use event_translator::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||
pub(crate) use provider::ProviderRuntime;
|
||||
pub(crate) use rig::{rig_bedrock_response_stream, rig_openai_response_stream};
|
||||
|
||||
+65
-160
@@ -3,13 +3,12 @@ use std::sync::Arc;
|
||||
use futures::channel::oneshot;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use galaxy_agent_core::{
|
||||
turn_control, AgentError, AgentEvent, AgentRuntime, MessageContent, MessageRole, StopReason,
|
||||
ToolCall, ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand, Usage,
|
||||
turn_control, AgentError, AgentEvent, AgentRuntime, MessageContent, MessageRole, ToolCall,
|
||||
ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand,
|
||||
};
|
||||
use galaxy_agent_rig::{OpenAICompatibleRuntime, OpenAICompatibleRuntimeConfig};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event::stream_finished;
|
||||
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent, ToolType};
|
||||
use warp_multi_agent_api::ToolType;
|
||||
|
||||
use super::rig_request::{prepare_bedrock_rig_turn, prepare_rig_turn, PreparedRigTurn};
|
||||
use super::rig_tool::action_from_tool_call;
|
||||
@@ -17,13 +16,10 @@ use crate::ai::agent::api::{Event, RequestParams, ResponseStream, StreamEvent};
|
||||
use crate::ai::agent::AIAgentAction;
|
||||
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig};
|
||||
use crate::ai::bedrock::external_config::ExternalBedrockConfig;
|
||||
use crate::ai::bedrock::response_translator::{
|
||||
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
|
||||
build_user_query_message,
|
||||
};
|
||||
use crate::ai::bedrock::response_translator::build_add_agent_output_message;
|
||||
use crate::ai::openai::client::OpenAIClientConfig;
|
||||
use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage};
|
||||
use crate::ai::provider::types::{ContentPart, ConversationMessage};
|
||||
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||
use crate::server::server_api::AIApiError;
|
||||
|
||||
pub(crate) fn rig_openai_response_stream(
|
||||
@@ -103,6 +99,7 @@ fn rig_response_stream<R>(
|
||||
where
|
||||
R: AgentRuntime + Send + Sync + 'static,
|
||||
{
|
||||
let runtime_capabilities = runtime.descriptor().capabilities.clone();
|
||||
let PreparedRigTurn {
|
||||
task_id,
|
||||
needs_create_task,
|
||||
@@ -143,17 +140,22 @@ where
|
||||
},
|
||||
};
|
||||
|
||||
let request_id = Uuid::new_v4().to_string();
|
||||
let conversation_id = conversation_id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let mut initialized = false;
|
||||
let mut current_text_message_id: Option<String> = None;
|
||||
let mut current_reasoning_message_id: Option<String> = None;
|
||||
let mut translator = RuntimeResponseTranslator::new(RuntimeResponseConfig {
|
||||
task_id: task_id.clone(),
|
||||
conversation_id,
|
||||
needs_create_task,
|
||||
user_query,
|
||||
model_id,
|
||||
max_context_tokens,
|
||||
capabilities: runtime_capabilities,
|
||||
empty_output_message: None,
|
||||
});
|
||||
let mut full_text = String::new();
|
||||
let mut full_reasoning = String::new();
|
||||
let mut reasoning_signature = None;
|
||||
let mut proposed_tools = Vec::new();
|
||||
let mut assistant_history_index = None;
|
||||
let mut usage = Usage::default();
|
||||
|
||||
loop {
|
||||
let next_event = agent_events.next().fuse();
|
||||
@@ -176,48 +178,6 @@ where
|
||||
};
|
||||
|
||||
match event {
|
||||
AgentEvent::TurnStarted { .. } => {
|
||||
initialized = true;
|
||||
yield Ok(StreamEvent::Response(build_stream_init(&request_id, &conversation_id)));
|
||||
if needs_create_task {
|
||||
yield Ok(StreamEvent::Response(build_create_task(&task_id)));
|
||||
}
|
||||
if let Some(user_query) = &user_query {
|
||||
yield Ok(StreamEvent::Response(build_user_query_message(&task_id, user_query)));
|
||||
}
|
||||
}
|
||||
AgentEvent::TextDelta { text } => {
|
||||
full_text.push_str(&text);
|
||||
if let Some(message_id) = ¤t_text_message_id {
|
||||
yield Ok(StreamEvent::Response(build_append_text(&task_id, message_id, &text)));
|
||||
} else {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
yield Ok(StreamEvent::Response(build_add_agent_output_message(&task_id, &message_id, &text)));
|
||||
current_text_message_id = Some(message_id);
|
||||
}
|
||||
}
|
||||
AgentEvent::ReasoningDelta { text } => {
|
||||
full_reasoning.push_str(&text);
|
||||
if let Some(message_id) = ¤t_reasoning_message_id {
|
||||
yield Ok(StreamEvent::Response(build_append_reasoning(&task_id, message_id, &text)));
|
||||
} else {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
yield Ok(StreamEvent::Response(build_add_reasoning(&task_id, &message_id, &text)));
|
||||
current_reasoning_message_id = Some(message_id);
|
||||
}
|
||||
}
|
||||
AgentEvent::ReasoningCompleted { text, signature } => {
|
||||
if current_reasoning_message_id.is_none() && !text.is_empty() {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
yield Ok(StreamEvent::Response(build_add_reasoning(&task_id, &message_id, &text)));
|
||||
current_reasoning_message_id = Some(message_id);
|
||||
}
|
||||
if !text.is_empty() {
|
||||
full_reasoning = text;
|
||||
}
|
||||
reasoning_signature = signature;
|
||||
}
|
||||
AgentEvent::UsageUpdated { usage: updated } => usage = updated,
|
||||
AgentEvent::Tool {
|
||||
event: ToolEvent::Proposed { call },
|
||||
} => {
|
||||
@@ -271,9 +231,6 @@ where
|
||||
}
|
||||
}
|
||||
AgentEvent::TurnStopped { reason } => {
|
||||
if !initialized {
|
||||
yield Ok(StreamEvent::Response(build_stream_init(&request_id, &conversation_id)));
|
||||
}
|
||||
sync_assistant_turn(
|
||||
&messages_sent,
|
||||
&full_reasoning,
|
||||
@@ -282,38 +239,57 @@ where
|
||||
&proposed_tools,
|
||||
&mut assistant_history_index,
|
||||
);
|
||||
yield Ok(StreamEvent::Response(build_stream_finished(
|
||||
map_stop_reason(reason),
|
||||
StreamUsage {
|
||||
input_tokens: saturating_i32(usage.input_tokens),
|
||||
output_tokens: saturating_i32(usage.output_tokens),
|
||||
cache_read_tokens: saturating_i32(usage.cached_input_tokens),
|
||||
cache_write_tokens: saturating_i32(
|
||||
usage.cache_creation_input_tokens,
|
||||
),
|
||||
cost_in_cents: 0.0,
|
||||
model_id,
|
||||
max_context_tokens,
|
||||
},
|
||||
)));
|
||||
let response_events = match translator
|
||||
.translate(AgentEvent::TurnStopped { reason })
|
||||
{
|
||||
Ok(response_events) => response_events,
|
||||
Err(message) => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
message,
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
};
|
||||
for response_event in response_events {
|
||||
yield Ok(StreamEvent::Response(response_event));
|
||||
}
|
||||
return;
|
||||
}
|
||||
AgentEvent::Tool { .. } => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
"the provider runtime attempted to execute a tool outside Galaxy's permission boundary",
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
AgentEvent::RuntimeActivityUpdated { .. }
|
||||
| AgentEvent::ContextUsageUpdated { .. }
|
||||
| AgentEvent::UserInputAccepted { .. }
|
||||
| AgentEvent::RuntimeNotice { .. } => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
"the provider runtime emitted a session-runtime event",
|
||||
), stream_type));
|
||||
return;
|
||||
event => {
|
||||
match &event {
|
||||
AgentEvent::TextDelta { text } => full_text.push_str(text),
|
||||
AgentEvent::ReasoningDelta { text } => {
|
||||
full_reasoning.push_str(text);
|
||||
}
|
||||
AgentEvent::ReasoningCompleted { text, signature } => {
|
||||
if !text.is_empty() {
|
||||
full_reasoning.clone_from(text);
|
||||
}
|
||||
reasoning_signature.clone_from(signature);
|
||||
}
|
||||
AgentEvent::TurnStarted { .. }
|
||||
| AgentEvent::Tool { .. }
|
||||
| AgentEvent::UsageUpdated { .. }
|
||||
| AgentEvent::RuntimeActivityUpdated { .. }
|
||||
| AgentEvent::ContextUsageUpdated { .. }
|
||||
| AgentEvent::UserInputAccepted { .. }
|
||||
| AgentEvent::RuntimeNotice { .. }
|
||||
| AgentEvent::TurnStopped { .. } => {}
|
||||
}
|
||||
let response_events = match translator.translate(event) {
|
||||
Ok(response_events) => response_events,
|
||||
Err(message) => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
message,
|
||||
), stream_type));
|
||||
return;
|
||||
}
|
||||
};
|
||||
for response_event in response_events {
|
||||
yield Ok(StreamEvent::Response(response_event));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,77 +402,6 @@ fn build_tool_proposed(
|
||||
action_from_tool_call(task_id, call, skill_path_origin)
|
||||
}
|
||||
|
||||
fn build_add_reasoning(task_id: &str, message_id: &str, text: &str) -> ResponseEvent {
|
||||
reasoning_action(task_id, message_id, text, false)
|
||||
}
|
||||
|
||||
fn build_append_reasoning(task_id: &str, message_id: &str, text: &str) -> ResponseEvent {
|
||||
reasoning_action(task_id, message_id, text, true)
|
||||
}
|
||||
|
||||
fn reasoning_action(task_id: &str, message_id: &str, text: &str, append: bool) -> ResponseEvent {
|
||||
let message = api::Message {
|
||||
id: message_id.to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
request_id: String::new(),
|
||||
timestamp: None,
|
||||
server_message_data: String::new(),
|
||||
citations: Vec::new(),
|
||||
fetched_memories: Vec::new(),
|
||||
message: Some(api::message::Message::AgentReasoning(
|
||||
api::message::AgentReasoning {
|
||||
reasoning: text.to_string(),
|
||||
finished_duration: None,
|
||||
},
|
||||
)),
|
||||
};
|
||||
let action = if append {
|
||||
api::client_action::Action::AppendToMessageContent(
|
||||
api::client_action::AppendToMessageContent {
|
||||
task_id: task_id.to_string(),
|
||||
message: Some(message),
|
||||
mask: Some(prost_types::FieldMask {
|
||||
paths: vec!["agent_reasoning.reasoning".to_string()],
|
||||
}),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
api::client_action::Action::AddMessagesToTask(api::client_action::AddMessagesToTask {
|
||||
task_id: task_id.to_string(),
|
||||
messages: vec![message],
|
||||
})
|
||||
};
|
||||
ResponseEvent {
|
||||
r#type: Some(api::response_event::Type::ClientActions(
|
||||
api::response_event::ClientActions {
|
||||
actions: vec![ClientAction {
|
||||
action: Some(action),
|
||||
}],
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_stop_reason(reason: StopReason) -> stream_finished::Reason {
|
||||
match reason {
|
||||
StopReason::Completed => stream_finished::Reason::Done(stream_finished::Done {}),
|
||||
StopReason::MaxTokens => {
|
||||
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
|
||||
}
|
||||
StopReason::ContextWindowExceeded => stream_finished::Reason::ContextWindowExceeded(
|
||||
stream_finished::ContextWindowExceeded {},
|
||||
),
|
||||
StopReason::Cancelled
|
||||
| StopReason::Refusal
|
||||
| StopReason::ToolLoopLimit
|
||||
| StopReason::Other(_) => stream_finished::Reason::Other(stream_finished::Other {}),
|
||||
}
|
||||
}
|
||||
|
||||
fn saturating_i32(value: u64) -> i32 {
|
||||
i32::try_from(value).unwrap_or(i32::MAX)
|
||||
}
|
||||
|
||||
fn agent_error(error: AgentError, stream_type: &'static str) -> Arc<AIApiError> {
|
||||
Arc::new(
|
||||
AIApiError::Stream {
|
||||
|
||||
@@ -2,69 +2,10 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use ai::skills::SkillPathOrigin;
|
||||
use galaxy_agent_core::{
|
||||
ContentPart, MessageContent, MessageRole, StopReason, ToolCall, ToolResult, ToolResultStatus,
|
||||
};
|
||||
use warp_multi_agent_api::response_event::stream_finished;
|
||||
|
||||
use super::{
|
||||
append_tool_result, build_add_reasoning, build_append_reasoning, build_tool_proposed,
|
||||
map_stop_reason, saturating_i32, sync_assistant_turn,
|
||||
ContentPart, MessageContent, MessageRole, ToolCall, ToolResult, ToolResultStatus,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn stop_reasons_map_to_the_existing_ui_contract() {
|
||||
assert!(matches!(
|
||||
map_stop_reason(StopReason::Completed),
|
||||
stream_finished::Reason::Done(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
map_stop_reason(StopReason::MaxTokens),
|
||||
stream_finished::Reason::MaxTokenLimit(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
map_stop_reason(StopReason::Cancelled),
|
||||
stream_finished::Reason::Other(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_counts_saturate_at_the_proto_limit() {
|
||||
assert_eq!(saturating_i32(u64::MAX), i32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_events_match_the_existing_ui_message_contract() {
|
||||
let add = build_add_reasoning("task", "message", "think");
|
||||
let append = build_append_reasoning("task", "message", " more");
|
||||
|
||||
let Some(warp_multi_agent_api::response_event::Type::ClientActions(add)) = add.r#type else {
|
||||
panic!("expected client actions");
|
||||
};
|
||||
let Some(warp_multi_agent_api::client_action::Action::AddMessagesToTask(add)) =
|
||||
&add.actions[0].action
|
||||
else {
|
||||
panic!("expected add-message action");
|
||||
};
|
||||
assert!(matches!(
|
||||
add.messages[0].message.as_ref(),
|
||||
Some(warp_multi_agent_api::message::Message::AgentReasoning(reasoning))
|
||||
if reasoning.reasoning == "think"
|
||||
));
|
||||
|
||||
let Some(warp_multi_agent_api::response_event::Type::ClientActions(append)) = append.r#type
|
||||
else {
|
||||
panic!("expected client actions");
|
||||
};
|
||||
let Some(warp_multi_agent_api::client_action::Action::AppendToMessageContent(append)) =
|
||||
&append.actions[0].action
|
||||
else {
|
||||
panic!("expected append-message action");
|
||||
};
|
||||
assert_eq!(
|
||||
append.mask.as_ref().unwrap().paths,
|
||||
["agent_reasoning.reasoning"]
|
||||
);
|
||||
}
|
||||
use super::{append_tool_result, build_tool_proposed, sync_assistant_turn};
|
||||
|
||||
#[test]
|
||||
fn tool_proposal_matches_the_domain_permission_contract() {
|
||||
|
||||
Reference in New Issue
Block a user