refactor(fctool): add descriptions to tools and simplify model sync

- Add description field to all fctool file and git tools
- Simplify extract_model_name in sync.rs (use upstream id directly)
This commit is contained in:
ZhenYi 2026-04-28 09:43:15 +08:00
parent da2853d0ec
commit 7b43f55f41
11 changed files with 47 additions and 85 deletions

View File

@ -16,6 +16,7 @@ pub mod call;
pub mod context; pub mod context;
pub mod definition; pub mod definition;
pub mod executor; pub mod executor;
pub mod recorder;
pub mod registry; pub mod registry;
#[cfg(feature = "rig")] #[cfg(feature = "rig")]
@ -28,4 +29,8 @@ pub use call::{ToolCall, ToolCallResult, ToolError, ToolResult};
pub use context::ToolContext; pub use context::ToolContext;
pub use definition::{ToolDefinition, ToolParam, ToolSchema}; pub use definition::{ToolDefinition, ToolParam, ToolSchema};
pub use executor::ToolExecutor; pub use executor::ToolExecutor;
pub use recorder::{ToolCallRecord, ToolCallRecorder};
pub use registry::{ToolHandler, ToolRegistry}; pub use registry::{ToolHandler, ToolRegistry};
#[cfg(feature = "rig")]
pub use rig_adapter::{is_retryable_tool_error, RecordingTool, RigToolAdapter, RigToolSet};

View File

@ -53,6 +53,8 @@ async fn read_csv_exec(
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev) git::commit::types::CommitOid::new(&rev)
} else if let Ok(Some(oid)) = domain.ref_target(&rev) {
oid
} else { } else {
domain domain
.commit_get_prefix(&rev) .commit_get_prefix(&rev)

View File

@ -69,6 +69,8 @@ async fn git_grep_exec(
// Resolve revision to commit oid // Resolve revision to commit oid
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev) git::commit::types::CommitOid::new(&rev)
} else if let Ok(Some(oid)) = domain.ref_target(&rev) {
oid
} else { } else {
domain domain
.commit_get_prefix(&rev) .commit_get_prefix(&rev)

View File

@ -132,6 +132,8 @@ async fn read_json_exec(
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev) git::commit::types::CommitOid::new(&rev)
} else if let Ok(Some(oid)) = domain.ref_target(&rev) {
oid
} else { } else {
domain domain
.commit_get_prefix(&rev) .commit_get_prefix(&rev)

View File

@ -43,6 +43,8 @@ async fn read_markdown_exec(
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev) git::commit::types::CommitOid::new(&rev)
} else if let Ok(Some(oid)) = domain.ref_target(&rev) {
oid
} else { } else {
domain domain
.commit_get_prefix(&rev) .commit_get_prefix(&rev)

View File

@ -35,6 +35,8 @@ async fn read_sql_exec(
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev) git::commit::types::CommitOid::new(&rev)
} else if let Ok(Some(oid)) = domain.ref_target(&rev) {
oid
} else { } else {
domain domain
.commit_get_prefix(&rev) .commit_get_prefix(&rev)

View File

@ -115,6 +115,8 @@ fn resolve_oid(
) -> Result<git::commit::types::CommitOid, String> { ) -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev)) Ok(git::commit::types::CommitOid::new(rev))
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
Ok(oid)
} else { } else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid) domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
} }

View File

@ -48,10 +48,12 @@ async fn git_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_
} }
/// Resolve a rev string to commit metadata. Tries full OID first (exactly 40 hex chars), /// Resolve a rev string to commit metadata. Tries full OID first (exactly 40 hex chars),
/// falls back to prefix lookup (branch, tag, short hash). /// then reference name resolution (branch, tag, HEAD), then hex prefix lookup.
fn resolve_commit(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitMeta, String> { fn resolve_commit(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitMeta, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string()) domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
domain.commit_get(&oid).map_err(|e| e.to_string())
} else { } else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()) domain.commit_get_prefix(rev).map_err(|e| e.to_string())
} }

View File

@ -19,6 +19,8 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> { let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev)) Ok(git::commit::types::CommitOid::new(rev))
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
Ok(oid)
} else { } else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid) domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
} }
@ -45,12 +47,13 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
if domain.repo().head().is_err() { if domain.repo().head().is_err() {
return Err("No commits found in repository".into()); return Err("No commits found in repository".into());
} }
let head_meta = domain.commit_get_prefix("HEAD").map_err(|e| e.to_string())?; let head_oid = domain.ref_target("HEAD").map_err(|e| e.to_string())?
.ok_or_else(|| "HEAD reference not found".to_string())?;
// Bare repos have no working tree — use tree-to-tree diff instead // Bare repos have no working tree — use tree-to-tree diff instead
if domain.repo().is_bare() { if domain.repo().is_bare() {
domain.diff_tree_to_tree(None, Some(&head_meta.oid), opts).map_err(|e| e.to_string())? domain.diff_tree_to_tree(None, Some(&head_oid), opts).map_err(|e| e.to_string())?
} else { } else {
domain.diff_commit_to_workdir(&head_meta.oid, opts).map_err(|e| e.to_string())? domain.diff_commit_to_workdir(&head_oid, opts).map_err(|e| e.to_string())?
} }
} }
(Some(base), None) => { (Some(base), None) => {
@ -96,6 +99,8 @@ async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> { let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev)) Ok(git::commit::types::CommitOid::new(rev))
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
Ok(oid)
} else { } else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid) domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
} }
@ -123,6 +128,8 @@ async fn git_blame_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serd
let domain = ctx.open_repo(project_name, repo_name).await?; let domain = ctx.open_repo(project_name, repo_name).await?;
let oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { let oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(&rev)) Ok(git::commit::types::CommitOid::new(&rev))
} else if let Ok(Some(oid)) = domain.ref_target(&rev) {
Ok(oid)
} else { } else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string()).map(|m| m.oid) domain.commit_get_prefix(&rev).map_err(|e| e.to_string()).map(|m| m.oid)
}?; }?;

View File

@ -6,10 +6,12 @@ use base64::Engine;
use std::collections::HashMap; use std::collections::HashMap;
/// Resolve a rev string to a commit OID. Tries full OID first (exactly 40 hex chars), /// Resolve a rev string to a commit OID. Tries full OID first (exactly 40 hex chars),
/// falls back to prefix lookup (branch, tag, short hash). /// then reference name resolution (branch, tag, HEAD), then hex prefix lookup.
fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitOid, String> { fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev)) Ok(git::commit::types::CommitOid::new(rev))
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
Ok(oid)
} else { } else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid) domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
} }

View File

@ -29,7 +29,7 @@ use models::agents::model_provider::Model as ProviderModel;
use models::agents::model_version::Entity as VersionEntity; use models::agents::model_version::Entity as VersionEntity;
use models::agents::{CapabilityType, ModelCapability, ModelModality, ModelStatus}; use models::agents::{CapabilityType, ModelCapability, ModelModality, ModelStatus};
use sea_orm::prelude::*; use sea_orm::prelude::*;
use sea_orm::{QueryOrder, Set}; use sea_orm::Set;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use session::Session; use session::Session;
@ -240,10 +240,9 @@ async fn upsert_provider(db: &AppDatabase, slug: &str) -> Result<ProviderModel,
} }
} }
/// Upserts a model by name only (deduplication key), ignoring provider. /// Upserts a model by upstream ID as the deduplication key.
/// This ensures each model name exists only once, regardless of how many /// Each upstream model ID maps to exactly one row in the local `ai_model` table.
/// providers offer it. The first provider encountered is kept. async fn upsert_model_by_id(
async fn upsert_model_by_name(
db: &AppDatabase, db: &AppDatabase,
provider_id: Uuid, provider_id: Uuid,
model: &UpstreamModel, model: &UpstreamModel,
@ -253,11 +252,11 @@ async fn upsert_model_by_name(
let capability = infer_capability(model); let capability = infer_capability(model);
let ctx = context_length(model); let ctx = context_length(model);
let max_out = max_output_tokens(model); let max_out = max_output_tokens(model);
let model_name = extract_model_name(model); let model_id_str = extract_model_name(model);
use models::agents::model::Column as MCol; use models::agents::model::Column as MCol;
if let Some(existing) = ModelEntity::find() if let Some(existing) = ModelEntity::find()
.filter(MCol::Name.eq(&model_name)) .filter(MCol::Name.eq(&model_id_str))
.one(db) .one(db)
.await? .await?
{ {
@ -276,7 +275,7 @@ async fn upsert_model_by_name(
let active = models::agents::model::ActiveModel { let active = models::agents::model::ActiveModel {
id: Set(Uuid::now_v7()), id: Set(Uuid::now_v7()),
provider_id: Set(provider_id), provider_id: Set(provider_id),
name: Set(model_name), name: Set(model_id_str),
modality: Set(modality.to_string()), modality: Set(modality.to_string()),
capability: Set(capability.to_string()), capability: Set(capability.to_string()),
context_length: Set(ctx), context_length: Set(ctx),
@ -431,61 +430,16 @@ async fn upsert_parameter_profile(
// Core sync logic ------------------------------------------------------------ // Core sync logic ------------------------------------------------------------
/// Extracts the base model name from an upstream model ID. /// Extracts the API model identifier from an upstream model.
/// e.g., "openai/gpt-4o-mini" -> "gpt-4o-mini", "anthropic/claude-3.5-sonnet" -> "claude-3.5-sonnet" /// Uses the upstream `id` field directly (e.g. "kimi-k2.6") as the model name
/// stored in the database, since this is what AI API calls use as the `model` parameter.
fn extract_model_name(model: &UpstreamModel) -> String { fn extract_model_name(model: &UpstreamModel) -> String {
// Use the name field if available, otherwise extract from id model.id.clone()
if let Some(name) = &model.name {
if !name.is_empty() {
return name.clone();
}
}
// Extract from id: "provider/model-name" -> "model-name"
model.id.split('/').last().unwrap_or(&model.id).to_string()
} }
/// Deduplicates existing models in the database by name. /// Deduplicates existing models in the database by name.
/// For models with the same name from different providers, keeps the newest one /// For models with the same name from different providers, keeps the newest one
/// and deletes the older duplicates. /// and deletes the older duplicates.
async fn deduplicate_existing_models(db: &AppDatabase) -> Result<i64, AppError> {
use models::agents::model::Entity as MEntity;
use models::agents::model::Column as MCol;
// Find all models grouped by name, ordered by creation time
let all_models = MEntity::find()
.order_by_asc(MCol::CreatedAt)
.all(db.reader())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
// Group by name
let mut name_to_ids: std::collections::HashMap<String, Vec<uuid::Uuid>> =
std::collections::HashMap::new();
for model in &all_models {
name_to_ids
.entry(model.name.clone())
.or_default()
.push(model.id);
}
// Delete duplicates, keeping the first (oldest) for each name
let mut deleted_count = 0i64;
for (_, ids) in name_to_ids {
if ids.len() > 1 {
// Keep the first (oldest), delete the rest
for id_to_delete in ids.into_iter().skip(1) {
MEntity::delete_by_id(id_to_delete)
.exec(db.writer())
.await
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
deleted_count += 1;
}
}
}
Ok(deleted_count)
}
async fn mark_all_models_offline(db: &AppDatabase) -> Result<i64, AppError> { async fn mark_all_models_offline(db: &AppDatabase) -> Result<i64, AppError> {
use models::agents::model::Entity as MEntity; use models::agents::model::Entity as MEntity;
use models::agents::model::Column as MCol; use models::agents::model::Column as MCol;
@ -509,32 +463,12 @@ async fn sync_models_from_upstream(
db: &AppDatabase, db: &AppDatabase,
upstream_models: Vec<UpstreamModel>, upstream_models: Vec<UpstreamModel>,
) -> SyncModelsResponse { ) -> SyncModelsResponse {
// Step 0: Deduplicate existing models in the database by name
let existing_deduped = deduplicate_existing_models(db).await.unwrap_or(0);
if existing_deduped > 0 {
tracing::info!(
deleted = existing_deduped,
"sync_models_from_upstream: cleaned up existing duplicate models"
);
}
// Step 1: Mark all existing models as offline // Step 1: Mark all existing models as offline
let models_offline = mark_all_models_offline(db).await.unwrap_or(0); let models_offline = mark_all_models_offline(db).await.unwrap_or(0);
// Step 2: Deduplicate upstream models by name, keeping the first occurrence
let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
let deduplicated_models: Vec<&UpstreamModel> = upstream_models
.iter()
.filter(|m| {
let name = extract_model_name(m);
seen_names.insert(name)
})
.collect();
tracing::info!( tracing::info!(
upstream_total = upstream_models.len(), upstream_total = upstream_models.len(),
deduplicated_count = deduplicated_models.len(), "sync_models_from_upstream: syncing models"
"sync_models_from_upstream: deduplicated upstream models"
); );
let mut models_created = 0i64; let mut models_created = 0i64;
@ -545,7 +479,7 @@ async fn sync_models_from_upstream(
let mut capabilities_created = 0i64; let mut capabilities_created = 0i64;
let mut profiles_created = 0i64; let mut profiles_created = 0i64;
for model in deduplicated_models { for model in &upstream_models {
let provider_slug = extract_provider_name(model); let provider_slug = extract_provider_name(model);
let provider = match upsert_provider(db, &provider_slug).await { let provider = match upsert_provider(db, &provider_slug).await {
Ok(p) => p, Ok(p) => p,
@ -559,7 +493,7 @@ async fn sync_models_from_upstream(
} }
}; };
let (model_record, _is_new) = match upsert_model_by_name(db, provider.id, model).await { let (model_record, _is_new) = match upsert_model_by_id(db, provider.id, model).await {
Ok((m, created)) => { Ok((m, created)) => {
if created { if created {
models_created += 1; models_created += 1;