fix(engine): keep tool truncation notices in tool results

Stop emitting a user-facing Engine warning for ordinary output limiting while preserving structured size diagnostics and the model-visible truncation marker. Keep the Worker warning bridge for genuine warnings.

Regression coverage proves bounded ASCII/UTF-8 results, per-tool limits, attachments, dispositions and commit/model agreement, plus absence of live/buffered truncation alerts and preservation of other Engine alerts.

Validation: cargo check; cargo test -p agen; cargo test -p worker; targeted callback test with codex; cargo fmt --all -- --check; git diff --check HEAD.
This commit is contained in:
2026-10-08 17:33:15 +09:00
parent b715b1ee37
commit e939cd4e4b
4 changed files with 386 additions and 13 deletions
+204 -4
View File
@@ -1303,10 +1303,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
});
});
let alerter_for_worker = alerter.clone();
worker.on_warning(move |message| {
alerter_for_worker.alert(AlertLevel::Warn, AlertSource::Engine, message.to_owned());
});
wire_engine_warnings(worker, alerter);
// History-append broadcasts (previously `Event::SystemMessage`)
// have been removed: every persistent history item is now committed
@@ -1315,6 +1312,17 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
// per-item commit channel is wired at the top of this function.
}
/// Forward genuine Engine warnings to both live clients and the alert snapshot.
fn wire_engine_warnings<C: LlmClient, A: Send + Sync>(
engine: &mut agen::Engine<C, agen::state::Mutable, A>,
alerter: &Alerter,
) {
let alerter = alerter.clone();
engine.on_warning(move |message| {
alerter.alert(AlertLevel::Warn, AlertSource::Engine, message.to_owned());
});
}
fn validate_memory_lifecycle_targets(
memory: &manifest::MemoryFeatureProfileConfig,
subjektiv: &manifest::SubjektivFeatureProfileConfig,
@@ -3906,6 +3914,198 @@ mod tests {
use tempfile::TempDir;
use tokio::net::UnixListener;
mod engine_alerts {
use super::*;
use agen::llm_client::event::Event as LlmEvent;
use agen::llm_client::{ClientError, Item, Request, ToolCallCompletionSupport};
use agen::tool::{
Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolOutputLimits,
};
use agen::{Engine, EngineRunExit, History, ToolCallDispatchMode};
use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::Mutex;
#[derive(Clone)]
struct ScriptedClient {
responses: Arc<Mutex<VecDeque<Vec<LlmEvent>>>>,
requests: Arc<Mutex<Vec<Request>>>,
}
impl ScriptedClient {
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses.into())),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[async_trait::async_trait]
impl LlmClient for ScriptedClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
ToolCallCompletionSupport::ResponseComplete
}
async fn stream(
&self,
request: Request,
) -> Result<
Pin<Box<dyn futures::Stream<Item = Result<LlmEvent, ClientError>> + Send>>,
ClientError,
> {
self.requests.lock().unwrap().push(request);
let events = self
.responses
.lock()
.unwrap()
.pop_front()
.expect("unexpected LLM request");
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
}
}
struct LargeOutputTool;
#[async_trait::async_trait]
impl Tool for LargeOutputTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput {
summary: "Fixed large output".into(),
content: Some("useful output line\n".repeat(512)),
attachments: Vec::new(),
})
}
}
fn finished_response() -> Vec<LlmEvent> {
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "Done"),
LlmEvent::text_block_stop(0, None),
]
}
#[tokio::test]
async fn truncated_tool_output_preserves_model_result_without_live_or_buffered_alerts() {
let client = ScriptedClient::new(vec![
vec![
LlmEvent::tool_use_start(0, "large-call", "large_output"),
LlmEvent::tool_input_delta(0, "{}"),
LlmEvent::tool_use_stop(0),
],
finished_response(),
]);
let requests = client.requests.clone();
let mut engine = Engine::new(client);
// Isolate output limiting from the independent early-dispatch warning.
engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse);
engine.set_tool_output_limits(Some(ToolOutputLimits {
default_max_bytes: 256,
per_tool: Default::default(),
}));
engine.register_tool(Arc::new(|| {
(
ToolMeta::new("large_output")
.input_schema(serde_json::json!({"type": "object"})),
Arc::new(LargeOutputTool),
)
}));
let (tx, _) = broadcast::channel(8);
let alerter = Alerter::new(tx);
let (_, mut live) = alerter.subscribe_with_snapshot();
wire_engine_warnings(&mut engine, &alerter);
let output = engine.run(&mut History::new(), "Run the tool").await;
assert!(
matches!(output.result, EngineRunExit::Finished),
"{:?}",
output.result
);
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 2);
let result = requests[1].items.iter().find(|item| {
matches!(item, Item::ToolResult { call_id, .. } if call_id == "large-call")
}).expect("the next model request must include the bounded tool result");
match result {
Item::ToolResult {
summary,
content: Some(content),
is_error,
disposition,
..
} => {
assert_eq!(summary, "Fixed large output");
assert!(!is_error);
assert_eq!(*disposition, agen::ToolResultDisposition::Success);
assert!(content.len() <= 256, "{content}");
assert!(content.starts_with("useful output line\n"), "{content}");
assert!(content.contains("[truncated:"), "{content}");
assert!(content.contains("refine your query]"), "{content}");
}
other => panic!("unexpected model-visible result: {other:?}"),
}
assert!(matches!(
live.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
let (snapshot, _) = alerter.subscribe_with_snapshot();
assert!(snapshot.is_empty(), "unexpected alerts: {snapshot:?}");
}
#[tokio::test]
async fn unsupported_early_dispatch_warning_reaches_live_and_buffered_engine_alerts() {
let mut engine = Engine::new(ScriptedClient::new(vec![finished_response()]));
engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::OnToolCallComplete);
let (tx, _) = broadcast::channel(8);
let alerter = Alerter::new(tx);
let (_, mut live) = alerter.subscribe_with_snapshot();
wire_engine_warnings(&mut engine, &alerter);
let output = engine.run(&mut History::new(), "Finish").await;
assert!(
matches!(output.result, EngineRunExit::Finished),
"{:?}",
output.result
);
let alert = match live.try_recv() {
Ok(Event::Alert(alert)) => alert,
other => panic!("expected live Engine warning: {other:?}"),
};
assert_eq!(alert.level, AlertLevel::Warn);
assert_eq!(alert.source, AlertSource::Engine);
assert!(
alert.message.contains(
"provider does not expose a trustworthy per-call completion boundary"
),
"{}",
alert.message
);
assert!(matches!(
live.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
let (snapshot, mut late_live) = alerter.subscribe_with_snapshot();
assert_eq!(snapshot.len(), 1, "unexpected alerts: {snapshot:?}");
assert_eq!(snapshot[0].level, alert.level);
assert_eq!(snapshot[0].source, alert.source);
assert_eq!(snapshot[0].message, alert.message);
assert_eq!(snapshot[0].timestamp_ms, alert.timestamp_ms);
assert!(matches!(
late_live.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
}
}
#[test]
fn runtime_session_head_is_materialized_after_feature_installation_before_exposure() {
let production = include_str!("controller.rs")
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Separate from `tracing` (which is for developer logs). Alerts
//! are short human-readable messages the Worker layer wants a client to
//! see — for example "compaction failed", "tool output truncated".
//! see — for example "compaction failed", "tool-call early dispatch deferred".
//!
//! Each alert is broadcast on the shared `Event` channel and
//! also appended to an in-memory buffer so that clients connecting