Skip to content

Commit 7038d99

Browse files
authored
Merge pull request #24 from A3S-Lab/codex/code-search-hardening-20260722
release: prepare a3s-code 6.4.0
2 parents 2636193 + da232d8 commit 7038d99

26 files changed

Lines changed: 1132 additions & 306 deletions

.github/setup-workspace.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ 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"] }|' \
22+
-e 's|a3s-flow = { version = "0.4.3", path = "../../flow" }|a3s-flow = "0.4.3"|' \
2223
-e 's|a3s-flow = { version = "0.4.2", path = "../../flow" }|a3s-flow = "0.4.2"|' \
2324
-e 's|a3s-box-sdk = { version = "0.7", path = "../../box/src/sdk", optional = true }|a3s-box-sdk = { version = "0.7", optional = true }|' \
2425
core/Cargo.toml

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [6.4.0] - 2026-07-22
11+
12+
### Added
13+
14+
- Added structured per-engine search failure metadata and provider-agnostic
15+
fallback within the original request timeout budget.
16+
17+
### Changed
18+
19+
- Restored DuckDuckGo and Wikipedia as the built-in web-search defaults and
20+
made AnySearch an explicit request or ACL selection.
21+
- Preserved bounded fallback notices and failure metadata when web search runs
22+
inside a batch workflow.
23+
24+
### Fixed
25+
26+
- Accepted a complete schema-valid streamed object after a short terminal-event
27+
grace period, so an OpenAI-compatible endpoint that omits or delays its final
28+
`Done` event cannot turn an already generated result into a timeout.
29+
1030
## [6.3.1] - 2026-07-22
1131

1232
### Fixed

Cargo.lock

Lines changed: 3 additions & 13 deletions
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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-core"
3-
version = "6.3.1"
3+
version = "6.4.0"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"
@@ -16,8 +16,8 @@ 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"] }
20-
a3s-flow = { version = "0.4.2", path = "../../flow" }
19+
a3s-search = { version = "2.1.0", path = "../../search", default-features = false, features = ["lightpanda"] }
20+
a3s-flow = { version = "0.4.3", path = "../../flow" }
2121

2222
# Async runtime
2323
tokio = { version = "1.35", features = [

core/src/llm/structured.rs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,18 +303,65 @@ pub async fn generate_streaming(
303303

304304
let cancel_token = CancellationToken::new();
305305
let mut rx = client
306-
.complete_streaming_structured(&messages, Some(&system), &tools, &directive, cancel_token)
306+
.complete_streaming_structured(
307+
&messages,
308+
Some(&system),
309+
&tools,
310+
&directive,
311+
cancel_token.clone(),
312+
)
307313
.await
308314
.context("LLM streaming call failed during structured generation")?;
309315

310316
let mut json_buffer = String::new();
311317
let mut last_valid_partial: Option<Value> = None;
312318
let mut final_response: Option<super::LlmResponse> = None;
313319
let mut last_parse_len: usize = 0;
320+
let mut complete_candidate: Option<(Value, String, tokio::time::Instant)> = None;
314321
// Minimum bytes of new data before attempting a partial parse (reduces CPU)
315322
const PARSE_THRESHOLD: usize = 8;
316-
317-
while let Some(event) = rx.recv().await {
323+
// Well-behaved providers send Done immediately after the complete object.
324+
// A short grace preserves their final usage metadata while preventing an
325+
// otherwise valid result from hanging on a compatible endpoint that never
326+
// terminates its stream.
327+
const DONE_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
328+
loop {
329+
let event = if let Some((_, _, deadline)) = complete_candidate.as_ref() {
330+
tokio::select! {
331+
event = rx.recv() => event,
332+
_ = tokio::time::sleep_until(*deadline) => {
333+
let candidate = complete_candidate
334+
.take()
335+
.expect("complete streamed candidate exists");
336+
let (value, raw_text, _) = candidate;
337+
cancel_token.cancel();
338+
on_partial(&value);
339+
return Ok(StructuredResult {
340+
object: value,
341+
raw_text: Some(raw_text),
342+
usage: TokenUsage::default(),
343+
repair_rounds: 0,
344+
mode_used: mode,
345+
});
346+
}
347+
}
348+
} else {
349+
rx.recv().await
350+
};
351+
let Some(event) = event else {
352+
if let Some((value, raw_text, _)) = complete_candidate.take() {
353+
cancel_token.cancel();
354+
on_partial(&value);
355+
return Ok(StructuredResult {
356+
object: value,
357+
raw_text: Some(raw_text),
358+
usage: TokenUsage::default(),
359+
repair_rounds: 0,
360+
mode_used: mode,
361+
});
362+
}
363+
break;
364+
};
318365
match event {
319366
StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
320367
if final_response.is_some() {
@@ -334,6 +381,17 @@ pub async fn generate_streaming(
334381
}
335382
last_parse_len = json_buffer.len();
336383
}
384+
if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
385+
complete_candidate = resolve_structured(
386+
std::slice::from_ref(&json_buffer),
387+
&req.schema,
388+
envelope,
389+
)
390+
.valid
391+
.map(|(value, raw_text)| {
392+
(value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
393+
});
394+
}
337395
}
338396
StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
339397
if final_response.is_some() {
@@ -356,9 +414,21 @@ pub async fn generate_streaming(
356414
}
357415
last_parse_len = json_buffer.len();
358416
}
417+
if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
418+
complete_candidate = resolve_structured(
419+
std::slice::from_ref(&json_buffer),
420+
&req.schema,
421+
envelope,
422+
)
423+
.valid
424+
.map(|(value, raw_text)| {
425+
(value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
426+
});
427+
}
359428
}
360429
StreamEvent::Done(resp) => {
361430
final_response = Some(resp);
431+
break;
362432
}
363433
_ => {}
364434
}

core/src/llm/structured_tests.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,39 @@ impl LlmClient for MockStructuredClient {
155155
}
156156
}
157157

158+
struct CompleteObjectWithoutDoneClient;
159+
160+
#[async_trait]
161+
impl LlmClient for CompleteObjectWithoutDoneClient {
162+
async fn complete(
163+
&self,
164+
_messages: &[Message],
165+
_system: Option<&str>,
166+
_tools: &[ToolDefinition],
167+
) -> anyhow::Result<LlmResponse> {
168+
anyhow::bail!("blocking generation is not used in this test")
169+
}
170+
171+
async fn complete_streaming(
172+
&self,
173+
_messages: &[Message],
174+
_system: Option<&str>,
175+
_tools: &[ToolDefinition],
176+
cancel_token: CancellationToken,
177+
) -> anyhow::Result<mpsc::Receiver<StreamEvent>> {
178+
let (tx, rx) = mpsc::channel(4);
179+
tokio::spawn(async move {
180+
tx.send(StreamEvent::TextDelta(
181+
r#"{"color":"blue","count":3}"#.to_string(),
182+
))
183+
.await
184+
.ok();
185+
cancel_token.cancelled().await;
186+
});
187+
Ok(rx)
188+
}
189+
}
190+
158191
// ========================================================================
159192
// extract_json_value tests
160193
// ========================================================================
@@ -1211,6 +1244,39 @@ async fn test_generate_streaming_text_mode() {
12111244
let result = generate_streaming(&client, &req, callback).await.unwrap();
12121245
assert_eq!(result.object["color"], "blue");
12131246
assert_eq!(result.object["count"], 3);
1247+
assert_eq!(result.usage.total_tokens, 15);
1248+
}
1249+
1250+
#[tokio::test]
1251+
async fn test_generate_streaming_accepts_a_complete_valid_object_without_done() {
1252+
let req = StructuredRequest {
1253+
prompt: "test".to_string(),
1254+
system: None,
1255+
schema: serde_json::json!({
1256+
"type": "object",
1257+
"additionalProperties": false,
1258+
"required": ["color", "count"],
1259+
"properties": {
1260+
"color": {"type": "string"},
1261+
"count": {"type": "integer"}
1262+
}
1263+
}),
1264+
schema_name: "result".to_string(),
1265+
schema_description: None,
1266+
mode: StructuredMode::Prompt,
1267+
max_repair_attempts: 0,
1268+
};
1269+
1270+
let result = tokio::time::timeout(
1271+
std::time::Duration::from_secs(1),
1272+
generate_streaming(&CompleteObjectWithoutDoneClient, &req, Box::new(|_| {})),
1273+
)
1274+
.await
1275+
.expect("a schema-valid streamed object must not wait forever for Done")
1276+
.expect("complete streamed object");
1277+
1278+
assert_eq!(result.object["color"], "blue");
1279+
assert_eq!(result.object["count"], 3);
12141280
}
12151281

12161282
#[tokio::test]

0 commit comments

Comments
 (0)