Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 7934cfb

Browse files
fix(catalog): normalize tool names with trimming and case-insensitive catalog matching (#2439)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent e077ddc commit 7934cfb

8 files changed

Lines changed: 179 additions & 82 deletions

File tree

.forge/commands/pr_description.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: pr-description
33
description: Updates the description of the PR
44
---
5-
- I have created a Pull Request with all the accepted changes
6-
- Understand the current PR deeply using the GH CLI and update the PR title and description
7-
- Make sure the title follows conventional commits standard
8-
- Top-level summary should contain 2-3 lines about the core functionality improvements
5+
6+
Create / Update the pull-request description and title using the pull-request skill.
7+
8+
{{parameters}}

crates/forge_app/src/template_engine.rs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
use std::sync::LazyLock;
2+
13
use forge_domain::Template;
24
use handlebars::{Handlebars, no_escape};
3-
use lazy_static::lazy_static;
45
use rust_embed::Embed;
56

67
#[derive(Embed)]
@@ -105,21 +106,20 @@ fn create_handlebar() -> Handlebars<'static> {
105106
hb
106107
}
107108

108-
lazy_static! {
109-
/// Global template engine instance with all custom helpers and templates registered.
110-
///
111-
/// This static instance is lazily initialized on first access and provides:
112-
/// - The 'inc' helper for incrementing values (useful for 1-based indexing)
113-
/// - The 'json' helper for serializing values to JSON strings
114-
/// - The 'contains' helper for checking if an array contains a value
115-
/// - Strict mode enabled
116-
/// - No HTML escaping
117-
/// - All embedded templates registered
118-
///
119-
/// Use this instance for template rendering throughout the application to avoid
120-
/// creating multiple Handlebars instances.
121-
static ref HANDLEBARS: Handlebars<'static> = create_handlebar();
122-
}
109+
/// Global template engine instance with all custom helpers and templates
110+
/// registered.
111+
///
112+
/// This static instance is lazily initialized on first access and provides:
113+
/// - The 'inc' helper for incrementing values (useful for 1-based indexing)
114+
/// - The 'json' helper for serializing values to JSON strings
115+
/// - The 'contains' helper for checking if an array contains a value
116+
/// - Strict mode enabled
117+
/// - No HTML escaping
118+
/// - All embedded templates registered
119+
///
120+
/// Use this instance for template rendering throughout the application to avoid
121+
/// creating multiple Handlebars instances.
122+
static HANDLEBARS: LazyLock<Handlebars<'static>> = LazyLock::new(create_handlebar);
123123

124124
/// A wrapper around the Handlebars template engine providing a simplified API.
125125
///

crates/forge_domain/src/tools/catalog.rs

Lines changed: 118 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#![allow(clippy::enum_variant_names)]
22
use std::borrow::Cow;
3-
use std::collections::HashSet;
3+
use std::collections::{HashMap, HashSet};
44
use std::path::{Path, PathBuf};
5+
use std::sync::LazyLock;
56

67
use convert_case::{Case, Casing};
78
use derive_more::From;
@@ -645,21 +646,31 @@ impl ToolDescription for ToolCatalog {
645646
}
646647
}
647648
}
648-
lazy_static::lazy_static! {
649-
// Cache of all tool names
650-
static ref FORGE_TOOLS: HashSet<ToolName> = ToolCatalog::iter()
651-
.map(ToolName::new)
652-
.collect();
653-
}
649+
// Cache of all tool names
650+
static FORGE_TOOLS: LazyLock<HashSet<ToolName>> =
651+
LazyLock::new(|| ToolCatalog::iter().map(ToolName::new).collect());
652+
653+
// Case-insensitive lookup map: lowercase tool name -> canonical tool name
654+
static FORGE_TOOLS_LOWER: LazyLock<HashMap<String, ToolName>> = LazyLock::new(|| {
655+
ToolCatalog::iter()
656+
.map(|tool| {
657+
let name = ToolName::new(tool.to_string());
658+
(name.as_str().to_lowercase(), name)
659+
})
660+
.collect()
661+
});
654662

655-
/// Normalizes tool names for backward compatibility
656-
/// Maps capitalized aliases to their lowercase canonical forms
663+
/// Normalizes a tool name received in a response before catalog matching.
664+
/// Trims surrounding whitespace and performs a case-insensitive lookup
665+
/// against all known catalog tool names, returning the canonical form when
666+
/// a match is found.
657667
fn normalize_tool_name(name: &ToolName) -> ToolName {
658-
match name.as_str() {
659-
"Read" => ToolName::new("read"),
660-
"Write" => ToolName::new("write"),
661-
_ => name.clone(),
662-
}
668+
let trimmed = name.as_str().trim();
669+
let lower = trimmed.to_lowercase();
670+
FORGE_TOOLS_LOWER
671+
.get(&lower)
672+
.cloned()
673+
.unwrap_or_else(|| ToolName::new(trimmed))
663674
}
664675

665676
impl ToolCatalog {
@@ -931,15 +942,17 @@ impl TryFrom<ToolCallFull> for ToolCatalog {
931942
type Error = crate::Error;
932943

933944
fn try_from(value: ToolCallFull) -> Result<Self, Self::Error> {
945+
// Normalize the tool name: trim whitespace and perform case-insensitive
946+
// catalog match so the serde deserialization receives the canonical name.
947+
let normalized_name = normalize_tool_name(&value.name);
948+
934949
let mut map = Map::new();
935-
map.insert("name".into(), value.name.as_str().into());
950+
map.insert("name".into(), normalized_name.as_str().into());
936951

937952
// Parse the arguments
938953
let parsed_args = value.arguments.parse()?;
939954

940955
// Try to find the tool definition and coerce types based on schema
941-
// Normalize the tool name for comparison
942-
let normalized_name = normalize_tool_name(&value.name);
943956
let coerced_args = ToolCatalog::iter()
944957
.find(|tool| tool.definition().name == normalized_name)
945958
.map(|tool| {
@@ -1606,4 +1619,92 @@ mod tests {
16061619

16071620
assert_eq!(actual, expected);
16081621
}
1622+
1623+
#[test]
1624+
fn test_normalize_tool_name_trims_whitespace() {
1625+
let actual = super::normalize_tool_name(&ToolName::new(" read "));
1626+
let expected = ToolName::new("read");
1627+
assert_eq!(actual, expected);
1628+
}
1629+
1630+
#[test]
1631+
fn test_normalize_tool_name_case_insensitive_uppercase() {
1632+
let actual = super::normalize_tool_name(&ToolName::new("READ"));
1633+
let expected = ToolName::new("read");
1634+
assert_eq!(actual, expected);
1635+
}
1636+
1637+
#[test]
1638+
fn test_normalize_tool_name_case_insensitive_mixed() {
1639+
let actual = super::normalize_tool_name(&ToolName::new("FS_SEARCH"));
1640+
let expected = ToolName::new("fs_search");
1641+
assert_eq!(actual, expected);
1642+
}
1643+
1644+
#[test]
1645+
fn test_normalize_tool_name_trim_and_case_insensitive() {
1646+
let actual = super::normalize_tool_name(&ToolName::new(" SHELL "));
1647+
let expected = ToolName::new("shell");
1648+
assert_eq!(actual, expected);
1649+
}
1650+
1651+
#[test]
1652+
fn test_normalize_tool_name_unknown_returns_trimmed() {
1653+
let actual = super::normalize_tool_name(&ToolName::new(" unknown_tool "));
1654+
let expected = ToolName::new("unknown_tool");
1655+
assert_eq!(actual, expected);
1656+
}
1657+
1658+
#[test]
1659+
fn test_contains_case_insensitive() {
1660+
assert!(ToolCatalog::contains(&ToolName::new("READ")));
1661+
assert!(ToolCatalog::contains(&ToolName::new("Shell")));
1662+
assert!(ToolCatalog::contains(&ToolName::new("PATCH")));
1663+
assert!(!ToolCatalog::contains(&ToolName::new("nonexistent")));
1664+
}
1665+
1666+
#[test]
1667+
fn test_contains_with_whitespace() {
1668+
assert!(ToolCatalog::contains(&ToolName::new(" read ")));
1669+
assert!(ToolCatalog::contains(&ToolName::new(" shell ")));
1670+
}
1671+
1672+
#[test]
1673+
fn test_try_from_tool_call_uppercase_name() {
1674+
use crate::{ToolCallArguments, ToolCallFull};
1675+
1676+
let tool_call = ToolCallFull {
1677+
name: ToolName::new("SHELL"),
1678+
call_id: None,
1679+
arguments: ToolCallArguments::from_json(r#"{"command": "ls"}"#),
1680+
thought_signature: None,
1681+
};
1682+
1683+
let actual = ToolCatalog::try_from(tool_call);
1684+
1685+
assert!(actual.is_ok(), "Should parse uppercase 'SHELL' tool name");
1686+
assert!(matches!(actual.unwrap(), ToolCatalog::Shell(_)));
1687+
}
1688+
1689+
#[test]
1690+
fn test_try_from_tool_call_with_whitespace_name() {
1691+
use crate::{ToolCallArguments, ToolCallFull};
1692+
1693+
let tool_call = ToolCallFull {
1694+
name: ToolName::new(" patch "),
1695+
call_id: None,
1696+
arguments: ToolCallArguments::from_json(
1697+
r#"{"file_path": "/test/file.rs", "new_string": "new", "old_string": "old"}"#,
1698+
),
1699+
thought_signature: None,
1700+
};
1701+
1702+
let actual = ToolCatalog::try_from(tool_call);
1703+
1704+
assert!(
1705+
actual.is_ok(),
1706+
"Should parse whitespace-padded 'patch' tool name"
1707+
);
1708+
assert!(matches!(actual.unwrap(), ToolCatalog::Patch(_)));
1709+
}
16091710
}

crates/forge_domain/src/workflow.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
use std::sync::LazyLock;
2+
13
use derive_setters::Setters;
2-
use lazy_static::lazy_static;
34
use merge::Merge;
45
use schemars::JsonSchema;
56
use serde::{Deserialize, Serialize};
@@ -119,10 +120,8 @@ pub struct Workflow {
119120
pub compact: Option<Compact>,
120121
}
121122

122-
lazy_static! {
123-
static ref DEFAULT_WORKFLOW: Workflow =
124-
serde_yml::from_str(include_str!("../../../forge.default.yaml")).unwrap();
125-
}
123+
static DEFAULT_WORKFLOW: LazyLock<Workflow> =
124+
LazyLock::new(|| serde_yml::from_str(include_str!("../../../forge.default.yaml")).unwrap());
126125

127126
impl Default for Workflow {
128127
fn default() -> Self {

crates/forge_main/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ mod zsh;
2323

2424
mod update;
2525

26+
use std::sync::LazyLock;
27+
2628
pub use cli::{Cli, TopLevelCommand};
27-
use lazy_static::lazy_static;
2829
pub use sandbox::Sandbox;
2930
pub use title_display::*;
3031
pub use ui::UI;
3132

32-
lazy_static! {
33-
pub static ref TRACKER: forge_tracker::Tracker = forge_tracker::Tracker::default();
34-
}
33+
pub static TRACKER: LazyLock<forge_tracker::Tracker> =
34+
LazyLock::new(forge_tracker::Tracker::default);

crates/forge_repo/src/provider/openai.rs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::sync::Arc;
1+
use std::sync::{Arc, LazyLock};
22

33
use anyhow::{Context as _, Result};
44
use derive_setters::Setters;
@@ -9,7 +9,6 @@ use forge_app::domain::{
99
};
1010
use forge_app::dto::openai::{ListModelResponse, ProviderPipeline, Request, Response};
1111
use forge_domain::{ChatRepository, Provider};
12-
use lazy_static::lazy_static;
1312
use reqwest::header::AUTHORIZATION;
1413
use tokio_stream::StreamExt;
1514
use tracing::{debug, info};
@@ -199,13 +198,10 @@ impl<H: HttpInfra> OpenAIProvider<H> {
199198

200199
/// Load Vertex AI models from static JSON file
201200
fn inner_vertex_models(&self) -> Vec<forge_app::domain::Model> {
202-
lazy_static! {
203-
static ref VERTEX_MODELS: Vec<forge_app::domain::Model> = {
204-
let models =
205-
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../vertex.json"));
206-
serde_json::from_str(models).unwrap()
207-
};
208-
}
201+
static VERTEX_MODELS: LazyLock<Vec<forge_app::domain::Model>> = LazyLock::new(|| {
202+
let models = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../vertex.json"));
203+
serde_json::from_str(models).unwrap()
204+
});
209205
VERTEX_MODELS.clone()
210206
}
211207
}

crates/forge_services/src/policy.rs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::path::PathBuf;
2-
use std::sync::Arc;
2+
use std::sync::{Arc, LazyLock};
33

44
use anyhow::Context;
55
use bytes::Bytes;
@@ -11,7 +11,6 @@ use forge_app::{
1111
DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra, FileReaderInfra, FileWriterInfra,
1212
PolicyDecision, PolicyService, UserInfra,
1313
};
14-
use lazy_static::lazy_static;
1514
use strum_macros::{Display, EnumIter};
1615

1716
/// User response for permission confirmation requests
@@ -32,14 +31,13 @@ pub enum PolicyPermission {
3231
pub struct ForgePolicyService<I> {
3332
infra: Arc<I>,
3433
}
35-
lazy_static! {
36-
/// Default policies loaded once at startup from the embedded YAML file
37-
static ref DEFAULT_POLICIES: PolicyConfig = {
38-
let yaml_content = include_str!("./permissions.default.yaml");
39-
serde_yml::from_str(yaml_content)
40-
.expect("Failed to parse default policies YAML. This should never happen as the YAML is embedded.")
41-
};
42-
}
34+
/// Default policies loaded once at startup from the embedded YAML file
35+
static DEFAULT_POLICIES: LazyLock<PolicyConfig> = LazyLock::new(|| {
36+
let yaml_content = include_str!("./permissions.default.yaml");
37+
serde_yml::from_str(yaml_content).expect(
38+
"Failed to parse default policies YAML. This should never happen as the YAML is embedded.",
39+
)
40+
});
4341

4442
impl<I> ForgePolicyService<I>
4543
where

crates/forge_tracker/src/dispatch.rs

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
use std::collections::HashSet;
22
use std::process::Output;
3-
use std::sync::Arc;
43
use std::sync::atomic::{AtomicBool, Ordering};
4+
use std::sync::{Arc, LazyLock};
55

66
use chrono::{DateTime, Utc};
77
use forge_domain::Conversation;
8-
use lazy_static::lazy_static;
98
use sysinfo::System;
109
use tokio::process::Command;
1110
use tokio::sync::Mutex;
@@ -27,20 +26,26 @@ const VERSION: &str = match option_env!("APP_VERSION") {
2726
};
2827

2928
// Cached system information that doesn't change during application lifetime
30-
lazy_static! {
31-
static ref CACHED_CORES: usize = System::physical_core_count().unwrap_or(0);
32-
static ref CACHED_CLIENT_ID: String = client_id::get_or_create_client_id()
33-
.unwrap_or_else(|_| client_id::DEFAULT_CLIENT_ID.to_string());
34-
static ref CACHED_OS_NAME: String = System::long_os_version().unwrap_or("Unknown".to_string());
35-
static ref CACHED_USER: String = whoami::username().unwrap_or_else(|_| "unknown".to_string());
36-
static ref CACHED_CWD: Option<String> = std::env::current_dir()
29+
static CACHED_CORES: LazyLock<usize> = LazyLock::new(|| System::physical_core_count().unwrap_or(0));
30+
static CACHED_CLIENT_ID: LazyLock<String> = LazyLock::new(|| {
31+
client_id::get_or_create_client_id()
32+
.unwrap_or_else(|_| client_id::DEFAULT_CLIENT_ID.to_string())
33+
});
34+
static CACHED_OS_NAME: LazyLock<String> =
35+
LazyLock::new(|| System::long_os_version().unwrap_or("Unknown".to_string()));
36+
static CACHED_USER: LazyLock<String> =
37+
LazyLock::new(|| whoami::username().unwrap_or_else(|_| "unknown".to_string()));
38+
static CACHED_CWD: LazyLock<Option<String>> = LazyLock::new(|| {
39+
std::env::current_dir()
3740
.ok()
38-
.and_then(|path| path.to_str().map(|s| s.to_string()));
39-
static ref CACHED_PATH: Option<String> = std::env::current_exe()
41+
.and_then(|path| path.to_str().map(|s| s.to_string()))
42+
});
43+
static CACHED_PATH: LazyLock<Option<String>> = LazyLock::new(|| {
44+
std::env::current_exe()
4045
.ok()
41-
.and_then(|path| path.to_str().map(|s| s.to_string()));
42-
static ref CACHED_ARGS: Vec<String> = std::env::args().skip(1).collect();
43-
}
46+
.and_then(|path| path.to_str().map(|s| s.to_string()))
47+
});
48+
static CACHED_ARGS: LazyLock<Vec<String>> = LazyLock::new(|| std::env::args().skip(1).collect());
4449

4550
#[derive(Clone)]
4651
pub struct Tracker {
@@ -245,9 +250,7 @@ fn parse_email(text: String) -> Vec<String> {
245250
mod tests {
246251
use super::*;
247252

248-
lazy_static! {
249-
static ref TRACKER: Tracker = Tracker::default();
250-
}
253+
static TRACKER: LazyLock<Tracker> = LazyLock::new(Tracker::default);
251254

252255
#[tokio::test]
253256
async fn test_tracker() {

0 commit comments

Comments
 (0)