Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ serde = { version = "1", features = ["derive"] }
# Structured Outputs emit object keys in JSON Schema declaration order.
serde_json = { version = "1", features = ["preserve_order", "raw_value"] }
sse-stream = "0.2.3"
# Derives only the variant-name list for configuration error messages, keeping
# it in lockstep with serde's accepted wire names (WebSearchProviderKind::parse_name).
strum = { version = "0.27", features = ["derive"] }
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tokio-util = "0.7"
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ llm_api_base = "http://127.0.0.1:5050"
# database_url = "postgresql://agentic-api@localhost/agentic_api"

[web_search]
# Backend that serves the gateway-owned `web_search` tool: "you" (default) or
# "brave". Overridden by AGENTIC_WEB_SEARCH_PROVIDER.
provider = "you"
base_url = "https://api.ydc-index.io"
api_key_env = "YOU_API_KEY"

Expand Down Expand Up @@ -253,8 +256,19 @@ parsed. Order of precedence is `--max-request-body-size-bytes`, then `AGENTIC_MA
file setting.

`api_key_env` names the process environment variable containing the web-search credential; it does not contain the
credential itself. `YOU_API_BASE_URL`, `AGENTIC_MCP_ALLOWED_HOSTS`, `AGENTIC_MAX_REQUEST_BODY_SIZE_BYTES`, and
`AGENTIC_MAX_CONCURRENT_GATEWAY_CALLS` can override their typed file settings. The concurrency value is a sliding-window
credential itself. When `api_key_env` is unset, the selected provider's default name is used: `YOU_API_KEY` for
`"you"` and `BRAVE_API_KEY` for `"brave"`, so switching providers never requires editing the configuration file.
`AGENTIC_WEB_SEARCH_PROVIDER`, `AGENTIC_WEB_SEARCH_BASE_URL`,
`YOU_API_BASE_URL`, `AGENTIC_MCP_ALLOWED_HOSTS`, `AGENTIC_MAX_REQUEST_BODY_SIZE_BYTES`, and
`AGENTIC_MAX_CONCURRENT_GATEWAY_CALLS` can override their typed file settings.
`AGENTIC_WEB_SEARCH_BASE_URL` applies to the alternative `brave` provider; You.com
keeps its historical `YOU_API_BASE_URL` override. The `brave` provider (Brave
Search API) is the default alternative to You.com: it clamps `count` to its 20-result
per-section cap, has no server-side domain filtering (domain allow/block lists are
post-filtered client-side), and runs at most one request in flight to respect the
free-tier ~1 QPS rate limit. One consequence of client-side filtering: You.com rejects a request that combines
`include_domains` with `exclude_domains`, while `brave` accepts the combination and applies the blocklist on top of
the allowlist. The concurrency value is a sliding-window
upper bound; handlers may further serialize calls to the same tool name. The MCP allowlist is used only for
request-declared remote MCP URLs; configured `[mcp_servers]` entries are trusted operator configuration.

Expand Down
1 change: 1 addition & 0 deletions crates/agentic-server-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ rmcp = { workspace = true, features = [
serde.workspace = true
serde_json.workspace = true
sse-stream.workspace = true
strum.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["time"] }
tokio-util = { workspace = true, features = ["rt"] }
Expand Down
116 changes: 108 additions & 8 deletions crates/agentic-server-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,23 +90,61 @@ impl Default for SqliteConfig {
/// Backend that serves the gateway-owned `web_search` tool.
///
/// Additional providers are added here (#291). The enum is non-exhaustive so
/// downstream crates keep a fallback arm when a new variant lands. Selecting a
/// provider through [`WebSearchProviderConfig`] is deferred until a second
/// provider exists.
/// downstream crates keep a fallback arm when a new variant lands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
#[derive(strum::EnumIter, strum::VariantNames)]
#[strum(serialize_all = "snake_case")]
pub enum WebSearchProviderKind {
#[default]
You,
Brave,
}

impl WebSearchProviderKind {
/// Every provider kind's `snake_case` name, in declaration order. Powers
/// configuration error messages without duplicating the variant list.
pub const VARIANTS: &'static [&'static str] = <Self as strum::VariantNames>::VARIANTS;

/// Iterates over every provider kind.
pub fn variants() -> impl Iterator<Item = Self> {
<Self as strum::IntoEnumIterator>::iter()
}

/// Parses a provider name using the same `snake_case` wire names serde
/// accepts in configuration files, so environment parsing and file parsing
/// share one set of accepted names. Environment values are trimmed and
/// case-insensitive, matching operator expectations; the error carries the
/// caller's context.
///
/// This deliberately round-trips through serde instead of strum's
/// `EnumString`: `FromStr` would derive a parallel name set that only a
/// test (not the type system) keeps in sync with serde's, whereas routing
/// through serde makes file and environment parsing consistent by
/// construction.
///
/// # Errors
///
/// Returns [`crate::error::Error::Config`] when `value` (trimmed,
/// case-insensitively) is not a `snake_case` name of a known provider
/// variant.
pub fn parse_name(context: &str, value: &str) -> Result<Self, crate::error::Error> {
let normalized = value.trim().to_ascii_lowercase();
serde_json::from_value::<Self>(serde_json::Value::String(normalized)).map_err(|_| {
crate::error::Error::Config(format!(
"invalid {context} value '{value}': expected one of '{}'",
Self::VARIANTS.join("', '")
))
})
}

/// Environment variable that conventionally carries this provider's API key.
#[must_use]
pub const fn default_api_key_env(self) -> &'static str {
match self {
Self::You => "YOU_API_KEY",
Self::Brave => "BRAVE_API_KEY",
}
}

Expand All @@ -115,6 +153,7 @@ impl WebSearchProviderKind {
pub const fn display_name(self) -> &'static str {
match self {
Self::You => "You.com",
Self::Brave => "Brave",
}
}
}
Expand All @@ -125,25 +164,35 @@ impl std::fmt::Display for WebSearchProviderKind {
}
}

/// Credentials for the gateway-owned `web_search` provider (You.com).
/// Credentials and selection for the gateway-owned `web_search` provider.
///
/// `kind` chooses the backend; the credential and endpoint are resolved per
/// provider at deployment time.
#[derive(Clone, Default)]
pub struct WebSearchProviderConfig {
pub kind: WebSearchProviderKind,
pub api_key: Option<String>,
pub base_url: Option<String>,
}

impl WebSearchProviderConfig {
/// Builds the config from the credential and endpoint the deployment resolved.
/// Builds the config from the selection and credential the deployment
/// resolved.
#[must_use]
pub const fn new(api_key: Option<String>, base_url: Option<String>) -> Self {
Self { api_key, base_url }
pub fn new(kind: WebSearchProviderKind, api_key: Option<String>, base_url: Option<String>) -> Self {
Self {
kind,
api_key,
base_url,
}
}
}

impl std::fmt::Debug for WebSearchProviderConfig {
/// Redacts `api_key` so debug-printing any enclosing config never logs the secret.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebSearchProviderConfig")
.field("kind", &self.kind)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("base_url", &self.base_url)
.finish()
Expand Down Expand Up @@ -300,6 +349,7 @@ mod tests {
#[test]
fn web_search_provider_config_debug_redacts_api_key() {
let config = WebSearchProviderConfig::new(
WebSearchProviderKind::You,
Some("super-secret-key".to_owned()),
Some("https://api.example".to_owned()),
);
Expand All @@ -315,17 +365,67 @@ mod tests {
assert!(!format!("{tools:?}").contains("super-secret-key"));
assert_eq!(
format!("{:?}", WebSearchProviderConfig::default()),
"WebSearchProviderConfig { api_key: None, base_url: None }"
"WebSearchProviderConfig { kind: You, api_key: None, base_url: None }"
);
}

#[test]
fn web_search_provider_kind_labels() {
assert_eq!(WebSearchProviderKind::You.to_string(), "You.com");
assert_eq!(WebSearchProviderKind::You.default_api_key_env(), "YOU_API_KEY");
assert_eq!(WebSearchProviderKind::Brave.to_string(), "Brave");
assert_eq!(WebSearchProviderKind::Brave.default_api_key_env(), "BRAVE_API_KEY");
assert_eq!(WebSearchProviderKind::default(), WebSearchProviderKind::You);
}

#[test]
fn web_search_provider_kind_variants_stay_in_sync() {
// strum's variant list and iterator must match serde's accepted wire
// names so configuration errors and parsing never drift apart.
assert_eq!(WebSearchProviderKind::VARIANTS, ["you", "brave"]);
let names: Vec<String> = WebSearchProviderKind::variants().map(|kind| kind.to_string()).collect();
assert_eq!(names, ["You.com", "Brave"]);
for name in WebSearchProviderKind::VARIANTS {
let parsed: WebSearchProviderKind =
serde_json::from_value(serde_json::Value::String((*name).to_owned())).expect("serde parses variant");
// Round-tripping the strum-derived name through serde proves the
// two derive the same set of accepted wire names.
let rendered = serde_json::to_value(parsed).expect("serde serializes variant");
assert_eq!(rendered, serde_json::Value::String((*name).to_owned()));
}
}

#[test]
fn web_search_provider_kind_parse_name_is_trimmed_and_case_insensitive() {
// Environment values are trimmed and case-insensitive, so common
// operator spellings such as `Brave` or ` brave ` are accepted.
assert_eq!(
WebSearchProviderKind::parse_name("env", "brave").expect("parses"),
WebSearchProviderKind::Brave
);
assert_eq!(
WebSearchProviderKind::parse_name("env", "Brave").expect("parses"),
WebSearchProviderKind::Brave
);
assert_eq!(
WebSearchProviderKind::parse_name("env", " BRAVE ").expect("parses"),
WebSearchProviderKind::Brave
);
assert_eq!(
WebSearchProviderKind::parse_name("env", "you").expect("parses"),
WebSearchProviderKind::You
);

// Unknown names are rejected, and the error quotes the original value.
let error = WebSearchProviderKind::parse_name("TEST_PROVIDER", "nope").expect_err("rejects unknown");
assert!(
error
.to_string()
.contains("invalid TEST_PROVIDER value 'nope': expected one of 'you', 'brave'"),
"unexpected error message: {error}"
);
}

#[test]
fn strip_trailing_v1() {
assert_eq!(normalize_base_url("http://host:8000/v1"), "http://host:8000");
Expand Down
3 changes: 2 additions & 1 deletion crates/agentic-server-core/src/tool/executors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ impl GatewayExecutors {
} else {
config.mcp_allowed_hosts.clone()
},
web_search: Some(Arc::new(WebSearchHandler::from_values(
web_search: Some(Arc::new(WebSearchHandler::from_provider(
client,
config.web_search.kind,
config.web_search.api_key.clone(),
config.web_search.base_url.clone(),
config.max_concurrent_gateway_calls,
Expand Down
30 changes: 25 additions & 5 deletions crates/agentic-server-core/src/tool/web_search/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! filtering.

use std::fmt;
use std::num::NonZeroUsize;
use std::str::FromStr;

use chrono::NaiveDate;
Expand Down Expand Up @@ -205,22 +206,19 @@ pub(crate) fn clean_vec(values: Option<&[String]>) -> Option<Vec<String>> {
}

/// Provider-neutral domain post-filter for providers without server-side
/// `include_domains` / `exclude_domains` support.
/// `include_domains` / `exclude_domains` support (e.g. Brave).
///
/// A host matches a domain when it equals the domain or ends with `.{domain}`
/// (label boundary), compared case-insensitively after IDNA normalization. A
/// URL without a parseable host cannot be checked, so it is rejected whenever
/// any allowlist or blocklist is active (fail closed). You.com filters
/// server-side, so this is not applied on that path; the first provider that
/// needs it wires it in (#291 Phase 2).
/// server-side, so this is not applied on that path.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[allow(dead_code)] // wired by the first provider without server-side filtering (#291 Phase 2)
pub(crate) struct DomainFilter {
include: Vec<String>,
exclude: Vec<String>,
}

#[allow(dead_code)] // wired by the first provider without server-side filtering (#291 Phase 2)
impl DomainFilter {
pub(crate) fn new(include: Option<&[String]>, exclude: Option<&[String]>) -> Self {
Self {
Expand Down Expand Up @@ -278,10 +276,32 @@ fn host_matches_domain(host: &str, domain: &str) -> bool {
host == domain || host.strip_suffix(domain).is_some_and(|prefix| prefix.ends_with('.'))
}

/// Caps the requested query concurrency at a provider's own ceiling.
///
/// `None` leaves the requested value untouched; otherwise the result is
/// `min(requested, ceiling)`.
pub(crate) fn cap_provider_concurrency(ceiling: Option<NonZeroUsize>, requested: NonZeroUsize) -> NonZeroUsize {
ceiling.map_or(requested, |cap| requested.min(cap))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn provider_concurrency_cap_bounds_the_request() {
let requested = NonZeroUsize::new(5).unwrap();
assert_eq!(cap_provider_concurrency(None, requested), requested);
assert_eq!(
cap_provider_concurrency(Some(NonZeroUsize::new(2).unwrap()), requested),
NonZeroUsize::new(2).unwrap()
);
assert_eq!(
cap_provider_concurrency(Some(NonZeroUsize::new(8).unwrap()), requested),
requested
);
}

fn result(url: &str) -> WebSearchResult {
WebSearchResult {
url: url.to_owned(),
Expand Down
Loading