F5: web search/fetch on the Kimi services (kimi-cli parity)
web_search now speaks the Kimi search service (kimi-cli tools/web/search.py,
wire-verified against api.kimi.com):
- POST {coding_base}/search with {text_query, limit 1-20 (default 5),
enable_page_crawling, timeout_seconds: 30}, OAuth bearer +
X-Msh-Tool-Call-Id; results render in kimi-cli's Title/Date/URL/Summary
schema with result URLs as citations.
- The old implementation called the xAI Responses API with a search model;
that client is fully replaced and the entire model-based config surface
is excised root-and-branch: web_search_model config keys/env/CLI plumbing,
resolve_web_search_sampling_config, toolset web_search SamplerConfig,
RemoteSettings.web_search_model, default_web_search_model.
- Enablement is now purely structural: the service exists only on the Kimi
Code subscription channel, so OAuth sessions get Enabled and API-key-only
sessions get Disabled (tool absent) — per PRD F5.
web_fetch gains the Kimi fetch service as its primary path (kimi-cli
tools/web/fetch.py): POST {coding_base}/fetch with {url}, Accept:
text/markdown, OAuth bearer + X-Msh-Tool-Call-Id; the 200 body is the
extracted markdown (still overflow-budgeted). Any service failure falls
back to the existing local pipeline (SSRF guards, cache, extraction).
The tool gate defaults ON now (kimi-cli always offers FetchURL) and the
egress User-Agent no longer claims grok-agent/x.ai.
Verified end-to-end against the scripted mock service: a headless session
drove web_search (limit/crawling/call-id observed on the wire) then
web_fetch (Accept + call-id observed) to completion.
This commit is contained in:
@@ -148,7 +148,7 @@ pub(crate) async fn spawn_session_actor(
|
||||
session_client_identifier: Option<String>,
|
||||
inference_idle_timeout_secs: u64,
|
||||
max_retries: Option<u32>,
|
||||
web_search_sampling_config: Option<kigi_sampler::SamplerConfig>,
|
||||
web_search_config: kigi_tools::implementations::WebSearchConfig,
|
||||
web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
|
||||
image_gen_config: kigi_tools::implementations::grok_build::image_gen::ImageGenConfig,
|
||||
video_gen_config: kigi_tools::implementations::grok_build::video_gen::VideoGenConfig,
|
||||
@@ -341,22 +341,8 @@ pub(crate) async fn spawn_session_actor(
|
||||
let primary_model_id = sampling_config.model.clone();
|
||||
let web_search_config = if disable_web_search {
|
||||
kigi_tools::implementations::WebSearchConfig::Disabled
|
||||
} else if let Some(cfg) = web_search_sampling_config {
|
||||
if let Some(api_key) = cfg.api_key {
|
||||
kigi_tools::implementations::WebSearchConfig::Enabled {
|
||||
api_key,
|
||||
base_url: cfg.base_url,
|
||||
model: cfg.model,
|
||||
extra_headers: cfg.extra_headers,
|
||||
alpha_test_key: credentials.alpha_test_key.clone(),
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("web_search disabled: resolved config has no API key");
|
||||
kigi_tools::implementations::WebSearchConfig::Disabled
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("web_search disabled: configured model could not be resolved");
|
||||
kigi_tools::implementations::WebSearchConfig::Disabled
|
||||
web_search_config
|
||||
};
|
||||
let embed_base_url = sampling_config.base_url.clone();
|
||||
let embed_api_key = sampling_config.api_key.clone();
|
||||
@@ -1597,7 +1583,7 @@ pub(crate) async fn spawn_session_on_thread(
|
||||
session_client_identifier: Option<String>,
|
||||
inference_idle_timeout_secs: u64,
|
||||
max_retries: Option<u32>,
|
||||
web_search_sampling_config: Option<kigi_sampler::SamplerConfig>,
|
||||
web_search_config: kigi_tools::implementations::WebSearchConfig,
|
||||
web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
|
||||
image_gen_config: kigi_tools::implementations::grok_build::image_gen::ImageGenConfig,
|
||||
video_gen_config: kigi_tools::implementations::grok_build::video_gen::VideoGenConfig,
|
||||
@@ -1744,7 +1730,7 @@ pub(crate) async fn spawn_session_on_thread(
|
||||
session_client_identifier,
|
||||
inference_idle_timeout_secs,
|
||||
max_retries,
|
||||
web_search_sampling_config,
|
||||
web_search_config,
|
||||
web_fetch_config,
|
||||
image_gen_config,
|
||||
video_gen_config,
|
||||
|
||||
@@ -1,151 +1,11 @@
|
||||
use axum::{Json, Router, extract::State, routing::post};
|
||||
use kigi_tools::computer::local::{LocalFs, LocalTerminalBackend};
|
||||
use kigi_tools::computer::types::{AsyncFileSystem, TerminalBackend};
|
||||
use kigi_tools::notification::ToolNotificationHandle;
|
||||
use kigi_tools::registry::types::{SessionContext, ToolConfig, ToolServerConfig};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_search_uses_model_override_from_config_end_to_end() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
|
||||
async fn handle_request(
|
||||
State(tx): State<tokio::sync::mpsc::UnboundedSender<Value>>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Json<Value> {
|
||||
let _ = tx.send(body);
|
||||
Json(json!({
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"status": "completed",
|
||||
"model": "enterprise-search",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": "search result",
|
||||
"annotations": []
|
||||
}]
|
||||
}]
|
||||
}))
|
||||
}
|
||||
let app = Router::new()
|
||||
.route("/responses", post(handle_request))
|
||||
.with_state(tx);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let raw_config: toml::Value = toml::from_str(&format!(
|
||||
r#"
|
||||
[models]
|
||||
web_search = "enterprise-search"
|
||||
|
||||
[model.enterprise-search]
|
||||
model = "enterprise-search"
|
||||
base_url = "http://{addr}"
|
||||
api_key = "enterprise-key"
|
||||
context_window = 256000
|
||||
api_backend = "responses"
|
||||
"#,
|
||||
))
|
||||
.unwrap();
|
||||
let web_search_model =
|
||||
crate::config::ModelOverrideConfig::resolve(None, None, &raw_config, None).web_search;
|
||||
let agent_cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
|
||||
let models = crate::agent::config::resolve_model_list(&agent_cfg, None);
|
||||
let entry = models.get(web_search_model.as_str()).unwrap();
|
||||
let resolved = crate::agent::config::sampling_config_for_model(
|
||||
entry,
|
||||
crate::agent::config::resolve_credentials(entry, None),
|
||||
None,
|
||||
);
|
||||
let web_search_sampling = crate::tools::config::web_search_sampling_config(resolved);
|
||||
|
||||
let builder = crate::tools::bridge::ToolBridge::get_builder();
|
||||
let config = ToolServerConfig {
|
||||
tools: vec![ToolConfig {
|
||||
id: "GrokBuild:web_search".into(),
|
||||
params: None,
|
||||
name_override: None,
|
||||
params_name_overrides: None,
|
||||
description_override: None,
|
||||
behavior_version: None,
|
||||
kind: None,
|
||||
}],
|
||||
behavior_preset: None,
|
||||
};
|
||||
let fs: std::sync::Arc<dyn AsyncFileSystem> = std::sync::Arc::new(LocalFs);
|
||||
let terminal: std::sync::Arc<dyn TerminalBackend> =
|
||||
std::sync::Arc::new(LocalTerminalBackend::new());
|
||||
let ctx = SessionContext {
|
||||
backend: terminal,
|
||||
fs,
|
||||
cwd: std::env::temp_dir(),
|
||||
session_folder: std::env::temp_dir().join("grok-web-search-e2e"),
|
||||
session_env: std::sync::Arc::new(std::collections::HashMap::new()),
|
||||
notification_handle: ToolNotificationHandle::noop(),
|
||||
owner_session_id: None,
|
||||
parent_scheduler_handle: None,
|
||||
skills: vec![],
|
||||
state_path: std::env::temp_dir().join("grok-web-search-e2e/state.json"),
|
||||
memory_backend: None,
|
||||
web_search_config: kigi_tools::implementations::web_search::WebSearchConfig::Enabled {
|
||||
api_key: web_search_sampling.api_key.clone().unwrap(),
|
||||
base_url: web_search_sampling.base_url.clone(),
|
||||
model: web_search_sampling.model.clone(),
|
||||
extra_headers: web_search_sampling.extra_headers.clone(),
|
||||
// The optional extra access key is no longer carried on
|
||||
// `SamplerConfig`. The shell-level value flows in via
|
||||
// `Credentials` at session-spawn time; in this self-contained
|
||||
// test fixture there's no extra access key in scope.
|
||||
alpha_test_key: None,
|
||||
},
|
||||
web_fetch_config: Default::default(),
|
||||
lsp: None,
|
||||
image_gen_config: Default::default(),
|
||||
video_gen_config: Default::default(),
|
||||
app_builder_deployer_config: Default::default(),
|
||||
api_key_provider: None,
|
||||
auth_provider: None,
|
||||
attribution_callback: None,
|
||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
||||
};
|
||||
let bridge = crate::tools::bridge::ToolBridge::finalize_builder(builder, config, ctx)
|
||||
.await
|
||||
.expect("finalize_builder should succeed");
|
||||
let result = bridge
|
||||
.call(
|
||||
"web_search",
|
||||
json!({
|
||||
"query": "test query",
|
||||
"allowed_domains": ["example.com"]
|
||||
}),
|
||||
"web-search-e2e",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"web_search should succeed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let request = rx.recv().await.expect("mock server should receive request");
|
||||
assert_eq!(
|
||||
request.get("model").and_then(|v| v.as_str()),
|
||||
Some(web_search_model.as_str())
|
||||
);
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_search_errors_when_configured_model_cannot_be_resolved() {
|
||||
async fn web_search_errors_when_disabled() {
|
||||
let builder = crate::tools::bridge::ToolBridge::get_builder();
|
||||
let config = ToolServerConfig {
|
||||
tools: vec![ToolConfig {
|
||||
|
||||
Reference in New Issue
Block a user