use crate::agent::AgentContext;
use crate::error::{Error, LlmError};
use crate::ids::ThreadId;
use crate::llm::{FinishReason, Message, Usage};
use crate::memory::Episode;
use tracing::{debug, info};
use super::dispatch::dispatch_tool_calls;
pub(crate) struct StepOutcome {
pub message: Message,
pub finish_reason: FinishReason,
pub usage: Usage,
pub latency_ms: u32,
pub provider: Option<String>,
pub model: Option<String>,
}
pub(crate) fn split_provider_model(name: &str) -> (Option<String>, Option<String>) {
match name.split_once(':') {
Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
(Some(provider.to_string()), Some(model.to_string()))
}
_ => (None, None),
}
}
pub(crate) enum StepDisposition {
Done(String),
Continue,
}
pub(crate) async fn record_and_dispatch(
ctx: &AgentContext,
thread: &ThreadId,
step: u32,
outcome: StepOutcome,
dispatch_label: &'static str,
) -> Result<StepDisposition, Error> {
let StepOutcome {
message,
finish_reason,
usage,
latency_ms,
provider,
model,
} = outcome;
ctx.episodic
.record(
ctx.run_id,
Episode::LlmCall {
tokens: usage.prompt_tokens + usage.completion_tokens,
latency_ms,
provider,
model,
prompt_tokens: Some(usage.prompt_tokens),
completion_tokens: Some(usage.completion_tokens),
},
)
.await?;
ctx.short_term
.append(thread.clone(), message.clone())
.await?;
match finish_reason {
FinishReason::Stop | FinishReason::Length => {
info!(
step,
content_len = message.content.len(),
mode = dispatch_label,
"agent finished"
);
ctx.episodic.record(ctx.run_id, Episode::Completed).await?;
Ok(StepDisposition::Done(message.content))
}
FinishReason::ToolCalls => {
debug!(
step,
count = message.tool_calls.len(),
mode = dispatch_label,
"dispatching tools"
);
dispatch_tool_calls(ctx, thread, &message.tool_calls, dispatch_label).await?;
Ok(StepDisposition::Continue)
}
FinishReason::ContentFilter => Err(Error::Llm(LlmError::BadRequest(
"content-filter triggered".into(),
))),
FinishReason::Error => Err(Error::Llm(LlmError::Server(
"provider returned Error finish_reason".into(),
))),
}
}
#[cfg(test)]
mod tests {
use super::split_provider_model;
#[test]
fn splits_provider_and_model_from_a_conventional_name() {
assert_eq!(
split_provider_model("openai:gpt-4o"),
(Some("openai".into()), Some("gpt-4o".into()))
);
}
#[test]
fn keeps_a_colon_in_the_model_half() {
assert_eq!(
split_provider_model("ollama:qwen2.5:14b"),
(Some("ollama".into()), Some("qwen2.5:14b".into()))
);
}
#[test]
fn keeps_the_raw_provider_prefix_aliasing_is_the_price_tables_job() {
assert_eq!(
split_provider_model("gemini:gemini-2.0-flash"),
(Some("gemini".into()), Some("gemini-2.0-flash".into()))
);
}
#[test]
fn yields_none_when_the_name_has_no_provider_model_shape() {
assert_eq!(split_provider_model("fake"), (None, None));
assert_eq!(split_provider_model(":m"), (None, None));
assert_eq!(split_provider_model("p:"), (None, None));
}
}