Skip to content

Commit e42684f

Browse files
committed
feat(search): add structured engine fallback
1 parent 080d853 commit e42684f

10 files changed

Lines changed: 921 additions & 230 deletions

File tree

.github/setup-workspace.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ sed -i.bak \
1515
-e 's|a3s-memory = { version = "0.1.1", path = "../../memory" }|a3s-memory = "0.1.1"|' \
1616
-e 's|a3s-lane = { version = "0.5", path = "../../lane" }|a3s-lane = "0.5"|' \
1717
-e 's|a3s-lane = { version = "0.4", path = "../../lane" }|a3s-lane = "0.4"|' \
18-
-e 's|a3s-search = { version = "2.0.0", path = "../../search", default-features = false, features = \["lightpanda"\] }|a3s-search = { version = "2.0.0", default-features = false, features = ["lightpanda"] }|' \
18+
-e 's|a3s-search = { version = "2.1.0", path = "../../search", default-features = false, features = \["lightpanda"\] }|a3s-search = { version = "2.1.0", default-features = false, features = ["lightpanda"] }|' \
1919
-e 's|a3s-search = { version = "1.4.3", path = "../../search", default-features = false, features = \["lightpanda"\] }|a3s-search = { version = "1.4.3", default-features = false, features = ["lightpanda"] }|' \
2020
-e 's|a3s-search = { version = "1.3.0", path = "../../search", default-features = false, features = \["lightpanda"\] }|a3s-search = { version = "1.3.0", default-features = false, features = ["lightpanda"] }|' \
2121
-e 's|a3s-search = { version = "1.2.3", path = "../../search", default-features = false, features = \["lightpanda"\] }|a3s-search = { version = "1.2.3", default-features = false, features = ["lightpanda"] }|' \

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ object-only backend that cannot execute it.
280280
| Workspace search | `glob`, `grep` | Bounded matching with explicit result metadata and continuation |
281281
| Code Intelligence | `code_symbols`, `code_navigation`, `code_diagnostics` | Saved-file semantic metadata and locations with bounded results; source retrieval and mutation stay in the existing file tools |
282282
| Commands and source control | `bash`, `git` | Bounded output, cancellation, process-group termination on Unix, and typed Git operations |
283-
| Web evidence | `web_search`, `web_fetch` | Ranked multi-engine search, including native AnySearch and Tavily providers, normalized sources, semantic `<main>` extraction with `<body>` fallback, SSRF protections, and bounded pages |
283+
| Web evidence | `web_search`, `web_fetch` | Ranked multi-engine search with structured engine failures, policy-driven fallback, optional native AnySearch and Tavily providers, normalized sources, semantic `<main>` extraction with `<body>` fallback, SSRF protections, and bounded pages |
284284
| Structured output | `generate_object` | Schema-constrained model generation with validation and repair |
285285
| Composition | `batch`, `program` | Safe batch scheduling and sandboxed JavaScript programmatic tool calling |
286286
| Delegation | `task`, `parallel_task` | Foreground/background workers, bounded parallelism, partial results, and task tracking |
@@ -289,9 +289,13 @@ object-only backend that cannot execute it.
289289
| Dynamic workflows | `dynamic_workflow` | Explicitly registered A3S Flow-backed, replayable per-turn workflows |
290290

291291
Without an explicit request or `SearchConfig` engine selection, `web_search`
292-
uses AnySearch in its anonymous mode. Set `ANYSEARCH_API_KEY` to authenticate,
293-
or select `tavily`, `ddg`, `wiki`, and the other documented engines through
294-
the tool arguments or ACL configuration.
292+
uses DuckDuckGo and Wikipedia; AnySearch is not enabled by default. Configure
293+
the `search.engine` block in `config.acl` to replace that default selection,
294+
including an explicit `anysearch { enabled = true }` entry when desired. Set
295+
`ANYSEARCH_API_KEY` to authenticate it. When selected engines fail or return no
296+
usable result, the tool uses untried HTTP engines within the original timeout
297+
budget and reports the degradation through structured metadata and a visible
298+
notice.
295299

296300
Standalone greetings are conversational turns: the model receives no tool
297301
definitions and a friendly response is not converted into a synthetic

agent.example.acl

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@ search {
8181
suspend_seconds = 60
8282
}
8383

84-
# Engine configurations. Omit this block to use the built-in AnySearch
84+
# Engine configurations. Omit this block to use the built-in ddg + wiki
8585
# default; any entries here explicitly replace that default selection.
86+
# AnySearch is opt-in: add `anysearch { enabled = true weight = 1.0 }` here.
8687
engine {
8788
google {
8889
enabled = true
@@ -95,7 +96,7 @@ search {
9596
timeout = 20
9697
}
9798

98-
duckduckgo {
99+
ddg {
99100
enabled = true
100101
weight = 1.2
101102
}

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ path = "src/lib.rs"
1616
a3s-common = { version = "0.1.1", path = "../../common" }
1717
a3s-memory = { version = "0.1.2", path = "../../memory" }
1818
a3s-lane = { version = "0.5", path = "../../lane" }
19-
a3s-search = { version = "2.0.0", path = "../../search", default-features = false, features = ["lightpanda"] }
19+
a3s-search = { version = "2.1.0", path = "../../search", default-features = false, features = ["lightpanda"] }
2020
a3s-flow = { version = "0.4.2", path = "../../flow" }
2121

2222
# Async runtime

core/src/tools/builtin/batch.rs

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -283,22 +283,16 @@ fn compact_child_metadata(metadata: Option<serde_json::Value>) -> Option<serde_j
283283
"engine_selection_source",
284284
"selected_engines",
285285
"engine_fallback",
286+
"notices",
287+
"search_fallback",
286288
"artifact",
287289
] {
288290
if let Some(value) = metadata.get(key) {
289-
let value = if key == "selected_engines" {
290-
serde_json::Value::Array(
291-
value
292-
.as_array()
293-
.into_iter()
294-
.flatten()
295-
.filter_map(serde_json::Value::as_str)
296-
.take(8)
297-
.map(|engine| engine.chars().take(96).collect::<String>().into())
298-
.collect(),
299-
)
300-
} else {
301-
value.clone()
291+
let value = match key {
292+
"selected_engines" => compact_string_array(value, 8, 96),
293+
"notices" => compact_string_array(value, 4, 512),
294+
"search_fallback" => compact_search_fallback(value),
295+
_ => value.clone(),
302296
};
303297
compacted.insert(key.to_string(), value);
304298
if serde_json::to_vec(&compacted)
@@ -311,6 +305,63 @@ fn compact_child_metadata(metadata: Option<serde_json::Value>) -> Option<serde_j
311305
Some(serde_json::Value::Object(compacted))
312306
}
313307

308+
fn compact_string_array(
309+
value: &serde_json::Value,
310+
maximum_items: usize,
311+
maximum_chars: usize,
312+
) -> serde_json::Value {
313+
serde_json::Value::Array(
314+
value
315+
.as_array()
316+
.into_iter()
317+
.flatten()
318+
.filter_map(serde_json::Value::as_str)
319+
.take(maximum_items)
320+
.map(|item| item.chars().take(maximum_chars).collect::<String>().into())
321+
.collect(),
322+
)
323+
}
324+
325+
fn compact_search_fallback(value: &serde_json::Value) -> serde_json::Value {
326+
let Some(fallback) = value.as_object() else {
327+
return serde_json::Value::Null;
328+
};
329+
let mut compacted = serde_json::Map::new();
330+
for key in ["trigger", "mode", "attempted", "successful"] {
331+
if let Some(value) = fallback.get(key) {
332+
compacted.insert(key.to_string(), value.clone());
333+
}
334+
}
335+
if let Some(engines) = fallback.get("engines") {
336+
compacted.insert("engines".to_string(), compact_string_array(engines, 8, 96));
337+
}
338+
if let Some(failures) = fallback
339+
.get("failures")
340+
.and_then(serde_json::Value::as_array)
341+
{
342+
let failures = failures
343+
.iter()
344+
.take(8)
345+
.filter_map(serde_json::Value::as_object)
346+
.map(|failure| {
347+
let mut item = serde_json::Map::new();
348+
for key in ["engine", "provider", "kind", "transient"] {
349+
if let Some(value) = failure.get(key) {
350+
let value = value
351+
.as_str()
352+
.map(|text| text.chars().take(96).collect::<String>().into())
353+
.unwrap_or_else(|| value.clone());
354+
item.insert(key.to_string(), value);
355+
}
356+
}
357+
serde_json::Value::Object(item)
358+
})
359+
.collect();
360+
compacted.insert("failures".to_string(), serde_json::Value::Array(failures));
361+
}
362+
serde_json::Value::Object(compacted)
363+
}
364+
314365
// ============================================================================
315366
// Tests
316367
// ============================================================================
@@ -470,6 +521,20 @@ mod tests {
470521
"engine_selection_source": "config",
471522
"selected_engines": ["private-search", "x".repeat(5 * 1024)],
472523
"engine_fallback": null,
524+
"notices": ["AnySearch quota is exhausted", "x".repeat(5 * 1024)],
525+
"search_fallback": {
526+
"trigger": "engine_failure",
527+
"mode": "additional_engines",
528+
"attempted": true,
529+
"engines": ["brave", "bing"],
530+
"successful": true,
531+
"failures": [{
532+
"engine": "AnySearch",
533+
"provider": "anysearch",
534+
"kind": "provider_quota",
535+
"transient": false
536+
}]
537+
},
473538
"search_metrics": {
474539
"oversized": "x".repeat(5 * 1024)
475540
}
@@ -483,6 +548,13 @@ mod tests {
483548
assert_eq!(compacted["selected_engines"][0], "private-search");
484549
assert_eq!(compacted["selected_engines"][1].as_str().unwrap().len(), 96);
485550
assert!(compacted.get("engine_fallback").is_some());
551+
assert_eq!(compacted["notices"][0], "AnySearch quota is exhausted");
552+
assert_eq!(compacted["notices"][1].as_str().unwrap().len(), 512);
553+
assert_eq!(compacted["search_fallback"]["trigger"], "engine_failure");
554+
assert_eq!(
555+
compacted["search_fallback"]["failures"][0]["kind"],
556+
"provider_quota"
557+
);
486558
assert!(compacted.get("search_metrics").is_none());
487559
assert!(serde_json::to_vec(&compacted).unwrap().len() <= 4 * 1024);
488560
}

0 commit comments

Comments
 (0)