use std::sync::Arc;
use async_trait::async_trait;
use scv_core::{Tool, ToolContext, ToolError, ToolFailure, ToolOutput, ToolRisk, ToolSpec};
use scv_protocol::JobStatus;
use serde_json::Value;
use crate::delegate::{
adapters,
output::{self, AgentReply},
};
const UNAVAILABLE: &[&str] = &[
"not found on path",
"no such file or directory",
"the agent exited",
"acp server exited",
"not logged in",
"not signed in",
"not authenticated",
"unauthorized",
"authentication",
"missing_credential",
"no api key",
"auth_required",
"forbidden",
"401",
"403",
"404",
"429",
"502",
"503",
"529",
"rate limit",
"rate_limit",
"quota",
"overloaded",
"service unavailable",
"model not found",
"no such model",
"does not exist",
"insufficient",
"billing",
"connection refused",
"connection reset",
];
pub(crate) fn reports_unavailable(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
UNAVAILABLE.iter().any(|needle| lower.contains(needle))
}
pub(crate) struct ChosenAgent {
pub(crate) inner: Arc<dyn Tool>,
pub(crate) use_for: Option<String>,
pub(crate) model: Option<String>,
pub(crate) effort: Option<String>,
pub(crate) alternatives: Vec<String>,
}
pub fn model_effort_phrase(model: Option<&str>, effort: Option<&str>) -> Option<String> {
match (model, effort) {
(Some(model), Some(effort)) => Some(format!("model {model} and effort {effort}")),
(Some(model), None) => Some(format!("model {model}")),
(None, Some(effort)) => Some(format!("effort {effort}")),
(None, None) => None,
}
}
fn choice_note(use_for: Option<&str>, model: Option<&str>, effort: Option<&str>) -> Option<String> {
let defaults = model_effort_phrase(model, effort);
match (use_for, defaults) {
(Some(use_for), Some(defaults)) => Some(format!(
" The user's note on when to use it: {use_for}. For that work, pass {defaults}; \
omit model and effort for other work so the agent uses its own default."
)),
(Some(use_for), None) => Some(format!(" The user's note on when to use it: {use_for}")),
(None, Some(defaults)) => Some(format!(
" Pass {defaults} unless the user asks for another; omit them to use the agent's \
own default."
)),
(None, None) => None,
}
}
impl ChosenAgent {
fn fallback(&self) -> Option<String> {
(!self.alternatives.is_empty()).then(|| {
format!(
"This agent could not run: it is missing, signed out, or its provider \
returned an error. Other agents are available: {}.",
self.alternatives.join(", ")
)
})
}
fn offers_grok(&self) -> bool {
self.alternatives.iter().any(|name| name == "agent_grok")
}
}
fn unavailable_result(reply: &AgentReply) -> bool {
reply.status == Some(JobStatus::Failed)
&& reply.error.as_deref().is_some_and(reports_unavailable)
}
fn descriptor(tool: &str) -> Option<&'static adapters::AdapterDescriptor> {
adapters::adapter(tool.strip_prefix("agent_")?)
}
#[async_trait]
impl Tool for ChosenAgent {
fn spec(&self) -> ToolSpec {
let mut spec = self.inner.spec();
if let Some(descriptor) = descriptor(&spec.name) {
spec.description = format!(
"{}: {}. {}",
descriptor.product, descriptor.offers, spec.description
);
}
if let Some(note) = choice_note(
self.use_for.as_deref(),
self.model.as_deref(),
self.effort.as_deref(),
) {
spec.description.push_str(¬e);
}
spec
}
fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
self.inner.risk(arguments)
}
fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
self.inner.approval_summary(arguments)
}
async fn execute(
&self,
arguments: Value,
context: ToolContext,
) -> Result<ToolOutput, ToolError> {
match self.inner.execute(arguments, context).await {
Ok(mut result) if result.is_error() => {
if let Ok(value) = serde_json::from_str::<Value>(&result.content)
&& let Some(reply) = AgentReply::read(&value)
&& let Value::Object(mut content) = value
{
if unavailable_result(&reply) {
result.failure = Some(ToolFailure::Unavailable);
if let Some(fallback) = self.fallback() {
content.insert("fallback".into(), fallback.into());
result.content = Value::Object(content).to_string();
}
} else if reply.status == Some(JobStatus::Declined) && self.offers_grok() {
content.insert("note".into(), output::DECLINED_NOTE_TRY_GROK.into());
result.content = Value::Object(content).to_string();
}
}
Ok(result)
}
Err(error) if error.kind == ToolFailure::Unavailable => Err(match self.fallback() {
Some(fallback) => ToolError::unavailable(format!("{} {fallback}", error.message)),
None => error,
}),
other => other,
}
}
}
pub fn product(tool: &str) -> &str {
descriptor(tool).map_or(tool, |descriptor| descriptor.product)
}
#[cfg(test)]
mod tests;