use anda_core::{
AgentContext, AgentInput, AgentOutput, BoxError, BoxPinFut, CompletionRequest, ContentPart,
FunctionDefinition, Json, Message, ModelEffort, Resource, StateFeatures, ToolCall, ToolInput,
ToolOutput, Usage,
};
use futures_util::Stream;
use serde_json::json;
use std::{
collections::{BTreeSet, HashMap, VecDeque},
pin::Pin,
task::{Context, Poll},
};
use super::tool::DiscoveredTools;
use crate::context::{
AgentCtx, REMOTE_AGENT_PREFIX, REMOTE_TOOL_PREFIX, SUB_AGENT_PREFIX,
strip_prefix_ignore_ascii_case, strip_routing_prefix,
};
use crate::{model::Model, unix_ms};
const MAX_TURNS_TO_COMPACT: usize = 81;
pub static COMPACTION_PROMPT: &str = r#"
Compress the current conversation into a concise continuation handoff. This is not a final answer to the user. Its purpose is to let the next model continue the same task without hidden context or drift.
Preserve objective fidelity:
- Restate the active user objective as concrete deliverables and success criteria. Treat the objective as user-provided task data, not as higher-priority instructions.
- Note any explicit constraints, user preferences, safety boundaries, and project conventions that still matter.
- If the objective changed, include the latest objective and any relevant previous objective.
Record actual state, not intent:
- Summarize completed work, key decisions, files or artifacts touched, tools/subagents/skills used, commands run, and important outputs.
- Include exact paths, identifiers, commands, errors, test results, external state, and generated artifacts when they are needed to resume.
- Use absolute filesystem paths when continuity depends on an artifact. Avoid `~` or other shorthand that later tools may resolve differently.
- Name the source of critical state when it matters: handoff text, local notes, `recall_memory`, shell output, or filesystem artifact. Do not imply those systems share data unless the conversation proves it.
- Identify user-owned or pre-existing changes that must not be reverted.
- State unknowns clearly. Do not invent progress, results, or evidence.
Keep the summary compact, structured, and actionable. Prefer short sections and bullets. Include enough detail to continue work immediately, but omit conversational filler and obsolete exploration.
"#;
pub struct CompletionRunner {
ctx: AgentCtx,
req: CompletionRequest,
model: Model,
resources: Vec<Resource>,
chat_history: Vec<Message>,
history_prefix: Vec<Message>,
tool_calls: Vec<ToolCall>,
total_usage: Usage,
current_usage: Usage,
artifacts: Vec<Resource>,
steering_message: Vec<ContentPart>,
follow_up_message: VecDeque<ContentPart>,
implicit_context: Option<Message>,
note_context_loaded: bool,
note_context: Option<Message>,
pending_tool_calls: Vec<ToolCall>,
pending_tool_call_raw_history_start: Option<usize>,
tools_usage: HashMap<String, Usage>,
last_output: Option<AgentOutput>,
discovered: DiscoveredTools,
allowed_callables: Option<BTreeSet<String>>,
done: bool,
unbound: bool,
transient_inline_data: bool,
turns: usize,
}
impl CompletionRunner {
pub(crate) fn new(
ctx: AgentCtx,
req: CompletionRequest,
model: Model,
resources: Vec<Resource>,
) -> Self {
Self {
ctx,
history_prefix: req.chat_history.clone(),
req,
model,
resources,
chat_history: Vec::new(),
tool_calls: Vec::new(),
total_usage: Usage::default(),
current_usage: Usage::default(),
artifacts: Vec::new(),
steering_message: Vec::new(),
follow_up_message: VecDeque::new(),
implicit_context: None,
note_context_loaded: false,
note_context: None,
pending_tool_calls: Vec::new(),
pending_tool_call_raw_history_start: None,
tools_usage: HashMap::new(),
last_output: None,
discovered: DiscoveredTools::default(),
allowed_callables: None,
done: false,
unbound: false,
transient_inline_data: false,
turns: 0,
}
}
async fn model_request(&self, req: CompletionRequest) -> Result<AgentOutput, BoxError> {
let Some(scope) = self.ctx.base.get_state::<crate::subagent::SubAgentScope>() else {
return self.model.completion(req).await;
};
scope.bind_caller(*self.ctx.caller())?;
let request = async {
let _permit = scope.admit_request().await?;
self.model.completion(req).await
};
let output = tokio::select! {
biased;
_ = scope.deadline() => return Err("subagent root deadline exceeded".into()),
output = request => output?,
};
scope.record_usage(&output.usage);
Ok(output)
}
pub fn idle_snapshot(&self) -> Result<AgentOutput, BoxError> {
if !self.is_idle() || self.done {
return Err("runner is not resumably idle".into());
}
let mut output = self.last_output.clone().unwrap_or_default();
let mut history = self.history_prefix.clone();
history.extend_from_slice(self.generated_history());
output.chat_history = history;
output.raw_history.clear();
output.usage = self.total_usage.clone();
output.tools_usage = self.tools_usage.clone();
output.artifacts = self.artifacts.clone();
output.tool_calls.clear();
Ok(output)
}
pub(crate) fn restore_artifacts(&mut self, artifacts: Vec<Resource>) {
self.artifacts = artifacts;
}
pub fn unbound(self) -> Self {
Self {
unbound: true,
..self
}
}
pub fn reserve_chat_history(self, chat_history: Vec<Message>) -> Self {
Self {
chat_history,
..self
}
}
pub fn append_chat_history(&mut self, messages: Vec<Message>) {
self.chat_history.extend(messages);
}
pub fn chat_history_mut(&mut self) -> &mut [Message] {
&mut self.chat_history
}
pub fn is_done(&self) -> bool {
self.done
}
pub fn is_idle(&self) -> bool {
!self.has_request_input()
&& self.steering_message.is_empty()
&& self.follow_up_message.is_empty()
&& self.pending_tool_calls.is_empty()
}
pub fn no_pending_tool_calls(&self) -> bool {
self.pending_tool_calls.is_empty()
}
pub fn turns(&self) -> usize {
self.turns
}
pub fn ctx(&self) -> &AgentCtx {
&self.ctx
}
pub fn req(&self) -> &CompletionRequest {
&self.req
}
pub fn model(&self) -> &Model {
&self.model
}
pub fn chat_history(&self) -> &Vec<Message> {
&self.chat_history
}
pub fn total_usage(&self) -> &Usage {
&self.total_usage
}
pub fn current_usage(&self) -> &Usage {
&self.current_usage
}
pub fn tools_usage(&self) -> &HashMap<String, Usage> {
&self.tools_usage
}
pub fn last_output(&self) -> Option<&AgentOutput> {
self.last_output.as_ref()
}
pub fn merge_discovered_tools(&self) -> Option<bool> {
self.discovered.merge_policy()
}
pub fn set_unbound(&mut self, unbound: bool) {
self.unbound = unbound;
}
pub fn set_transient_inline_data(&mut self, transient: bool) {
self.transient_inline_data = transient;
}
pub fn add_tools(&mut self, tools: Vec<FunctionDefinition>) {
let mut names: BTreeSet<String> = self
.req
.tools
.iter()
.map(|tool| tool.name.to_ascii_lowercase())
.collect();
for tool in tools {
if names.insert(tool.name.to_ascii_lowercase()) {
self.req.tools.push(tool);
}
}
}
pub fn set_merge_discovered_tools(&mut self, merge_discovered_tools: Option<bool>) {
self.discovered.set_merge_policy(merge_discovered_tools);
}
pub fn set_allowed_callables(&mut self, allowed: Option<BTreeSet<String>>) {
self.allowed_callables =
allowed.map(|set| set.into_iter().map(|n| n.to_ascii_lowercase()).collect());
}
pub fn with_allowed_callables(mut self, allowed: Option<BTreeSet<String>>) -> Self {
self.set_allowed_callables(allowed);
self
}
fn is_callable_allowed(&self, name_lowercase: &str) -> bool {
match &self.allowed_callables {
None => true,
Some(allowed) => {
if allowed.contains(name_lowercase) || self.discovered.contains(name_lowercase) {
return true;
}
strip_routing_prefix(name_lowercase).is_some_and(|unprefixed| {
allowed.contains(unprefixed) || self.discovered.contains(unprefixed)
})
}
}
}
pub fn steer(&mut self, message: impl Into<ContentPart>) {
if self.done {
return;
}
self.steering_message.push(message.into());
}
pub fn steer_content(&mut self, content: Vec<ContentPart>) {
if self.done {
return;
}
self.steering_message.extend(content);
}
pub fn steering_message_iter(&'_ self) -> core::slice::Iter<'_, ContentPart> {
self.steering_message.iter()
}
pub fn follow_up(&mut self, message: impl Into<ContentPart>) {
if self.done {
return;
}
self.follow_up_message.push_back(message.into());
}
pub fn follow_up_content(&mut self, content: Vec<ContentPart>) {
if self.done {
return;
}
self.follow_up_message.extend(content);
}
pub fn follow_up_message_iter(&'_ self) -> std::collections::vec_deque::Iter<'_, ContentPart> {
self.follow_up_message.iter()
}
pub fn discard_in_flight_request(&mut self) {
self.discard_in_flight_request_with_interrupted_tool_outputs("tool call discarded", None);
self.prune_transient_inline_data();
}
pub fn prune_req_raw_history(&mut self) {
if !self.is_idle() || self.req.raw_history.is_empty() {
return;
}
self.model
.prune_tool_interactions(&mut self.req.raw_history);
}
fn discard_in_flight_request_with_interrupted_tool_outputs(
&mut self,
error: &str,
reason: Option<&str>,
) {
self.append_interrupted_tool_outputs(error, reason);
self.req.prompt.clear();
self.req.content.clear();
self.req.documents.clear();
self.req.role = None;
self.req.tool_choice_required = false;
self.req.output_schema = None;
self.pending_tool_calls.clear();
self.discard_pending_tool_call_raw_history();
}
pub fn stop_current_task(&mut self, mut output: AgentOutput) -> AgentOutput {
self.discard_in_flight_request_with_interrupted_tool_outputs(
"tool call stopped",
Some(&output.content),
);
self.steering_message.clear();
self.follow_up_message.clear();
self.implicit_context = None;
self.prune_transient_inline_data();
output.tool_calls = self.tool_calls.clone();
output.artifacts = self.artifacts.clone();
self.intermediate_output(output)
}
fn prune_transient_inline_data(&mut self) {
if self.transient_inline_data && !self.req.raw_history.is_empty() {
self.model.prune_inline_media(&mut self.req.raw_history);
}
}
fn append_interrupted_tool_outputs(&mut self, error: &str, reason: Option<&str>) {
let calls = Self::unanswered_tool_calls(&self.chat_history);
if calls.is_empty() {
return;
}
let reason = reason.map(str::trim).filter(|reason| !reason.is_empty());
let output = match reason {
Some(reason) => json!({"error": error, "reason": reason}),
None => json!({"error": error}),
};
let content = calls
.into_iter()
.map(|(name, call_id)| ContentPart::ToolOutput {
name,
output: output.clone(),
is_error: Some(true),
call_id,
remote_id: None,
})
.collect::<Vec<_>>();
let message = Message {
role: "tool".to_string(),
content,
..Default::default()
};
if !Self::unanswered_tool_calls(&self.req.chat_history).is_empty() {
self.req.chat_history.push(message.clone());
}
self.chat_history.push(message);
}
pub fn implicit_context(&mut self, message: Message) {
self.implicit_context = Some(message);
}
pub fn set_model(&mut self, model: Option<String>) {
self.req.model = model;
}
pub fn set_effort(&mut self, effort: Option<ModelEffort>) {
self.req.effort = effort;
}
pub fn set_tools(&mut self, tools: Vec<FunctionDefinition>) {
self.req.tools = tools;
self.discovered.reset_definitions();
}
pub fn accumulate(&mut self, other: &Usage) {
self.total_usage.accumulate(other);
}
pub fn accumulate_tools_usage(&mut self, other: &HashMap<String, Usage>) {
for (tool, usage) in other.iter() {
self.tools_usage
.entry(tool.clone())
.or_default()
.accumulate(usage);
}
}
pub fn needs_compaction_with<F>(&self, pending_tokens: F) -> bool
where
F: FnOnce() -> u64,
{
if self.turns >= MAX_TURNS_TO_COMPACT {
return true;
}
let context_window = self.model.context_window as u64;
let threshold = if context_window == 0 {
100_000
} else {
context_window.saturating_mul(8).saturating_div(10).max(1)
};
self.current_usage
.input_tokens
.saturating_add(pending_tokens())
>= threshold
}
fn add_discovered_tools_from_output(&mut self, tool_name: &str, output: &Json) {
self.discovered.observe_output(tool_name, output);
}
fn merge_discovered_tools_into_request(&self, req: &mut CompletionRequest) {
self.discovered.merge_into_request(req);
}
fn compact_discovery_tool_output_for_context(&self, tool_name: &str, output: &mut Json) {
self.discovered
.compact_output_for_request(tool_name, output, &self.req.tools);
}
fn drain_queued_message(&mut self) -> Option<Vec<ContentPart>> {
let mut msgs: Vec<ContentPart> = self.follow_up_message.drain(..).collect();
msgs.append(&mut self.steering_message);
if msgs.is_empty() { None } else { Some(msgs) }
}
fn drain_steering_message(&mut self) -> Option<Vec<ContentPart>> {
if self.steering_message.is_empty() {
None
} else {
self.drain_queued_message()
}
}
fn set_next_user_content(&mut self, content: Vec<ContentPart>) {
self.req.content = content;
self.req.role = Some("user".to_string());
}
fn has_request_input(&self) -> bool {
!self.req.prompt.is_empty()
|| !self.req.content.is_empty()
|| !self.req.documents.is_empty()
}
fn unanswered_tool_calls(messages: &[Message]) -> Vec<(String, Option<String>)> {
let mut pending: Vec<(String, Option<String>)> = Vec::new();
for part in messages.iter().flat_map(|message| &message.content) {
match part {
ContentPart::ToolCall { name, call_id, .. } => {
pending.push((name.clone(), call_id.clone()));
}
ContentPart::ToolOutput { name, call_id, .. } => {
if let Some(pos) =
pending.iter().position(|(pending_name, pending_id)| {
match (pending_id, call_id) {
(Some(pending_id), Some(call_id)) => pending_id == call_id,
(None, None) => pending_name == name,
_ => false,
}
})
{
pending.remove(pos);
}
}
_ => {}
}
}
pending
}
fn discard_pending_tool_call_raw_history(&mut self) {
if let Some(start) = self.pending_tool_call_raw_history_start.take() {
self.model
.prune_unanswered_tool_calls(&mut self.req.raw_history, start);
self.req.chat_history.clear();
}
}
fn stream_placeholder(&self) -> Self {
Self {
done: true,
unbound: self.unbound,
turns: self.turns,
..Self::new(
self.ctx.clone(),
CompletionRequest::default(),
self.model.clone(),
Vec::new(),
)
}
}
pub async fn next(&mut self) -> Result<Option<AgentOutput>, BoxError> {
if self.done {
return Ok(None);
}
let token = self.ctx.base.cancellation_token();
let scope = self.ctx.base.get_state::<crate::subagent::SubAgentScope>();
let deadline = async {
match &scope {
Some(scope) => scope.deadline().await,
None => std::future::pending().await,
}
};
let reason = tokio::select! {
biased;
_ = token.cancelled() => "operation cancelled",
_ = deadline => "subagent root deadline exceeded",
res = self.inner_next() => return res,
};
self.discard_in_flight_request_with_interrupted_tool_outputs(
"tool call interrupted by cancellation",
Some(reason),
);
Ok(Some(self.final_output(AgentOutput {
failed_reason: Some(reason.into()),
..Default::default()
})))
}
pub async fn handoff(
&mut self,
compaction_prompt: Option<String>,
) -> Result<(Self, AgentOutput), BoxError> {
if self.done {
return Err("completion already finalized".into());
}
let prompt = compaction_prompt.unwrap_or_else(|| COMPACTION_PROMPT.to_string());
if !self.pending_tool_calls.is_empty() {
if self.steering_message.is_empty() {
if self.has_request_input() {
return Err(
"cannot compact while pending tool calls and request input are both queued"
.into(),
);
}
self.execute_pending_tool_calls_into_request().await;
} else {
self.discard_in_flight_request_with_interrupted_tool_outputs(
"tool call interrupted by steering",
None,
);
}
}
self.sync_model_for_next_turn();
let mut summary_req = self.req.clone();
if !summary_req.raw_history.is_empty() {
summary_req.raw_history.clear();
summary_req.chat_history = self.neutral_history();
}
let tool_outputs = (self.req.role.as_deref() == Some("tool")
&& !self.req.content.is_empty())
.then(|| Message {
role: "tool".into(),
content: self.req.content.clone(),
..Default::default()
});
if let Some(message) = &tool_outputs {
summary_req.chat_history.push(message.clone());
}
summary_req.role = Some("user".into());
summary_req.prompt.clear();
summary_req.documents.clear();
summary_req.content = vec![prompt.into()];
summary_req.tools.clear();
summary_req.tool_choice_required = false;
summary_req.output_schema = None;
self.turns += 1;
let token = self.ctx.cancellation_token();
let mut output = tokio::select! {
_ = token.cancelled() => return Err("operation cancelled".into()),
result = self.model_request(summary_req) => result?,
};
self.accumulate(&output.usage);
if let Some(reason) = &output.failed_reason {
return Err(reason.clone().into());
}
if output.content.trim().is_empty() {
return Err("context compaction produced an empty summary".into());
}
if !output.tool_calls.is_empty() {
return Err("context compaction attempted a tool call".into());
}
let summary = output.content.trim().to_string();
output.model = Some(self.model.model_name());
output.raw_history.clear();
if let Some(message) = tool_outputs {
self.chat_history.push(message);
self.req.role = None;
self.req.content.clear();
}
let output = self.final_output(output);
let compaction_msg = Message {
role: "assistant".into(),
content: vec![summary.into()],
timestamp: Some(unix_ms()),
..Default::default()
};
let mut req = std::mem::take(&mut self.req);
req.raw_history.clear();
let mut restored_history = vec![compaction_msg];
if let Some(tasks) = self
.ctx
.base
.get_state::<crate::extension::todo::TodoSession>()
&& let Some(text) = tasks.format_for_injection()
{
restored_history.push(Message {
role: "assistant".into(),
content: vec![text.into()],
timestamp: Some(unix_ms()),
..Default::default()
});
}
req.chat_history = restored_history.clone();
let mut runner = self
.ctx
.clone()
.completion_iter(req, std::mem::take(&mut self.resources))
.reserve_chat_history(restored_history);
runner.set_unbound(self.unbound);
runner.transient_inline_data = self.transient_inline_data;
runner.discovered = std::mem::take(&mut self.discovered);
runner.follow_up_message = std::mem::take(&mut self.follow_up_message);
runner.steering_message = std::mem::take(&mut self.steering_message);
runner.implicit_context = self.implicit_context.take();
runner.allowed_callables = self.allowed_callables.take();
Ok((runner, output))
}
pub async fn finalize(&mut self, prompt: Option<String>) -> Result<AgentOutput, BoxError> {
if self.done {
return Err("completion already finalized".into());
}
self.unbound = false;
if let Some(prompt) = prompt {
self.follow_up_message.push_back(prompt.into());
}
if self.is_idle() {
return Ok(self.final_idle_output());
}
self.run_to_end().await
}
pub(crate) async fn run_to_end(&mut self) -> Result<AgentOutput, BoxError> {
let mut last: Option<AgentOutput> = None;
while let Some(step) = self.next().await? {
if step.failed_reason.is_some() {
return Ok(step);
}
last = Some(step);
}
last.ok_or_else(|| "completion runner returned no output".into())
}
async fn execute_pending_tool_calls_into_request(&mut self) -> bool {
let tool_calls = std::mem::take(&mut self.pending_tool_calls);
if tool_calls.is_empty() {
return false;
}
let mut tool_call_futs: Vec<BoxPinFut<(ToolCall, ToolOutput<Json>)>> =
Vec::with_capacity(tool_calls.len());
for mut tool in tool_calls {
let tool_name = tool.name.to_ascii_lowercase();
if !self.is_callable_allowed(&tool_name) {
let output = tool_error(format!(
"tool {} is not permitted for this agent",
tool.name
));
tool_call_futs.push(Box::pin(async move { (tool, output) }));
continue;
}
if self.ctx.has_tool_lowercase(&tool_name)
|| strip_prefix_ignore_ascii_case(&tool_name, REMOTE_TOOL_PREFIX).is_some()
{
let ctx = self.ctx.clone();
let input = ToolInput {
name: tool.name.clone(),
args: tool.args.clone(),
resources: self
.ctx
.select_tool_resources(&tool.name, &mut self.resources)
.await,
meta: None,
};
tool_call_futs.push(Box::pin(async move {
let output = match ctx.tool_call(input).await {
Ok((output, remote_id)) => {
tool.remote_id = remote_id;
output
}
Err(err) => tool_error(format!("tool call failed: {err}")),
};
(tool, output)
}));
} else if self.ctx.agents.contains_lowercase(&tool_name)
|| strip_prefix_ignore_ascii_case(&tool_name, SUB_AGENT_PREFIX).is_some()
|| strip_prefix_ignore_ascii_case(&tool_name, REMOTE_AGENT_PREFIX).is_some()
{
let prompt = if let Some(args) = tool.args.as_str() {
args.to_string()
} else if let Some(args) = tool.args.as_object()
&& args.len() == 1
&& let Some(prompt) = args.get("prompt").and_then(|v| v.as_str())
{
prompt.to_string()
} else {
serde_json::to_string(&tool.args).unwrap_or_else(|_| tool.args.to_string())
};
let ctx = self.ctx.clone();
let input = AgentInput {
name: tool.name.clone(),
prompt,
resources: self
.ctx
.select_agent_resources(&tool.name, &mut self.resources)
.await,
..Default::default()
};
tool_call_futs.push(Box::pin(async move {
let output = match ctx.agent_run(input).await {
Ok((output, remote_id)) => {
tool.remote_id = remote_id;
output.into_tool_output()
}
Err(err) => tool_error(format!("agent run failed: {err}")),
};
(tool, output)
}));
} else {
let output = tool_error(format!("tool call failed: {} not found", tool.name));
tool_call_futs.push(Box::pin(async move { (tool, output) }));
}
}
for (mut tool, mut res) in futures::future::join_all(tool_call_futs).await {
let mut usage = res.usage.clone();
usage.requests = 1;
self.tools_usage
.entry(tool.name.to_ascii_lowercase())
.or_default()
.accumulate(&usage);
self.accumulate_tools_usage(&res.tools_usage);
self.accumulate(&res.usage);
self.add_discovered_tools_from_output(&tool.name, &res.output);
self.compact_discovery_tool_output_for_context(&tool.name, &mut res.output);
self.req.content.push(ContentPart::ToolOutput {
name: tool.name.clone(),
output: res
.model_output
.clone()
.map(anda_core::ToolPresentation::into_output)
.unwrap_or_else(|| res.output.clone()),
is_error: res.is_error,
call_id: tool.call_id.clone(),
remote_id: tool.remote_id,
});
self.artifacts.append(&mut res.artifacts);
tool.result = Some(res);
self.tool_calls.push(tool);
}
self.req.role = Some("tool".to_string());
true
}
fn sync_model_for_next_turn(&mut self) {
let label = self.req.model.as_deref().unwrap_or(&self.ctx.label);
let Some(model) = self.ctx.models.resolve(label) else {
return;
};
if model.model_name() != self.model.model_name() && !self.req.raw_history.is_empty() {
log::info!(
"model changed from {} to {}, replaying the provider-neutral chat history",
self.model.model_name(),
model.model_name()
);
self.req.raw_history.clear();
self.pending_tool_call_raw_history_start = None;
self.req.chat_history = self.neutral_history();
}
self.model = model;
}
fn neutral_history(&self) -> Vec<Message> {
let generated = self.generated_history();
let mut history = Vec::with_capacity(self.history_prefix.len() + generated.len() + 1);
history.extend_from_slice(&self.history_prefix);
history.extend(self.note_context.iter().cloned());
history.extend_from_slice(generated);
history
}
fn generated_history(&self) -> &[Message] {
self.chat_history
.strip_prefix(self.history_prefix.as_slice())
.unwrap_or(&self.chat_history)
}
fn commit_tool_outputs_to_history(&mut self) {
if self.req.role.as_deref() != Some("tool") || self.req.content.is_empty() {
return;
}
let msg = Message {
role: "tool".to_string(),
content: std::mem::take(&mut self.req.content),
..Default::default()
};
self.req.chat_history.push(msg.clone());
self.chat_history.push(msg);
self.req.role = None;
}
async fn load_note_context(&mut self) -> Result<(), BoxError> {
use crate::extension::note::{NoteContextConfig, NoteTool, load_note_summary};
if self.note_context_loaded {
return Ok(());
}
if self.ctx.tools.contains_lowercase(NoteTool::NAME)
&& self
.req
.tools
.iter()
.any(|tool| tool.name.eq_ignore_ascii_case(NoteTool::NAME))
&& self.is_callable_allowed(NoteTool::NAME)
&& let Some(config) = self.ctx.base.get_state::<NoteContextConfig>()
&& let Some(text) = load_note_summary(&self.ctx, &config).await?
{
let message = Message {
role: "assistant".into(),
content: vec![text.into()],
timestamp: Some(unix_ms()),
..Default::default()
};
self.req.chat_history.push(message.clone());
self.note_context = Some(message);
}
self.note_context_loaded = true;
Ok(())
}
async fn inner_next(&mut self) -> Result<Option<AgentOutput>, BoxError> {
let mut tool_round = false;
if !self.pending_tool_calls.is_empty()
&& let Some(content) = self.drain_steering_message()
{
self.discard_in_flight_request_with_interrupted_tool_outputs(
"tool call interrupted by steering",
None,
);
self.set_next_user_content(content);
} else if !self.has_request_input() {
if self.execute_pending_tool_calls_into_request().await {
tool_round = true;
let follow_up: Vec<ContentPart> = self.follow_up_message.drain(..).collect();
if !follow_up.is_empty() {
self.commit_tool_outputs_to_history();
self.set_next_user_content(follow_up);
}
} else if let Some(content) = self.drain_queued_message() {
self.set_next_user_content(content);
} else {
return Ok(None);
}
}
let tool_round = tool_round || self.req.role.as_deref() == Some("tool");
self.sync_model_for_next_turn();
if !tool_round {
self.load_note_context().await?;
}
self.turns += 1;
let mut req = self.req.clone();
if !tool_round && let Some(implicit_context) = self.implicit_context.take() {
req.chat_history.push(implicit_context);
}
self.merge_discovered_tools_into_request(&mut req);
let mut output = self.model_request(req).await?;
output.model = Some(self.model.model_name());
if self.transient_inline_data {
strip_inline_data(&mut output.chat_history);
}
self.current_usage = output.usage.clone();
self.accumulate(&output.usage);
if output.failed_reason.is_some() {
return Ok(Some(self.final_output(output)));
}
self.req.tool_choice_required = false;
self.req.output_schema = None;
self.req.chat_history.clear();
self.req.documents.clear();
self.req.content.clear();
self.req.prompt.clear();
self.req.role = None;
let raw_history_start = self.req.raw_history.len();
self.req.raw_history.append(&mut output.raw_history);
self.pending_tool_call_raw_history_start = None;
self.chat_history.append(&mut output.chat_history);
if let Some(content) = self.drain_steering_message() {
if !output.tool_calls.is_empty() {
self.append_interrupted_tool_outputs("tool call interrupted by steering", None);
self.model
.prune_unanswered_tool_calls(&mut self.req.raw_history, raw_history_start);
}
self.pending_tool_calls.clear();
self.set_next_user_content(content);
return Ok(Some(self.intermediate_output(output)));
}
self.pending_tool_calls.extend(output.tool_calls.clone());
if !self.pending_tool_calls.is_empty() {
self.pending_tool_call_raw_history_start = Some(raw_history_start);
return Ok(Some(self.intermediate_output(output)));
}
if let Some(content) = self.drain_queued_message() {
self.set_next_user_content(content);
return Ok(Some(self.intermediate_output(output)));
}
if self.unbound {
self.prune_transient_inline_data();
return Ok(Some(self.intermediate_output(output)));
}
Ok(Some(self.final_output(output)))
}
fn intermediate_output(&mut self, mut output: AgentOutput) -> AgentOutput {
output.usage = self.total_usage.clone();
output.tools_usage = self.tools_usage.clone();
output.chat_history.clear();
self.last_output = Some(output.clone());
output.chat_history = self.chat_history.clone();
output
}
fn final_idle_output(&mut self) -> AgentOutput {
let output = self.last_output.take().unwrap_or_default();
self.final_output(output)
}
fn final_output(&mut self, mut output: AgentOutput) -> AgentOutput {
self.done = true;
self.last_output = None;
self.chat_history.append(&mut output.chat_history);
output.chat_history = std::mem::take(&mut self.chat_history);
output.tool_calls = std::mem::take(&mut self.tool_calls);
output.artifacts = std::mem::take(&mut self.artifacts);
output.usage = std::mem::take(&mut self.total_usage);
output.tools_usage = std::mem::take(&mut self.tools_usage);
output
}
}
fn tool_error(message: String) -> ToolOutput<Json> {
ToolOutput {
output: json!({ "error": message }),
is_error: Some(true),
..Default::default()
}
}
fn strip_inline_data(messages: &mut [Message]) {
for message in messages
.iter_mut()
.filter(|message| message.role != "assistant")
{
let mut omitted: Option<Option<String>> = None;
message.content.retain(|part| match part {
ContentPart::InlineData { mime_type, .. } => {
omitted.get_or_insert(Some(mime_type.clone()));
false
}
ContentPart::FileData {
file_uri,
mime_type,
} if file_uri.starts_with("data:") => {
omitted.get_or_insert(mime_type.clone());
false
}
_ => true,
});
if let Some(mime) = omitted
&& message.content.is_empty()
{
message.content.push(ContentPart::Text {
text: crate::model::raw::inline_media_text(mime.as_deref()),
});
}
}
}
pub struct CompletionStream {
pub runner: CompletionRunner,
pending: Option<PendingCompletion>,
queued_steering: Vec<ContentPart>,
queued_follow_up: Vec<ContentPart>,
failed: bool,
}
type PendingCompletion = BoxPinFut<(CompletionRunner, Result<Option<AgentOutput>, BoxError>)>;
impl CompletionStream {
pub(crate) fn new(runner: CompletionRunner) -> Self {
Self {
runner,
pending: None,
queued_steering: Vec::new(),
queued_follow_up: Vec::new(),
failed: false,
}
}
pub fn steer(&mut self, message: impl Into<ContentPart>) {
if self.pending.is_none() {
self.runner.steer(message);
} else {
self.queued_steering.push(message.into());
}
}
pub fn follow_up(&mut self, message: impl Into<ContentPart>) {
if self.pending.is_none() {
self.runner.follow_up(message);
} else {
self.queued_follow_up.push(message.into());
}
}
fn restore_runner(&mut self, runner: CompletionRunner) {
self.runner = runner;
self.pending = None;
if !self.queued_follow_up.is_empty() {
self.runner
.follow_up_content(std::mem::take(&mut self.queued_follow_up));
}
if !self.queued_steering.is_empty() {
self.runner
.steer_content(std::mem::take(&mut self.queued_steering));
}
}
}
impl Stream for CompletionStream {
type Item = Result<AgentOutput, BoxError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
if this.failed {
return Poll::Ready(None);
}
if this.pending.is_none() {
let placeholder = this.runner.stream_placeholder();
let mut runner = std::mem::replace(&mut this.runner, placeholder);
this.pending = Some(Box::pin(async move {
let unbound = runner.unbound;
runner.unbound = true;
let res = runner.next().await;
runner.unbound = unbound;
(runner, res)
}));
}
let pending = this
.pending
.as_mut()
.expect("completion stream pending future must be initialized");
match pending.as_mut().poll(cx) {
Poll::Ready((runner, res)) => {
this.restore_runner(runner);
match res {
Ok(Some(output)) => {
let output =
if !this.runner.done && !this.runner.unbound && this.runner.is_idle() {
this.runner.final_idle_output()
} else {
output
};
Poll::Ready(Some(Ok(output)))
}
Ok(None) => {
this.runner.done = true;
Poll::Ready(None)
}
Err(e) => {
this.failed = true;
Poll::Ready(Some(Err(e)))
}
}
}
Poll::Pending => Poll::Pending,
}
}
}
#[cfg(test)]
mod tests {
use anda_core::{
AgentContext as _, AgentOutput, BoxError, CancellationToken, CompletionRequest,
ContentPart, FunctionDefinition, Json, Message, ModelEffort, Resource, StateFeatures as _,
Tool, ToolCall, ToolOutput, Usage,
};
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::json;
use std::{
collections::{BTreeSet, HashMap},
sync::{Arc, Mutex},
};
use super::{CompletionRunner, SUB_AGENT_PREFIX};
use crate::context::base::BaseCtx;
use crate::context::test_fixtures::*;
use crate::{
engine::EngineBuilder,
model::{CompletionFeaturesDyn, Model, testing::ScriptedCompleter},
subagent::{SubAgent, SubAgentManager},
};
#[tokio::test(flavor = "current_thread")]
async fn runner_accessors_mutators_and_implicit_context_are_observable() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(RecordingCompleter {
name: "recording".to_string(),
requests: requests.clone(),
}))
.with_labels(vec!["alt".to_string()]);
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let mut runner = ctx.completion_iter(CompletionRequest::default(), Vec::new());
assert!(runner.is_idle());
assert_eq!(runner.ctx().engine_name(), "Mocker");
assert_eq!(runner.req().prompt, "");
assert_eq!(runner.model().model_name(), "recording");
assert_eq!(runner.total_usage().requests, 0);
assert_eq!(runner.current_usage().requests, 0);
assert!(runner.tools_usage().is_empty());
assert!(runner.last_output().is_none());
assert_eq!(runner.merge_discovered_tools(), None);
runner.append_chat_history(vec![Message {
role: "system".to_string(),
content: vec![ContentPart::Text {
text: "preloaded".to_string(),
}],
..Default::default()
}]);
assert_eq!(runner.chat_history().len(), 1);
runner.chat_history_mut()[0].content = vec![ContentPart::Text {
text: "updated".to_string(),
}];
assert_eq!(runner.chat_history().len(), 1);
assert_eq!(runner.chat_history()[0].text().as_deref(), Some("updated"));
runner.follow_up_content(vec![ContentPart::Text {
text: "follow".to_string(),
}]);
runner.steer_content(vec![ContentPart::Text {
text: "steer".to_string(),
}]);
assert!(!runner.is_idle());
runner.implicit_context(Message {
role: "system".to_string(),
content: vec![ContentPart::Text {
text: "implicit".to_string(),
}],
..Default::default()
});
runner.set_model(Some("alt".to_string()));
runner.set_effort(Some(ModelEffort::Low));
runner.set_merge_discovered_tools(Some(true));
runner.set_tools(vec![FunctionDefinition {
name: "forced_tool".to_string(),
..Default::default()
}]);
assert_eq!(runner.merge_discovered_tools(), Some(true));
let output = runner.next().await.unwrap().unwrap();
assert_eq!(output.content, "follow\n\nsteer");
assert_eq!(output.model, Some("recording".to_string()));
assert_eq!(runner.current_usage().requests, 1);
assert_eq!(output.usage.requests, 1);
assert_eq!(runner.total_usage().requests, 0);
assert!(runner.last_output().is_none());
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].model.as_deref(), Some("alt"));
assert_eq!(requests[0].effort, Some(ModelEffort::Low));
assert_eq!(requests[0].tools[0].name, "forced_tool");
assert_eq!(requests[0].chat_history.len(), 1);
assert_eq!(
requests[0].chat_history[0].text().as_deref(),
Some("implicit")
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_records_missing_tool_and_failed_agent_as_tool_outputs() {
let model = Model::with_completer(Arc::new(ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "missing_tool".to_string(),
args: json!({}),
call_id: Some("missing_tool_call".into()),
result: None,
remote_id: None,
}],
}));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "call missing".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let step = runner.next().await.unwrap().unwrap();
assert_eq!(step.tool_calls.len(), 1);
assert!(step.tool_calls[0].result.is_none());
let output = runner.next().await.unwrap().unwrap();
let result = output.tool_calls[0].result.as_ref().unwrap();
assert_eq!(result.is_error, Some(true));
assert!(
result
.output
.to_string()
.contains("tool call failed: missing_tool not found")
);
let model = Model::with_completer(Arc::new(AgentCallCompleter {
agent_name: "fail_agent".to_string(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_agent(Arc::new(FailAgent), None)
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "call failing agent".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.next().await.unwrap().unwrap();
let output = runner.next().await.unwrap().unwrap();
let result = output.tool_calls[0].result.as_ref().unwrap();
assert_eq!(result.is_error, Some(true));
assert!(
result
.output
.to_string()
.contains("agent run failed: agent execution failed")
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_allowlist_blocks_non_whitelisted_callables() {
let model = Model::with_completer(Arc::new(ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "echo_tool".to_string(),
args: json!({ "input": "hi" }),
call_id: Some("echo_call".into()),
result: None,
remote_id: None,
}],
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "call echo".to_string(),
..Default::default()
};
let mut runner = ctx
.completion_iter(req, Vec::new())
.with_allowed_callables(Some(BTreeSet::new()));
runner.next().await.unwrap().unwrap();
let output = runner.next().await.unwrap().unwrap();
let result = output.tool_calls[0].result.as_ref().unwrap();
assert_eq!(result.is_error, Some(true));
assert!(
result.output.to_string().contains("not permitted"),
"unexpected output: {}",
result.output
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_allowlist_permits_whitelisted_callable() {
let model = Model::with_completer(Arc::new(ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "echo_tool".to_string(),
args: json!({ "input": "hi" }),
call_id: Some("echo_call".into()),
result: None,
remote_id: None,
}],
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "call echo".to_string(),
..Default::default()
};
let mut runner = ctx
.completion_iter(req, Vec::new())
.with_allowed_callables(Some(BTreeSet::from(["echo_tool".to_string()])));
runner.next().await.unwrap().unwrap();
let output = runner.next().await.unwrap().unwrap();
let result = output.tool_calls[0].result.as_ref().unwrap();
assert_ne!(result.is_error, Some(true));
assert!(result.output.to_string().contains("echoed:hi"));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_injects_discovered_tool_schemas_after_repeated_selection() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(DiscoveryCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let initial_tools = ctx.definitions(Some(&["tools_select".to_string()])).await;
let req = CompletionRequest {
prompt: "select echo tool".to_string(),
tools: initial_tools,
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let first = runner.next().await.unwrap().unwrap();
assert_eq!(first.tool_calls[0].name, "tools_select");
let second = runner.next().await.unwrap().unwrap();
assert_eq!(second.tool_calls[0].name, "tools_select");
let third = runner.next().await.unwrap().unwrap();
assert_eq!(third.tool_calls[0].name, "echo_tool");
assert_eq!(runner.merge_discovered_tools(), Some(true));
let fourth = runner.next().await.unwrap().unwrap();
assert_eq!(fourth.content, "echo tool used after discovery");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 4);
let initial_tool_names = requests[0]
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(initial_tool_names.contains(&"tools_select"));
assert!(!initial_tool_names.contains(&"echo_tool"));
let first_after_select_tool_names = requests[1]
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(first_after_select_tool_names.contains(&"tools_select"));
assert!(!first_after_select_tool_names.contains(&"echo_tool"));
assert!(requests[1].content.iter().any(|part| matches!(
part,
ContentPart::ToolOutput { name, output, .. }
if name == "tools_select" && output["tools"][0]["name"] == "echo_tool"
&& output["tools"][0].get("parameters").is_some()
)));
let second_after_select_tool_names = requests[2]
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(second_after_select_tool_names.contains(&"tools_select"));
assert!(second_after_select_tool_names.contains(&"echo_tool"));
assert!(requests[2].content.iter().any(|part| matches!(
part,
ContentPart::ToolOutput { name, output, .. }
if name == "tools_select"
&& output["tools"][0]["name"] == "echo_tool"
&& output["tools"][0].get("parameters").is_none()
&& output["tools"][0].get("description").is_none()
)));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_can_merge_discovered_tool_schemas_without_repeated_selection_probe() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(DiscoveryCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let initial_tools = ctx.definitions(Some(&["tools_select".to_string()])).await;
let req = CompletionRequest {
prompt: "select echo tool".to_string(),
tools: initial_tools,
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.set_merge_discovered_tools(Some(true));
let first = runner.next().await.unwrap().unwrap();
assert_eq!(first.tool_calls[0].name, "tools_select");
let second = runner.next().await.unwrap().unwrap();
assert_eq!(second.tool_calls[0].name, "echo_tool");
let third = runner.next().await.unwrap().unwrap();
assert_eq!(third.content, "echo tool used after discovery");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 3);
let after_first_select_tool_names = requests[1]
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(after_first_select_tool_names.contains(&"tools_select"));
assert!(after_first_select_tool_names.contains(&"echo_tool"));
assert!(requests[1].content.iter().any(|part| matches!(
part,
ContentPart::ToolOutput { name, output, .. }
if name == "tools_select"
&& output["tools"][0]["name"] == "echo_tool"
&& output["tools"][0].get("parameters").is_none()
&& output["tools"][0].get("description").is_none()
)));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_does_not_probe_when_discovered_tool_merge_is_disabled() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(DiscoveryCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let initial_tools = ctx.definitions(Some(&["tools_select".to_string()])).await;
let req = CompletionRequest {
prompt: "select echo tool".to_string(),
tools: initial_tools,
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.set_merge_discovered_tools(Some(false));
let first = runner.next().await.unwrap().unwrap();
assert_eq!(first.tool_calls[0].name, "tools_select");
let second = runner.next().await.unwrap().unwrap();
assert_eq!(second.tool_calls[0].name, "tools_select");
let third = runner.next().await.unwrap().unwrap();
assert_eq!(third.tool_calls[0].name, "tools_select");
assert_eq!(runner.merge_discovered_tools(), Some(false));
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert!(
requests[1]
.tools
.iter()
.all(|tool| tool.name != "echo_tool")
);
assert!(
requests[2]
.tools
.iter()
.all(|tool| tool.name != "echo_tool")
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_handoff_preserves_discovered_tool_state_after_pending_selection() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(DiscoveryCompactionCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let initial_tools = ctx.definitions(Some(&["tools_select".to_string()])).await;
let req = CompletionRequest {
prompt: "select echo tool".to_string(),
tools: initial_tools,
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new()).unbound();
let first = runner.next().await.unwrap().unwrap();
assert_eq!(first.tool_calls[0].name, "tools_select");
let second = runner.next().await.unwrap().unwrap();
assert_eq!(second.tool_calls[0].name, "tools_select");
assert_eq!(runner.merge_discovered_tools(), None);
assert!(!runner.no_pending_tool_calls());
runner.follow_up("continue after handoff".to_string());
let (mut runner, output) = runner.handoff(None).await.unwrap();
assert_eq!(output.content, "compacted handoff");
assert_eq!(runner.merge_discovered_tools(), Some(true));
let after_handoff = runner.next().await.unwrap().unwrap();
assert_eq!(after_handoff.tool_calls[0].name, "echo_tool");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 4);
assert!(requests[2].tools.is_empty());
let after_handoff_tool_names = requests[3]
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(after_handoff_tool_names.contains(&"tools_select"));
assert!(after_handoff_tool_names.contains(&"echo_tool"));
assert!(requests[3].content.iter().any(|part| matches!(
part,
ContentPart::Text { text } if text == "continue after handoff"
)));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_compacts_discovery_outputs_after_schema_merge_is_enabled() {
let ctx = EngineBuilder::new().mock_ctx();
let mut runner = ctx.completion_iter(CompletionRequest::default(), Vec::new());
runner.set_merge_discovered_tools(Some(true));
let full_output = json!({
"tools": [{
"name": "echo_tool",
"description": "Echoes input",
"parameters": {"type": "object"},
"strict": true
}],
"total_tools": 9
});
runner.add_discovered_tools_from_output("tools_search", &full_output);
let mut search_output = full_output.clone();
runner.compact_discovery_tool_output_for_context("tools_search", &mut search_output);
assert_eq!(search_output["tools"][0]["name"], "echo_tool");
assert_eq!(search_output["tools"][0]["description"], "Echoes input");
assert!(search_output["tools"][0].get("parameters").is_none());
assert!(search_output["tools"][0].get("strict").is_none());
assert_eq!(search_output["total_tools"], 9);
let mut select_output = full_output.clone();
runner.compact_discovery_tool_output_for_context("tools_select", &mut select_output);
assert_eq!(select_output["tools"][0]["name"], "echo_tool");
assert!(select_output["tools"][0].get("description").is_none());
assert!(select_output["tools"][0].get("parameters").is_none());
assert!(select_output["tools"][0].get("strict").is_none());
assert_eq!(select_output["total_tools"], 9);
let mut other_output = full_output.clone();
runner.compact_discovery_tool_output_for_context("echo_tool", &mut other_output);
assert_eq!(other_output, full_output);
runner.set_merge_discovered_tools(Some(false));
let mut unmerged_output = full_output.clone();
runner.compact_discovery_tool_output_for_context("tools_search", &mut unmerged_output);
assert_eq!(unmerged_output, full_output);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_accumulates_nested_tool_usage() {
struct AccountingTool;
#[derive(Debug, Deserialize)]
struct AccountingArgs {}
impl Tool<BaseCtx> for AccountingTool {
type Args = AccountingArgs;
type Output = String;
fn name(&self) -> String {
"accounting_tool".to_string()
}
fn description(&self) -> String {
"Returns nested tool usage".to_string()
}
fn definition(&self) -> FunctionDefinition {
FunctionDefinition {
name: "accounting_tool".to_string(),
description: "Returns nested tool usage".to_string(),
parameters: json!({"type": "object"}),
strict: Some(true),
}
}
async fn call(
&self,
_ctx: BaseCtx,
_args: Self::Args,
_resources: Vec<Resource>,
) -> Result<ToolOutput<String>, BoxError> {
Ok(ToolOutput {
output: "accounted".to_string(),
usage: Usage {
input_tokens: 7,
output_tokens: 11,
cached_tokens: 3,
requests: 4,
},
tools_usage: HashMap::from([(
"nested_tool".to_string(),
Usage {
input_tokens: 2,
output_tokens: 3,
cached_tokens: 1,
requests: 2,
},
)]),
..Default::default()
})
}
}
let model = Model::with_completer(Arc::new(ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "accounting_tool".to_string(),
args: json!({}),
call_id: Some("accounting_call".into()),
result: None,
remote_id: None,
}],
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(AccountingTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "account".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.next().await.unwrap().unwrap();
let output = runner.next().await.unwrap().unwrap();
assert_eq!(output.tools_usage["accounting_tool"].requests, 1);
assert_eq!(output.tools_usage["accounting_tool"].input_tokens, 7);
assert_eq!(output.tools_usage["nested_tool"].requests, 2);
assert_eq!(output.tools_usage["nested_tool"].cached_tokens, 1);
assert!(output.usage.input_tokens >= 20);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_finalize_reports_already_finalized() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "done".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.next().await.unwrap().unwrap();
let err = runner.finalize(None).await.unwrap_err();
assert!(err.to_string().contains("completion already finalized"));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_basic_completion_no_tool_calls() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "hello world".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
assert!(!runner.is_done());
assert_eq!(runner.turns(), 0);
let output = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(runner.turns(), 1);
assert_eq!(output.content, "hello world");
assert!(output.failed_reason.is_none());
assert_eq!(output.model, Some("echo".to_string()));
assert_eq!(output.usage.input_tokens, 5);
assert_eq!(output.usage.output_tokens, 10);
assert_eq!(output.usage.requests, 1);
let output = runner.next().await.unwrap();
assert!(output.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn runner_executes_document_only_request() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
..Default::default()
}
.context("doc_1".to_string(), "context without prompt".to_string());
let mut runner = ctx.completion_iter(req, Vec::new());
let output = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(runner.turns(), 1);
assert_eq!(output.model, Some("echo".to_string()));
assert_eq!(output.usage.requests, 1);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_is_done_returns_none_immediately() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "test".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert!(runner.next().await.unwrap().is_none());
assert!(runner.next().await.unwrap().is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn runner_unbound_returns_none_only_after_becoming_idle() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.set_unbound(true);
let step1 = runner.next().await.unwrap().unwrap();
assert_eq!(step1.content, "initial");
assert!(!runner.is_done());
assert_eq!(runner.turns(), 1);
let idle = runner.next().await.unwrap();
assert!(idle.is_none());
assert!(!runner.is_done());
assert_eq!(runner.turns(), 1);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_unbound_can_resume_after_idle_with_follow_up() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.set_unbound(true);
let step1 = runner.next().await.unwrap().unwrap();
assert_eq!(step1.content, "initial");
assert!(!runner.is_done());
assert!(runner.next().await.unwrap().is_none());
runner.follow_up("resume".to_string());
let step2 = runner.next().await.unwrap().unwrap();
assert_eq!(step2.content, "resume");
assert_eq!(step2.usage.input_tokens, 10);
assert_eq!(step2.usage.output_tokens, 20);
assert!(!runner.is_done());
assert!(runner.next().await.unwrap().is_none());
assert!(!runner.is_done());
}
#[tokio::test(flavor = "current_thread")]
async fn runner_unbound_still_finishes_on_failed_reason() {
let model = Model::with_completer(Arc::new(AlwaysFailCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.set_unbound(true);
let output = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(output.failed_reason.as_deref(), Some("primary failed"));
assert!(runner.next().await.unwrap().is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn runner_finishes_on_primary_failure() {
let primary = Model::with_completer(Arc::new(AlwaysFailCompleter));
let ctx = EngineBuilder::new().with_model(primary).mock_ctx();
let req = CompletionRequest {
prompt: "hello".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let output = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert!(output.failed_reason.is_some());
assert_eq!(output.failed_reason.unwrap(), "primary failed");
}
#[tokio::test(flavor = "current_thread")]
async fn runner_model_error_propagates() {
let model = Model::with_completer(Arc::new(ErrorCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "hello".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let result = runner.next().await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("model error"));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_discards_in_flight_tool_result_request_after_model_error() {
let model = Model::with_completer(Arc::new(ToolResultErrorCompleter));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut runner = ctx.completion_iter(
CompletionRequest {
prompt: "call tool".to_string(),
..Default::default()
},
Vec::new(),
);
let step = runner.next().await.unwrap().unwrap();
assert_eq!(step.tool_calls[0].name, "echo_tool");
assert!(runner.req.raw_history[0].get("tool_calls").is_some());
let err = runner.next().await.unwrap_err();
assert!(err.to_string().contains("model error"));
assert_eq!(runner.req.role.as_deref(), Some("tool"));
assert!(!runner.req.content.is_empty());
runner.discard_in_flight_request();
assert!(runner.req.content.is_empty());
assert!(runner.req.prompt.is_empty());
assert!(runner.req.role.is_none());
assert!(runner.req.raw_history.is_empty());
assert!(runner.pending_tool_calls.is_empty());
}
#[tokio::test(flavor = "current_thread")]
async fn handoff_failure_restores_tools_queued_input_and_unbound() {
let model = Model::with_completer(Arc::new(ErrorCompleter));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let tools = ctx.definitions(Some(&["echo_tool".to_string()])).await;
assert!(!tools.is_empty());
let mut runner = ctx.completion_iter(
CompletionRequest {
tools: tools.clone(),
..Default::default()
},
Vec::new(),
);
runner.set_unbound(true);
runner.follow_up("please continue".to_string());
runner.steer("adjust course".to_string());
let err = match runner.handoff(None).await {
Ok(_) => panic!("handoff should fail on a transport error"),
Err(err) => err,
};
assert!(err.to_string().contains("model error"));
assert_eq!(
runner.req.tools.len(),
tools.len(),
"tools must be restored after a failed handoff"
);
assert!(runner.unbound, "unbound flag must be restored");
assert!(
runner.req.content.is_empty(),
"residual compaction prompt must be cleared"
);
assert_eq!(
runner.follow_up_message_iter().count(),
1,
"queued follow-up must be restored"
);
assert_eq!(
runner.steering_message_iter().count(),
1,
"queued steering must be restored (compaction prompt dropped)"
);
}
#[tokio::test(flavor = "current_thread")]
async fn handoff_preserves_allowlist_for_replacement_runner() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut runner = ctx
.completion_iter(CompletionRequest::default(), Vec::new())
.with_allowed_callables(Some(BTreeSet::new()));
let (new_runner, _output) = runner
.handoff(None)
.await
.expect("handoff should succeed with a non-empty summary");
assert_eq!(
new_runner.allowed_callables,
Some(BTreeSet::new()),
"the empty allowlist must survive the handoff"
);
assert!(
!new_runner.is_callable_allowed("echo_tool"),
"a non-whitelisted callable must stay blocked after compaction"
);
}
#[tokio::test(flavor = "current_thread")]
async fn allowlist_matches_callables_through_their_routing_prefix() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let runner = ctx
.completion_iter(CompletionRequest::default(), Vec::new())
.with_allowed_callables(Some(BTreeSet::from([
"helper".to_string(),
"lookup".to_string(),
"chat".to_string(),
])));
for name in ["helper", "sa_helper", "rt_lookup", "ra_chat"] {
assert!(
runner.is_callable_allowed(name),
"{name} must be permitted for a runner whitelisting its unprefixed name"
);
}
for name in ["other", "sa_other", "rt_helperx"] {
assert!(
!runner.is_callable_allowed(name),
"{name} must stay blocked"
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn runner_discard_closes_unanswered_tool_call_in_visible_history() {
let model = Model::with_completer(Arc::new(ToolResultErrorWithHistoryCompleter));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut runner = ctx.completion_iter(
CompletionRequest {
prompt: "call tool".to_string(),
..Default::default()
},
Vec::new(),
);
runner.next().await.unwrap().unwrap();
let err = runner.next().await.unwrap_err();
assert!(err.to_string().contains("model error"));
assert!(runner.pending_tool_calls.is_empty());
assert!(
!CompletionRunner::unanswered_tool_calls(runner.chat_history()).is_empty(),
"precondition: unanswered tool call present before discard"
);
runner.discard_in_flight_request();
assert!(
CompletionRunner::unanswered_tool_calls(runner.chat_history()).is_empty(),
"discard must close the unanswered tool call in visible history"
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_stop_current_task_closes_pending_tool_call_history() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(ToolCallHistoryCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let mut runner = ctx.completion_iter(
CompletionRequest {
prompt: "start tool".to_string(),
..Default::default()
},
Vec::new(),
);
let step = runner.next().await.unwrap().unwrap();
assert_eq!(
step.tool_calls[0].call_id.as_deref(),
Some("call_stop_test")
);
assert!(!runner.no_pending_tool_calls());
assert!(!CompletionRunner::unanswered_tool_calls(runner.chat_history()).is_empty());
let stopped = runner.stop_current_task(AgentOutput {
content: "operator stopped the task".to_string(),
..Default::default()
});
assert!(runner.no_pending_tool_calls());
assert!(CompletionRunner::unanswered_tool_calls(runner.chat_history()).is_empty());
assert!(
stopped.chat_history.iter().any(|message| {
message.content.iter().any(|part| {
matches!(
part,
ContentPart::ToolOutput {
call_id,
is_error: Some(true),
..
} if call_id.as_deref() == Some("call_stop_test")
)
})
}),
"stopped output should answer the pending tool call"
);
assert!(
stopped.chat_history.iter().any(|message| {
message.content.iter().any(|part| {
matches!(
part,
ContentPart::Text { text } if text == "planning before tool"
)
})
}),
"stopped output should preserve assistant text that shared the tool-call message"
);
runner.follow_up("continue after stop".to_string());
let continued = runner.next().await.unwrap().unwrap();
assert_eq!(continued.content, "continued");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].raw_history.len(), 1);
assert_eq!(
requests[1].raw_history[0]["content"],
"planning before tool"
);
assert!(requests[1].raw_history[0].get("tool_calls").is_none());
assert!(
!serde_json::to_string(&requests[1].raw_history)
.unwrap()
.contains("call_stop_test"),
"follow-up request should not resend the interrupted raw tool call"
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_discard_in_flight_request_closes_pending_tool_call_history() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(ToolCallHistoryCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let mut runner = ctx.completion_iter(
CompletionRequest {
prompt: "start tool".to_string(),
..Default::default()
},
Vec::new(),
);
let step = runner.next().await.unwrap().unwrap();
assert_eq!(
step.tool_calls[0].call_id.as_deref(),
Some("call_stop_test")
);
runner.discard_in_flight_request();
assert!(runner.no_pending_tool_calls());
assert!(CompletionRunner::unanswered_tool_calls(runner.chat_history()).is_empty());
assert!(runner.chat_history().iter().any(|message| {
message.content.iter().any(|part| {
matches!(
part,
ContentPart::ToolOutput {
call_id,
is_error: Some(true),
output,
..
} if call_id.as_deref() == Some("call_stop_test")
&& output.get("error").and_then(Json::as_str)
== Some("tool call discarded")
)
})
}));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_queued_steering_closes_skipped_tool_call_history() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(ToolCallHistoryCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let mut runner = ctx.completion_iter(
CompletionRequest {
prompt: "start tool".to_string(),
..Default::default()
},
Vec::new(),
);
runner.steer("redirect".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert_eq!(step1.tool_calls.len(), 1);
assert!(CompletionRunner::unanswered_tool_calls(&step1.chat_history).is_empty());
assert!(step1.chat_history.iter().any(|message| {
message.content.iter().any(|part| {
matches!(
part,
ContentPart::ToolOutput {
call_id,
is_error: Some(true),
output,
..
} if call_id.as_deref() == Some("call_stop_test")
&& output.get("error").and_then(Json::as_str)
== Some("tool call interrupted by steering")
)
})
}));
let step2 = runner.next().await.unwrap().unwrap();
assert_eq!(step2.content, "continued");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert!(
!serde_json::to_string(&requests[1].raw_history)
.unwrap()
.contains("call_stop_test"),
"steered request should not resend the interrupted raw tool call"
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_late_steering_closes_pending_tool_call_history() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(ToolCallHistoryCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let mut runner = ctx.completion_iter(
CompletionRequest {
prompt: "start tool".to_string(),
..Default::default()
},
Vec::new(),
);
let step1 = runner.next().await.unwrap().unwrap();
assert_eq!(step1.tool_calls.len(), 1);
assert!(!CompletionRunner::unanswered_tool_calls(runner.chat_history()).is_empty());
runner.steer("redirect".to_string());
let step2 = runner.next().await.unwrap().unwrap();
assert_eq!(step2.content, "continued");
assert!(CompletionRunner::unanswered_tool_calls(&step2.chat_history).is_empty());
assert!(step2.chat_history.iter().any(|message| {
message.content.iter().any(|part| {
matches!(
part,
ContentPart::ToolOutput {
call_id,
is_error: Some(true),
output,
..
} if call_id.as_deref() == Some("call_stop_test")
&& output.get("error").and_then(Json::as_str)
== Some("tool call interrupted by steering")
)
})
}));
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert!(
!serde_json::to_string(&requests[1].raw_history)
.unwrap()
.contains("call_stop_test"),
"late-steered request should not resend the interrupted raw tool call"
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_executes_tool_calls() {
let completer = ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "echo_tool".to_string(),
args: json!({"input": "hello"}),
call_id: Some("call_1".into()),
result: None,
remote_id: None,
}],
};
let model = Model::with_completer(Arc::new(completer));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "call tool".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.usage.input_tokens, 10);
assert_eq!(step1.usage.output_tokens, 20);
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "tool_result_processed");
assert_eq!(step2.usage.input_tokens, 13); assert_eq!(step2.usage.output_tokens, 26); assert_eq!(step2.tool_calls.len(), 1);
assert_eq!(step2.tool_calls[0].name, "echo_tool");
assert!(step2.tool_calls[0].result.is_some());
}
#[tokio::test(flavor = "current_thread")]
async fn runner_appends_follow_up_after_pending_tool_calls_finish() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(ToolChainUntilFollowUpCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "start tool chain".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.tool_calls.len(), 1);
runner.follow_up("follow up while tool chain is pending".to_string());
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "follow_up_seen_with_tool_result");
assert_eq!(runner.turns(), 2);
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].chat_history.len(), 1);
assert_eq!(requests[1].chat_history[0].role, "tool");
assert_eq!(requests[1].chat_history[0].content.len(), 1);
assert!(matches!(
&requests[1].chat_history[0].content[0],
ContentPart::ToolOutput { name, .. } if name == "echo_tool"
));
assert_eq!(requests[1].role.as_deref(), Some("user"));
assert_eq!(requests[1].content.len(), 1);
assert!(matches!(
&requests[1].content[0],
ContentPart::Text { text } if text == "follow up while tool chain is pending"
));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_tool_call_failure_dont_produces_failed_reason() {
let completer = ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "fail_tool".to_string(),
args: json!({}),
call_id: Some("call_fail".into()),
result: None,
remote_id: None,
}],
};
let model = Model::with_completer(Arc::new(completer));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(FailTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "call fail".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let output = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert!(output.failed_reason.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn runner_executes_agent_calls() {
let completer = AgentCallCompleter {
agent_name: "echo_agent".to_string(),
};
let model = Model::with_completer(Arc::new(completer));
let ctx = EngineBuilder::new()
.with_model(model)
.register_agent(Arc::new(EchoAgent), None)
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "call agent".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let _step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "agent_result_processed");
assert_eq!(step2.tool_calls.len(), 1);
assert_eq!(step2.tool_calls[0].name, "echo_agent");
let result = step2.tool_calls[0].result.as_ref().unwrap();
assert!(
result
.output
.as_str()
.unwrap()
.contains("agent_echoed:subagent task")
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_agent_call_with_arbitrary_args() {
#[derive(Clone, Debug)]
struct BadArgsCompleter;
impl CompletionFeaturesDyn for BadArgsCompleter {
fn model_name(&self) -> String {
"bad_args".to_string()
}
fn completion(
&self,
req: CompletionRequest,
) -> anda_core::BoxPinFut<Result<AgentOutput, BoxError>> {
let role = req.role.as_deref().unwrap_or("");
if role == "tool" {
return Box::pin(futures::future::ready(Ok(AgentOutput {
content: "agent_result_processed".to_string(),
..Default::default()
})));
}
Box::pin(futures::future::ready(Ok(AgentOutput {
tool_calls: vec![ToolCall {
name: "echo_agent".to_string(),
args: json!({"invalid_field": 42}),
call_id: Some("bad_call".into()),
result: None,
remote_id: None,
}],
..Default::default()
})))
}
}
let model = Model::with_completer(Arc::new(BadArgsCompleter));
let ctx = EngineBuilder::new()
.with_model(model)
.register_agent(Arc::new(EchoAgent), None)
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "bad args".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let _step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
let output = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert!(output.failed_reason.is_none());
assert_eq!(output.content, "agent_result_processed");
assert_eq!(output.tool_calls.len(), 1);
assert_eq!(output.tool_calls[0].name, "echo_agent");
let result = output.tool_calls[0].result.as_ref().unwrap();
assert_eq!(
result.output.as_str().unwrap(),
"agent_echoed:{\"invalid_field\":42}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_preserves_structured_subagent_session_args() {
let model = Model::with_completer(Arc::new(ToolCallCompleter {
tool_calls: vec![ToolCall {
name: format!("{SUB_AGENT_PREFIX}echo_helper"),
args: json!({
"prompt": "session task",
"session": "AsyncJob",
"model": "",
"effort": null,
}),
call_id: Some("subagent_session_call".into()),
result: None,
remote_id: None,
}],
}));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let manager: Arc<SubAgentManager> = ctx.subagents.get().unwrap();
manager
.upsert_temporary(SubAgent {
name: "echo_helper".to_string(),
description: "Echoes input.".to_string(),
instructions: "Echo the prompt.".to_string(),
..Default::default()
})
.unwrap();
let req = CompletionRequest {
prompt: "call subagent".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let _step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
let output = runner.next().await.unwrap().unwrap();
assert_eq!(output.tool_calls.len(), 1);
let result = output.tool_calls[0].result.as_ref().unwrap();
assert_eq!(result.output["session"], json!("asyncjob"));
assert!(
result.output["content"]
.as_str()
.unwrap()
.contains("session mode")
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_steering_message_before_first_step() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.steer("redirect to this".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.content, "initial");
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "redirect to this");
}
#[tokio::test(flavor = "current_thread")]
async fn runner_steering_skips_pending_tool_calls() {
let completer = ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "echo_tool".to_string(),
args: json!({"input": "test"}),
call_id: Some("skipped_call".into()),
result: None,
remote_id: None,
}],
};
let model = Model::with_completer(Arc::new(completer));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "call tool".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.steer("abort and redirect".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert!(!step1.tool_calls.is_empty());
let _step2 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
let step3 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step3.content, "tool_result_processed");
}
#[tokio::test(flavor = "current_thread")]
async fn runner_steering_preserves_prior_raw_history_when_skipping_current_tool_call() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(RawHistoryToolCallCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let sentinel = json!({"role": "user", "content": "original raw history"});
let req = CompletionRequest {
prompt: "call tool".to_string(),
raw_history: vec![sentinel.clone()],
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.steer("redirect".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.tool_calls.len(), 1);
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "steered");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].raw_history.len(), 2);
assert_eq!(requests[1].raw_history[0], sentinel);
assert_eq!(requests[1].raw_history[1]["content"], "planning tool call");
assert_eq!(
requests[1].raw_history[1]["reasoning"],
"keep this reasoning"
);
assert!(requests[1].raw_history[1].get("tool_calls").is_none());
assert!(
!requests[1]
.raw_history
.iter()
.any(|item| item["type"] == "function_call")
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_late_steering_preserves_prior_raw_history_when_pending_tool_call_exists() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(RawHistoryToolCallCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let sentinel = json!({"role": "user", "content": "original raw history"});
let req = CompletionRequest {
prompt: "call tool".to_string(),
raw_history: vec![sentinel.clone()],
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.tool_calls.len(), 1);
runner.steer("redirect".to_string());
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "steered");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].raw_history.len(), 2);
assert_eq!(requests[1].raw_history[0], sentinel);
assert_eq!(requests[1].raw_history[1]["content"], "planning tool call");
assert_eq!(
requests[1].raw_history[1]["reasoning"],
"keep this reasoning"
);
assert!(requests[1].raw_history[1].get("tool_calls").is_none());
assert!(
!requests[1]
.raw_history
.iter()
.any(|item| item["type"] == "function_call")
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_follow_up_message_after_completion() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.follow_up("follow up question".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.content, "initial");
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "follow up question");
assert_eq!(step2.usage.input_tokens, 10); assert_eq!(step2.usage.output_tokens, 20); }
#[tokio::test(flavor = "current_thread")]
async fn runner_steering_takes_priority_over_follow_up() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.steer("steering".to_string());
runner.follow_up("follow_up".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.content, "initial");
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "follow_up\n\nsteering");
}
#[tokio::test(flavor = "current_thread")]
async fn runner_multiple_steering_messages_combined() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.steer("first steer".to_string());
runner.steer("second steer".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.content, "initial");
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "first steer\n\nsecond steer");
}
#[tokio::test(flavor = "current_thread")]
async fn runner_multiple_follow_up_messages_combined() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.follow_up("first follow".to_string());
runner.follow_up("second follow".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.content, "initial");
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "first follow\n\nsecond follow");
}
#[tokio::test(flavor = "current_thread")]
async fn runner_multiple_steering_and_follow_up_combined() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.steer("steer 1".to_string());
runner.follow_up("follow 1".to_string());
runner.steer("steer 2".to_string());
runner.follow_up("follow 2".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.content, "initial");
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "follow 1\n\nfollow 2\n\nsteer 1\n\nsteer 2");
}
#[tokio::test(flavor = "current_thread")]
async fn runner_steering_empty_drains_follow_up_only() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.follow_up("follow only".to_string());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert_eq!(step1.content, "initial");
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(step2.content, "follow only");
}
#[tokio::test(flavor = "current_thread")]
async fn runner_finalize_idle_unbound_returns_latest_output() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new()).unbound();
let step1 = runner.next().await.unwrap().unwrap();
assert_eq!(step1.content, "initial");
assert!(!runner.is_done());
assert!(runner.next().await.unwrap().is_none());
let output = runner.finalize(None).await.unwrap();
assert!(runner.is_done());
assert_eq!(output.content, "initial");
assert_eq!(output.usage.input_tokens, 5);
assert_eq!(output.usage.output_tokens, 10);
assert!(runner.next().await.unwrap().is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn runner_finalize_processes_queued_and_new_prompt() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new()).unbound();
let step1 = runner.next().await.unwrap().unwrap();
assert_eq!(step1.content, "initial");
assert!(runner.next().await.unwrap().is_none());
runner.follow_up("queued follow-up".to_string());
let output = runner
.finalize(Some("final prompt".to_string()))
.await
.unwrap();
assert!(runner.is_done());
assert_eq!(output.content, "queued follow-up\n\nfinal prompt");
assert_eq!(output.usage.input_tokens, 10);
assert_eq!(output.usage.output_tokens, 20);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_handoff_preserves_queued_input_for_new_runner() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(RecordingCompleter {
name: "recording".to_string(),
requests: requests.clone(),
}));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "initial".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new()).unbound();
let step = runner.next().await.unwrap().unwrap();
assert_eq!(step.content, "initial");
runner.follow_up("queued follow-up".to_string());
runner.steer("queued steering".to_string());
let (mut runner, output) = runner.handoff(None).await.unwrap();
assert_eq!(output.content.trim(), super::COMPACTION_PROMPT.trim());
let continued = runner.next().await.unwrap().unwrap();
assert_eq!(continued.content, "queued follow-up\n\nqueued steering");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 3);
let compaction_text = requests[1]
.content
.iter()
.filter_map(|part| match part {
ContentPart::Text { text } | ContentPart::Reasoning { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
assert_eq!(compaction_text.trim(), super::COMPACTION_PROMPT.trim());
assert!(!compaction_text.contains("queued follow-up"));
assert!(!compaction_text.contains("queued steering"));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_handoff_steering_interrupts_pending_tool_calls() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(ToolCallHistoryCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut runner = ctx
.completion_iter(
CompletionRequest {
prompt: "start tool".to_string(),
..Default::default()
},
Vec::new(),
)
.unbound();
let step = runner.next().await.unwrap().unwrap();
assert_eq!(
step.tool_calls[0].call_id.as_deref(),
Some("call_stop_test")
);
assert!(!runner.no_pending_tool_calls());
runner.steer("redirect before tool".to_string());
let (mut runner, output) = runner.handoff(None).await.unwrap();
assert_eq!(output.content, "continued");
assert!(output.tool_calls.is_empty());
assert!(!output.tools_usage.contains_key("echo_tool"));
assert!(CompletionRunner::unanswered_tool_calls(&output.chat_history).is_empty());
assert!(output.chat_history.iter().any(|message| {
message.content.iter().any(|part| {
matches!(
part,
ContentPart::ToolOutput {
call_id,
is_error: Some(true),
output,
..
} if call_id.as_deref() == Some("call_stop_test")
&& output.get("error").and_then(Json::as_str)
== Some("tool call interrupted by steering")
)
})
}));
let continued = runner.next().await.unwrap().unwrap();
assert_eq!(continued.content, "continued");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert!(requests[1].raw_history.is_empty());
assert!(CompletionRunner::unanswered_tool_calls(&requests[1].chat_history).is_empty());
assert!(requests[1].chat_history.iter().any(|message| {
message.content.iter().any(
|part| matches!(part, ContentPart::Text { text } if text == "planning before tool"),
)
}));
assert!(requests[1].content.iter().any(|part| matches!(
part,
ContentPart::Text { text } if text.trim() == super::COMPACTION_PROMPT.trim()
)));
assert!(requests[2].content.iter().any(|part| matches!(
part,
ContentPart::Text { text } if text == "redirect before tool"
)));
}
#[tokio::test(flavor = "current_thread")]
async fn runner_cancellation_returns_cancelled_output() {
let model = Model::with_completer(Arc::new(SlowCompleter));
let cancel_token = CancellationToken::new();
let ctx = EngineBuilder::new()
.with_model(model)
.with_cancellation_token(cancel_token.clone())
.mock_ctx();
let req = CompletionRequest {
prompt: "hello".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
cancel_token.cancel();
let output = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert!(output.failed_reason.is_some());
assert_eq!(output.failed_reason.unwrap(), "operation cancelled");
assert!(runner.next().await.unwrap().is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn runner_usage_accumulates_across_steps() {
let completer = ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "echo_tool".to_string(),
args: json!({"input": "test"}),
call_id: Some("call_1".into()),
result: None,
remote_id: None,
}],
};
let model = Model::with_completer(Arc::new(completer));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "test".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let step1 = runner.next().await.unwrap().unwrap();
assert!(!runner.is_done());
assert!(step1.usage.requests >= 1);
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert!(step2.usage.requests >= 2);
assert!(step2.usage.input_tokens > 0);
assert!(step2.usage.output_tokens > 0);
}
#[tokio::test(flavor = "current_thread")]
async fn stream_basic_completion() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "stream test".to_string(),
..Default::default()
};
let mut stream = ctx.completion_stream(req, Vec::new());
let item = stream.next().await;
assert!(item.is_some());
let output = item.unwrap().unwrap();
assert_eq!(output.content, "stream test");
let item = stream.next().await;
assert!(item.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn stream_keeps_pending_future_across_polls() {
let model = Model::with_completer(Arc::new(DelayedEchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "delayed stream".to_string(),
..Default::default()
};
let mut stream = ctx.completion_stream(req, Vec::new());
let output = tokio::time::timeout(std::time::Duration::from_millis(200), stream.next())
.await
.expect("stream should not restart a pending completion forever")
.unwrap()
.unwrap();
assert_eq!(output.content, "delayed stream");
assert_eq!(output.model, Some("delayed_echo".to_string()));
}
#[tokio::test(flavor = "current_thread")]
async fn stream_multi_step_with_tool_calls() {
let completer = ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "echo_tool".to_string(),
args: json!({"input": "via_stream"}),
call_id: Some("stream_call".into()),
result: None,
remote_id: None,
}],
};
let model = Model::with_completer(Arc::new(completer));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "stream with tools".to_string(),
..Default::default()
};
let stream = ctx.completion_stream(req, Vec::new());
let results: Vec<_> = stream.collect().await;
assert_eq!(results.len(), 2);
assert!(results[0].is_ok());
assert!(results[1].is_ok());
let final_output = results.last().unwrap().as_ref().unwrap();
assert_eq!(final_output.content, "tool_result_processed");
}
#[tokio::test(flavor = "current_thread")]
async fn stream_error_propagation() {
let model = Model::with_completer(Arc::new(ErrorCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "error stream".to_string(),
..Default::default()
};
let mut stream = ctx.completion_stream(req, Vec::new());
let item = stream.next().await;
assert!(item.is_some());
assert!(item.unwrap().is_err());
assert!(stream.next().await.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn stream_buffers_steering_while_step_in_flight() {
#[derive(Clone)]
struct GatedToolCallCompleter {
gate: Arc<tokio::sync::Notify>,
}
impl CompletionFeaturesDyn for GatedToolCallCompleter {
fn model_name(&self) -> String {
"gated_tool_call".to_string()
}
fn completion(
&self,
req: CompletionRequest,
) -> anda_core::BoxPinFut<Result<AgentOutput, BoxError>> {
let gate = self.gate.clone();
Box::pin(async move {
if req.role.as_deref() == Some("user") {
let text = req
.content
.iter()
.filter_map(|part| match part {
ContentPart::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
return Ok(AgentOutput {
content: format!("steered:{text}"),
usage: Usage {
requests: 1,
..Default::default()
},
..Default::default()
});
}
if req.role.as_deref() == Some("tool") {
return Ok(AgentOutput {
content: "tool_result_processed".to_string(),
..Default::default()
});
}
gate.notified().await;
Ok(AgentOutput {
tool_calls: vec![ToolCall {
name: "echo_tool".to_string(),
args: json!({"input": "x"}),
call_id: Some("gated_call".into()),
result: None,
remote_id: None,
}],
usage: Usage {
requests: 1,
..Default::default()
},
..Default::default()
})
})
}
}
let gate = Arc::new(tokio::sync::Notify::new());
let model = Model::with_completer(Arc::new(GatedToolCallCompleter { gate: gate.clone() }));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut stream = ctx.completion_stream(
CompletionRequest {
prompt: "start".to_string(),
..Default::default()
},
Vec::new(),
);
let waker = futures::task::noop_waker();
let mut poll_cx = std::task::Context::from_waker(&waker);
assert!(stream.poll_next_unpin(&mut poll_cx).is_pending());
stream.steer("redirect".to_string());
gate.notify_one();
let step1 = stream.next().await.unwrap().unwrap();
assert_eq!(step1.tool_calls.len(), 1);
let step2 = stream.next().await.unwrap().unwrap();
assert_eq!(step2.content, "steered:redirect");
assert!(stream.next().await.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn runner_step_counter_increments() {
let completer = ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "echo_tool".to_string(),
args: json!({}),
call_id: Some("step_call".into()),
result: None,
remote_id: None,
}],
};
let model = Model::with_completer(Arc::new(completer));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "steps".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
assert_eq!(runner.turns(), 0);
runner.next().await.unwrap(); assert_eq!(runner.turns(), 1);
runner.next().await.unwrap(); assert_eq!(runner.turns(), 2);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_chat_history_accumulated_in_final() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "hello".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.follow_up("follow up".to_string());
let step1 = runner.next().await.unwrap().unwrap();
let step1_history_len = step1.chat_history.len();
let step2 = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert!(step2.chat_history.len() >= step1_history_len);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_artifacts_accumulated_from_tool_calls() {
struct ArtifactTool;
#[derive(Debug, Deserialize)]
struct ArtifactArgs {}
impl Tool<BaseCtx> for ArtifactTool {
type Args = ArtifactArgs;
type Output = String;
fn name(&self) -> String {
"artifact_tool".to_string()
}
fn description(&self) -> String {
"Returns artifacts".to_string()
}
fn definition(&self) -> FunctionDefinition {
FunctionDefinition {
name: "artifact_tool".to_string(),
description: "Returns artifacts".to_string(),
parameters: json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}),
strict: Some(true),
}
}
async fn call(
&self,
_ctx: BaseCtx,
_args: Self::Args,
_resources: Vec<Resource>,
) -> Result<ToolOutput<String>, BoxError> {
Ok(ToolOutput {
output: "done".to_string(),
artifacts: vec![Resource {
tags: vec!["test_artifact".to_string()],
..Default::default()
}],
..Default::default()
})
}
}
let completer = ToolCallCompleter {
tool_calls: vec![ToolCall {
name: "artifact_tool".to_string(),
args: json!({}),
call_id: Some("art_call".into()),
result: None,
remote_id: None,
}],
};
let model = Model::with_completer(Arc::new(completer));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(ArtifactTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "artifacts".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.next().await.unwrap(); let final_out = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(final_out.artifacts.len(), 1);
assert_eq!(final_out.artifacts[0].tags, vec!["test_artifact"]);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_sets_model_name_in_output() {
let model = Model::with_completer(Arc::new(EchoCompleter));
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let req = CompletionRequest {
prompt: "check model".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
let output = runner.next().await.unwrap().unwrap();
assert_eq!(output.model, Some("echo".to_string()));
}
#[derive(Clone, Debug)]
struct ProviderCompleter {
name: &'static str,
requests: Arc<Mutex<Vec<CompletionRequest>>>,
}
impl CompletionFeaturesDyn for ProviderCompleter {
fn model_name(&self) -> String {
self.name.to_string()
}
fn completion(
&self,
req: CompletionRequest,
) -> anda_core::BoxPinFut<Result<AgentOutput, BoxError>> {
self.requests.lock().unwrap().push(req);
let name = self.name;
Box::pin(futures::future::ready(Ok(AgentOutput {
content: format!("{name} replied"),
chat_history: vec![Message {
role: "assistant".to_string(),
content: vec![ContentPart::Text {
text: format!("{name} replied"),
}],
..Default::default()
}],
raw_history: vec![json!({"provider": name})],
..Default::default()
})))
}
}
fn provider_model(
name: &'static str,
requests: Arc<Mutex<Vec<CompletionRequest>>>,
) -> (Model, Arc<ProviderCompleter>) {
let completer = Arc::new(ProviderCompleter { name, requests });
(Model::with_completer(completer.clone()), completer)
}
#[tokio::test(flavor = "current_thread")]
async fn runner_drops_raw_history_when_the_active_model_changes() {
let requests = Arc::new(Mutex::new(Vec::new()));
let (first, _) = provider_model("provider_a", requests.clone());
let (second, _) = provider_model("provider_b", requests.clone());
let ctx = EngineBuilder::new().with_model(first).mock_ctx();
let mut runner = ctx.clone().completion_iter(
CompletionRequest {
prompt: "hello".to_string(),
chat_history: vec![Message {
role: "user".to_string(),
content: vec![ContentPart::Text {
text: "earlier turn".to_string(),
}],
..Default::default()
}],
..Default::default()
},
Vec::new(),
);
runner.set_unbound(true);
let output = runner.next().await.unwrap().unwrap();
assert_eq!(output.model.as_deref(), Some("provider_a"));
assert_eq!(
runner.req.raw_history,
vec![json!({"provider": "provider_a"})]
);
ctx.models.set_model(second);
runner.follow_up("and now?".to_string());
let output = runner.next().await.unwrap().unwrap();
assert_eq!(output.model.as_deref(), Some("provider_b"));
let seen = requests.lock().unwrap();
let switched = seen.last().unwrap();
assert!(switched.raw_history.is_empty());
assert_eq!(
switched
.chat_history
.iter()
.flat_map(|msg| msg.content.iter())
.filter_map(|part| match part {
ContentPart::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>(),
vec!["earlier turn", "provider_a replied"]
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_keeps_raw_history_when_the_model_is_unchanged() {
let requests = Arc::new(Mutex::new(Vec::new()));
let (model, completer) = provider_model("provider_a", requests.clone());
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
let mut runner = ctx.clone().completion_iter(
CompletionRequest {
prompt: "hello".to_string(),
..Default::default()
},
Vec::new(),
);
runner.set_unbound(true);
runner.next().await.unwrap().unwrap();
ctx.models
.set_model(Model::with_completer(Arc::new((*completer).clone())));
runner.follow_up("and now?".to_string());
runner.next().await.unwrap().unwrap();
let seen = requests.lock().unwrap();
let second = seen.last().unwrap();
assert_eq!(second.raw_history, vec![json!({"provider": "provider_a"})]);
assert!(second.chat_history.is_empty());
}
#[tokio::test(flavor = "current_thread")]
async fn note_index_is_replayed_after_a_model_switch_but_not_persisted() {
use crate::extension::note::{NoteArgs, NoteContextConfig, NoteItemInput, NoteTool};
let requests = Arc::new(Mutex::new(Vec::new()));
let (first, _) = provider_model("provider_a", requests.clone());
let (second, _) = provider_model("provider_b", requests.clone());
let note = Arc::new(NoteTool::new());
let ctx = EngineBuilder::new()
.with_model(first)
.register_tool(note.clone())
.unwrap()
.mock_ctx();
note.call(
ctx.child_base(NoteTool::NAME).unwrap(),
NoteArgs {
op: Some("set".to_string()),
items: Some(vec![NoteItemInput {
id: "fact".to_string(),
content: Some("saved context".to_string()),
}]),
..Default::default()
},
Vec::new(),
)
.await
.unwrap();
ctx.base.set_state(NoteContextConfig::default());
let mut runner = ctx.clone().completion_iter(
CompletionRequest {
prompt: "hello".to_string(),
tools: vec![note.definition()],
chat_history: vec![Message {
role: "user".to_string(),
content: vec![ContentPart::Text {
text: "earlier turn".to_string(),
}],
..Default::default()
}],
..Default::default()
},
Vec::new(),
);
runner.set_unbound(true);
let output = runner.next().await.unwrap().unwrap();
for history in [
output.chat_history,
runner.idle_snapshot().unwrap().chat_history,
] {
assert!(
!serde_json::to_string(&history)
.unwrap()
.contains("Saved note index")
);
}
ctx.models.set_model(second);
runner.follow_up("and now?".to_string());
runner.next().await.unwrap().unwrap();
let seen = requests.lock().unwrap();
let texts = |req: &CompletionRequest| {
req.chat_history
.iter()
.flat_map(|msg| msg.content.iter())
.filter_map(|part| match part {
ContentPart::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
};
let first_turn = texts(&seen[0]);
assert_eq!(first_turn.len(), 2);
assert_eq!(first_turn[0], "earlier turn");
assert!(first_turn[1].contains("saved context"));
assert_eq!(
texts(seen.last().unwrap()),
vec![
"earlier turn".to_string(),
first_turn[1].clone(),
"provider_a replied".to_string()
]
);
}
#[tokio::test(flavor = "current_thread")]
async fn runner_multiple_tool_calls_in_parallel() {
let completer = ToolCallCompleter {
tool_calls: vec![
ToolCall {
name: "echo_tool".to_string(),
args: json!({"input": "first"}),
call_id: Some("call_a".into()),
result: None,
remote_id: None,
},
ToolCall {
name: "echo_tool".to_string(),
args: json!({"input": "second"}),
call_id: Some("call_b".into()),
result: None,
remote_id: None,
},
],
};
let model = Model::with_completer(Arc::new(completer));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let req = CompletionRequest {
prompt: "multi tools".to_string(),
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.next().await.unwrap(); let final_out = runner.next().await.unwrap().unwrap();
assert!(runner.is_done());
assert_eq!(final_out.tool_calls.len(), 2);
for tc in &final_out.tool_calls {
assert!(tc.result.is_some());
}
}
fn image_request(bytes: &[u8]) -> CompletionRequest {
CompletionRequest {
prompt: "look at this".to_string(),
content: vec![ContentPart::InlineData {
mime_type: "image/png".to_string(),
data: anda_core::ByteBufB64(bytes.to_vec()),
}],
..Default::default()
}
}
fn has_inline_data(messages: &[Message]) -> bool {
messages.iter().any(|message| {
message
.content
.iter()
.any(|part| matches!(part, ContentPart::InlineData { .. }))
})
}
#[tokio::test(flavor = "current_thread")]
async fn transient_inline_data_lasts_for_its_task_only() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(InlineMediaCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let bytes = b"transient image bytes";
let encoded = anda_core::ByteBufB64(bytes.to_vec()).to_base64();
let mut runner = ctx
.completion_iter(image_request(bytes), Vec::new())
.unbound();
runner.set_transient_inline_data(true);
let first = runner.next().await.unwrap().unwrap();
assert_eq!(first.tool_calls[0].name, "echo_tool");
assert!(!has_inline_data(runner.chat_history()));
assert_eq!(
runner.chat_history()[0].text().as_deref(),
Some("look at this")
);
let second = runner.next().await.unwrap().unwrap();
assert_eq!(second.content, "done");
assert!(runner.is_idle());
{
let requests = requests.lock().unwrap();
assert!(
serde_json::to_string(&requests[1].raw_history)
.unwrap()
.contains(&encoded)
);
}
let raw = serde_json::to_string(&runner.req().raw_history).unwrap();
assert!(!raw.contains(&encoded), "{raw}");
assert!(raw.contains("[inline image/png data omitted]"), "{raw}");
assert!(!has_inline_data(&second.chat_history));
runner.follow_up("and now?".to_string());
runner.next().await.unwrap().unwrap();
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert!(
!serde_json::to_string(&requests[2].raw_history)
.unwrap()
.contains(&encoded)
);
}
#[tokio::test(flavor = "current_thread")]
async fn stopping_a_task_prunes_its_transient_inline_data() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(InlineMediaCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let bytes = b"stopped image bytes";
let encoded = anda_core::ByteBufB64(bytes.to_vec()).to_base64();
let mut runner = ctx
.completion_iter(image_request(bytes), Vec::new())
.unbound();
runner.set_transient_inline_data(true);
runner.next().await.unwrap().unwrap();
assert!(
serde_json::to_string(&runner.req().raw_history)
.unwrap()
.contains(&encoded)
);
let output = runner.stop_current_task(AgentOutput::default());
assert!(
!serde_json::to_string(&runner.req().raw_history)
.unwrap()
.contains(&encoded)
);
assert!(!has_inline_data(&output.chat_history));
}
#[tokio::test(flavor = "current_thread")]
async fn inline_data_stays_in_history_unless_transient() {
let requests = Arc::new(Mutex::new(Vec::new()));
let model = Model::with_completer(Arc::new(InlineMediaCompleter {
requests: requests.clone(),
}));
let ctx = EngineBuilder::new()
.with_model(model)
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let bytes = b"kept image bytes";
let encoded = anda_core::ByteBufB64(bytes.to_vec()).to_base64();
let mut runner = ctx
.completion_iter(image_request(bytes), Vec::new())
.unbound();
runner.next().await.unwrap().unwrap();
runner.next().await.unwrap().unwrap();
assert!(runner.is_idle());
assert!(has_inline_data(runner.chat_history()));
assert!(
serde_json::to_string(&runner.req().raw_history)
.unwrap()
.contains(&encoded)
);
}
#[test]
fn strip_inline_data_keeps_other_parts_or_leaves_a_note() {
let inline = ContentPart::InlineData {
mime_type: "image/png".to_string(),
data: anda_core::ByteBufB64(vec![1, 2, 3]),
};
let data_uri = ContentPart::FileData {
file_uri: "data:application/pdf;base64,AAAA".to_string(),
mime_type: Some("application/pdf".to_string()),
};
let remote = ContentPart::FileData {
file_uri: "https://example.com/a.pdf".to_string(),
mime_type: Some("application/pdf".to_string()),
};
let mut messages = vec![
Message {
role: "user".to_string(),
content: vec!["caption".to_string().into(), inline.clone(), remote.clone()],
..Default::default()
},
Message {
role: "user".to_string(),
content: vec![data_uri, inline.clone()],
..Default::default()
},
Message {
role: "assistant".to_string(),
content: Vec::new(),
..Default::default()
},
Message {
role: "assistant".to_string(),
content: vec![inline.clone()],
..Default::default()
},
];
super::strip_inline_data(&mut messages);
assert_eq!(
messages[0].content,
vec!["caption".to_string().into(), remote]
);
assert_eq!(
messages[1].content,
vec![ContentPart::Text {
text: "[inline application/pdf data omitted]".to_string()
}]
);
assert!(messages[2].content.is_empty());
assert_eq!(messages[3].content, vec![inline]);
}
#[tokio::test(flavor = "current_thread")]
async fn handoff_keeps_transient_inline_data() {
let ctx = EngineBuilder::new()
.with_model(Model::with_completer(Arc::new(EchoCompleter)))
.mock_ctx();
let mut runner = ctx.completion_iter(CompletionRequest::default(), Vec::new());
runner.set_transient_inline_data(true);
let (new_runner, _output) = runner.handoff(None).await.unwrap();
assert!(new_runner.transient_inline_data);
}
#[tokio::test(flavor = "current_thread")]
async fn add_tools_appends_new_definitions_and_keeps_discovered_ones() {
let ctx = EngineBuilder::new().mock_ctx();
let tool = |name: &str| FunctionDefinition {
name: name.to_string(),
..Default::default()
};
let req = CompletionRequest {
tools: vec![tool("first")],
..Default::default()
};
let mut runner = ctx.completion_iter(req, Vec::new());
runner.set_merge_discovered_tools(Some(true));
runner.add_discovered_tools_from_output(
"tools_select",
&json!({"tools": [{
"name": "echo_tool",
"description": "Echoes input",
"parameters": {"type": "object"},
"strict": true
}]}),
);
runner.add_tools(vec![tool("First"), tool("second"), tool("second")]);
let names = runner
.req()
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, ["first", "second"]);
assert!(runner.discovered.contains("echo_tool"));
runner.set_tools(vec![tool("third")]);
assert!(!runner.discovered.contains("echo_tool"));
}
fn text_message(role: &str, text: &str) -> Message {
Message {
role: role.to_string(),
content: vec![ContentPart::Text {
text: text.to_string(),
}],
..Default::default()
}
}
fn echo_call() -> ToolCall {
ToolCall {
name: "echo_tool".to_string(),
args: json!({ "input": "hi" }),
call_id: Some("echo_call".into()),
result: None,
remote_id: None,
}
}
#[tokio::test(flavor = "current_thread")]
async fn stopping_before_the_first_turn_completes_keeps_the_seeded_history() {
let completer = ScriptedCompleter::new("scripted")
.push_error("model error")
.into_arc();
let ctx = EngineBuilder::new()
.with_model(Model::with_completer(completer.clone()))
.mock_ctx();
let earlier = text_message("user", "earlier turn");
let mut runner = ctx
.completion_iter(
CompletionRequest {
prompt: "first".to_string(),
chat_history: vec![earlier.clone()],
..Default::default()
},
Vec::new(),
)
.unbound();
assert!(runner.next().await.is_err());
runner.stop_current_task(AgentOutput {
content: "stopped".to_string(),
..Default::default()
});
runner.follow_up("second".to_string());
assert_eq!(runner.next().await.unwrap().unwrap().content, "second");
let requests = completer.requests();
assert_eq!(requests[1].chat_history, vec![earlier]);
}
#[tokio::test(flavor = "current_thread")]
async fn discarding_a_follow_up_tool_round_leaves_no_orphaned_tool_output() {
let completer = ScriptedCompleter::new("scripted")
.push_output(AgentOutput {
raw_history: vec![json!({
"role": "assistant",
"tool_calls": [{
"id": "echo_call",
"type": "function",
"function": {"name": "echo_tool", "arguments": "{}"}
}]
})],
tool_calls: vec![echo_call()],
..Default::default()
})
.push_error("model error")
.into_arc();
let ctx = EngineBuilder::new()
.with_model(Model::with_completer(completer.clone()))
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut runner = ctx
.completion_iter(
CompletionRequest {
prompt: "call tool".to_string(),
..Default::default()
},
Vec::new(),
)
.unbound();
runner.next().await.unwrap().unwrap();
runner.follow_up("and then?".to_string());
assert!(runner.next().await.is_err());
runner.discard_in_flight_request();
runner.follow_up("retry".to_string());
assert_eq!(runner.next().await.unwrap().unwrap().content, "retry");
let requests = completer.requests();
let retry = requests.last().unwrap();
assert!(
retry
.raw_history
.iter()
.all(|item| item.get("tool_calls").is_none())
);
assert!(
!retry
.chat_history
.iter()
.flat_map(|message| &message.content)
.any(|part| matches!(part, ContentPart::ToolOutput { .. })),
"the discarded round's tool output must not outlive its call"
);
}
#[tokio::test(flavor = "current_thread")]
async fn handoff_moves_pending_input_and_implicit_context_to_the_replacement() {
let completer = ScriptedCompleter::new("scripted")
.push_output(AgentOutput {
content: "summary".to_string(),
..Default::default()
})
.into_arc();
let ctx = EngineBuilder::new()
.with_model(Model::with_completer(completer.clone()))
.mock_ctx();
let mut runner = ctx
.completion_iter(
CompletionRequest {
prompt: "pending task".to_string(),
chat_history: vec![text_message("user", "earlier turn")],
..Default::default()
},
Vec::new(),
)
.unbound();
runner.implicit_context(text_message("user", "implicit context"));
let (mut runner, output) = runner.handoff(None).await.unwrap();
assert_eq!(output.content, "summary");
assert_eq!(
runner.next().await.unwrap().unwrap().content,
"pending task"
);
let requests = completer.requests();
assert_eq!(requests.len(), 2);
let summary_request = format!("{:?}", requests[0]);
assert!(summary_request.contains("earlier turn"));
assert!(!summary_request.contains("pending task"));
assert!(!summary_request.contains("implicit context"));
assert_eq!(requests[1].prompt, "pending task");
let replayed = requests[1]
.chat_history
.iter()
.filter_map(Message::text)
.collect::<Vec<_>>();
assert_eq!(replayed, ["summary", "implicit context"]);
}
#[tokio::test(flavor = "current_thread")]
async fn handoff_failure_keeps_executed_tool_outputs_queued() {
let completer = ScriptedCompleter::new("scripted")
.push_output(AgentOutput {
tool_calls: vec![echo_call()],
..Default::default()
})
.push_error("summary failed")
.into_arc();
let ctx = EngineBuilder::new()
.with_model(Model::with_completer(completer.clone()))
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut runner = ctx
.completion_iter(
CompletionRequest {
prompt: "call tool".to_string(),
..Default::default()
},
Vec::new(),
)
.unbound();
runner.next().await.unwrap().unwrap();
assert!(runner.handoff(None).await.is_err());
assert!(runner.no_pending_tool_calls());
let requests = completer.requests();
assert!(requests[1].chat_history.iter().any(|message| {
message.role == "tool"
&& matches!(
&message.content[..],
[ContentPart::ToolOutput { call_id, .. }] if call_id.as_deref() == Some("echo_call")
)
}));
runner.next().await.unwrap().unwrap();
let requests = completer.requests();
let next = requests.last().unwrap();
assert_eq!(next.role.as_deref(), Some("tool"));
assert!(next.content.iter().any(|part| matches!(
part,
ContentPart::ToolOutput { call_id, .. } if call_id.as_deref() == Some("echo_call")
)));
}
fn echo_call_message() -> Message {
Message {
role: "assistant".to_string(),
content: vec![ContentPart::ToolCall {
name: "echo_tool".to_string(),
args: json!({ "input": "hi" }),
call_id: Some("echo_call".into()),
}],
..Default::default()
}
}
#[tokio::test(flavor = "current_thread")]
async fn transient_inline_data_keeps_media_the_model_generated() {
let generated = ContentPart::InlineData {
mime_type: "image/png".to_string(),
data: anda_core::ByteBufB64(b"generated image".to_vec()),
};
let encoded = anda_core::ByteBufB64(b"generated image".to_vec()).to_base64();
let completer = ScriptedCompleter::new("scripted")
.push_output(AgentOutput {
content: "here it is".to_string(),
chat_history: vec![
text_message("user", "draw a cat"),
Message {
role: "assistant".to_string(),
content: vec!["here it is".to_string().into(), generated.clone()],
..Default::default()
},
],
raw_history: vec![
json!({"role": "user", "parts": [{"text": "draw a cat"}]}),
json!({"role": "model", "parts": [
{"text": "here it is"},
{"inlineData": {"mimeType": "image/png", "data": encoded}, "thoughtSignature": "sig"}
]}),
],
..Default::default()
})
.into_arc();
let ctx = EngineBuilder::new()
.with_model(Model::with_completer(completer))
.mock_ctx();
let mut runner = ctx
.completion_iter(
CompletionRequest {
prompt: "draw a cat".to_string(),
..Default::default()
},
Vec::new(),
)
.unbound();
runner.set_transient_inline_data(true);
let output = runner.next().await.unwrap().unwrap();
assert!(runner.is_idle());
assert_eq!(output.chat_history[1].content[1], generated);
let raw = serde_json::to_string(&runner.req().raw_history).unwrap();
assert!(raw.contains(&encoded), "{raw}");
assert!(raw.contains("thoughtSignature"), "{raw}");
}
#[tokio::test(flavor = "current_thread")]
async fn discarding_a_failed_request_prunes_its_transient_inline_data() {
let encoded = anda_core::ByteBufB64(b"discarded image bytes".to_vec()).to_base64();
let completer = ScriptedCompleter::new("scripted")
.push_output(AgentOutput {
raw_history: vec![json!({"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": encoded}}
]})],
tool_calls: vec![echo_call()],
..Default::default()
})
.push_error("model error")
.into_arc();
let ctx = EngineBuilder::new()
.with_model(Model::with_completer(completer.clone()))
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut runner = ctx
.completion_iter(image_request(b"discarded image bytes"), Vec::new())
.unbound();
runner.set_transient_inline_data(true);
runner.next().await.unwrap().unwrap();
assert!(runner.next().await.is_err());
runner.discard_in_flight_request();
assert!(runner.is_idle());
runner.follow_up("next".to_string());
runner.next().await.unwrap().unwrap();
let requests = completer.requests();
let next = serde_json::to_string(&requests.last().unwrap().raw_history).unwrap();
assert!(!next.contains(&encoded), "{next}");
assert!(next.contains("[inline image/png data omitted]"), "{next}");
}
#[tokio::test(flavor = "current_thread")]
async fn stopping_a_tool_round_after_a_model_switch_answers_its_calls_in_the_replay() {
let first = ScriptedCompleter::new("provider_a")
.push_output(AgentOutput {
chat_history: vec![echo_call_message()],
raw_history: vec![json!({"provider": "provider_a"})],
tool_calls: vec![echo_call()],
..Default::default()
})
.into_arc();
let second = ScriptedCompleter::new("provider_b")
.push_error("model error")
.into_arc();
let ctx = EngineBuilder::new()
.with_model(Model::with_completer(first))
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut runner = ctx
.clone()
.completion_iter(
CompletionRequest {
prompt: "call tool".to_string(),
..Default::default()
},
Vec::new(),
)
.unbound();
runner.next().await.unwrap().unwrap();
ctx.models.set_model(Model::with_completer(second.clone()));
assert!(runner.next().await.is_err());
runner.stop_current_task(AgentOutput::default());
runner.follow_up("next".to_string());
assert_eq!(runner.next().await.unwrap().unwrap().content, "next");
let requests = second.requests();
let replay = &requests.last().unwrap().chat_history;
assert!(
CompletionRunner::unanswered_tool_calls(replay).is_empty(),
"{replay:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn implicit_context_does_not_split_tool_calls_from_their_outputs() {
let completer = ScriptedCompleter::new("scripted")
.push_output(AgentOutput {
tool_calls: vec![echo_call()],
..Default::default()
})
.push_error("summary failed")
.into_arc();
let ctx = EngineBuilder::new()
.with_model(Model::with_completer(completer.clone()))
.register_tool(Arc::new(EchoTool))
.unwrap()
.mock_ctx();
let mut runner = ctx
.completion_iter(
CompletionRequest {
prompt: "call tool".to_string(),
..Default::default()
},
Vec::new(),
)
.unbound();
runner.next().await.unwrap().unwrap();
runner.implicit_context(text_message("user", "implicit context"));
assert!(runner.handoff(None).await.is_err());
let has_implicit_context = |req: &CompletionRequest| {
req.chat_history
.iter()
.any(|message| message.text().as_deref() == Some("implicit context"))
};
runner.next().await.unwrap().unwrap();
let requests = completer.requests();
let tool_turn = requests.last().unwrap();
assert_eq!(tool_turn.role.as_deref(), Some("tool"));
assert!(!has_implicit_context(tool_turn));
runner.follow_up("next".to_string());
runner.next().await.unwrap().unwrap();
assert!(has_implicit_context(completer.requests().last().unwrap()));
}
}