1use std::sync::Arc;
16
17use async_trait::async_trait;
18use lash_core::llm::types::{ProviderReasoningReplay, ProviderReplayMeta, ResponseTextMeta};
19use lash_core::plugin::{
20 PluginError, PluginFactory, PluginRegistrar, PluginSessionContext, ProtocolDriverPlugin,
21 ProtocolSessionContext, ProtocolSessionPlugin, SessionPlugin,
22};
23use lash_core::sansio::{
24 CheckpointResumeAction, CompletedToolCall, PendingToolCall, ProtocolDriverHandle,
25 WaitingExecState, WaitingLlmState,
26};
27use lash_core::session_model::message::PartAttachment;
28use lash_core::session_model::{
29 ConversationRecord, Message, MessageRole, Part, PartKind, PruneState, SessionHistoryRecord,
30 SessionStreamEvent, fresh_message_id, make_error_event, reassign_part_ids, shared_parts,
31};
32
33mod batch;
34pub mod scenario_contracts;
35use batch::batch_tool_definition;
36use lash_core::{
37 CheckpointKind, DriverAction, DriverContextView, LlmOutputPart, LlmResponse,
38 ProtocolBuildInput, SessionError, ToolCall, ToolContract, ToolInvocation, ToolManifest,
39 ToolProvider, ToolResult, TurnDriverConfig, TurnDriverPreamble, TurnFinish, TurnOutcome,
40 TurnStop, append_assistant_text_part, normalized_response_parts, reasoning_part,
41};
42use serde_json::Value;
43
44const STANDARD_EXECUTION_SECTION: &str = r#"Use direct tool calls.
45
46- Use `batch` (up to 25 calls) for two or more independent tool calls. Serialize calls when later arguments depend on earlier results.
47- For direct conversational requests that need no tools, respond in prose only.
48
49Example — two independent reads in one `batch` call:
50
51```json
52{
53 "tool_calls": [
54 { "tool": "read_file", "parameters": { "path": "src/main.rs" } },
55 { "tool": "grep", "parameters": { "query": "ToolProvider", "path": "crates/lash/src/" } }
56 ]
57}
58```"#;
59
60const BATCH_MAX_TOOL_CALLS: usize = 25;
61const STANDARD_PROTOCOL_PLUGIN_ID: &str = "standard_protocol";
62
63#[derive(Default)]
66pub struct StandardProtocolPluginFactory;
67
68impl StandardProtocolPluginFactory {
69 pub fn new() -> Self {
70 Self
71 }
72}
73
74impl PluginFactory for StandardProtocolPluginFactory {
75 fn id(&self) -> &'static str {
76 STANDARD_PROTOCOL_PLUGIN_ID
77 }
78
79 fn build(&self, _ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
80 Ok(Arc::new(StandardProtocolPlugin))
81 }
82}
83
84struct StandardProtocolPlugin;
85
86impl SessionPlugin for StandardProtocolPlugin {
87 fn id(&self) -> &'static str {
88 STANDARD_PROTOCOL_PLUGIN_ID
89 }
90
91 fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
92 reg.protocol().session(Arc::new(StandardProtocolSession))?;
93 reg.protocol()
94 .protocol_driver(Arc::new(StandardProtocolDriver))?;
95 reg.tools().provider(Arc::new(StandardProtocolTools))?;
96 Ok(())
97 }
98}
99
100struct StandardProtocolSession;
101
102#[async_trait]
103impl ProtocolSessionPlugin for StandardProtocolSession {
104 async fn initialize_session(
105 &self,
106 _ctx: ProtocolSessionContext<'_>,
107 ) -> Result<(), SessionError> {
108 Ok(())
109 }
110}
111
112struct StandardProtocolDriver;
113
114impl ProtocolDriverPlugin for StandardProtocolDriver {
115 fn build_preamble(&self, input: ProtocolBuildInput) -> TurnDriverPreamble {
116 let tool_names = input.tool_catalog.tool_names();
117 let tool_names_fingerprint = input.tool_catalog.tool_names_fingerprint();
118 TurnDriverPreamble {
119 config: TurnDriverConfig::chat(
120 Arc::new(StandardDriver),
121 true,
122 Arc::new(turn_limit_exhausted_message),
123 ),
124 tool_specs: input.tool_catalog.model_tool_specs(),
125 tool_names,
126 tool_names_fingerprint,
127 execution_prompt: Arc::from(STANDARD_EXECUTION_SECTION),
128 prompt_contributions: input.extra_prompt_contributions,
129 }
130 }
131}
132
133fn turn_limit_exhausted_message(message_id: String, max_turns: usize) -> Message {
134 Message {
135 id: message_id.clone(),
136 role: MessageRole::System,
137 parts: shared_parts(vec![Part {
138 id: format!("{message_id}.p0"),
139 kind: PartKind::Error,
140 content: format!("Turn limit reached ({max_turns}) before a final assistant response."),
141 attachment: None,
142 tool_call_id: None,
143 tool_name: None,
144 tool_replay: None,
145 prune_state: PruneState::Intact,
146 reasoning_meta: None,
147 response_meta: None,
148 }]),
149 origin: None,
150 }
151}
152
153struct StandardProtocolTools;
154
155#[async_trait]
156impl ToolProvider for StandardProtocolTools {
157 fn tool_manifests(&self) -> Vec<ToolManifest> {
158 vec![batch_tool_definition().manifest()]
159 }
160
161 fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
162 (name == "batch").then(|| Arc::new(batch_tool_definition().contract()))
163 }
164
165 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
166 match call.name {
167 "batch" => execute_batch_tool_call(call).await,
168 _ => ToolResult::err_fmt(format_args!("Unknown tool: {}", call.name)),
169 }
170 }
171}
172
173#[derive(Debug)]
174struct BatchCallSpec {
175 index: usize,
176 tool: String,
177 parameters: Value,
178}
179
180async fn execute_batch_tool_call(call: ToolCall<'_>) -> ToolResult {
181 let args = call.args;
182 let specs = match parse_batch_specs(args) {
183 Ok(specs) => specs,
184 Err(err) => return err,
185 };
186
187 let mut immediate_outcomes = Vec::new();
188 let mut parallel_specs = Vec::new();
189 let dispatch = call.context.dispatch();
190
191 for spec in specs.into_iter().take(BATCH_MAX_TOOL_CALLS) {
192 if spec.tool == "batch" {
193 immediate_outcomes.push(serde_json::json!({
194 "index": spec.index,
195 "tool": spec.tool,
196 "success": false,
197 "duration_ms": 0,
198 "error": "Tool 'batch' is not allowed inside batch",
199 }));
200 continue;
201 }
202 let Some(manifest) = dispatch.callable_tool_manifest(&spec.tool) else {
203 let error = format!("Tool '{}' is unavailable in this session", spec.tool);
204 immediate_outcomes.push(serde_json::json!({
205 "index": spec.index,
206 "tool": spec.tool,
207 "success": false,
208 "duration_ms": 0,
209 "error": error,
210 }));
211 continue;
212 };
213 parallel_specs.push((
214 spec.index,
215 ToolInvocation::new(
216 format!(
217 "{}:{:02}",
218 call.context.tool_call_id().unwrap_or("batch"),
219 spec.index
220 ),
221 manifest.id,
222 spec.parameters,
223 ),
224 ));
225 }
226
227 let mut parallel_outcomes = dispatch
228 .batch(
229 parallel_specs
230 .iter()
231 .map(|(_, invocation)| invocation.clone())
232 .collect(),
233 )
234 .await;
235 for ((index, invocation), outcome) in
236 parallel_specs.into_iter().zip(parallel_outcomes.drain(..))
237 {
238 let tool_label = invocation.label();
239 let tool_record = outcome.record.unwrap_or(lash_core::ToolCallRecord {
240 call_id: Some(invocation.id),
241 tool: tool_label,
242 args: invocation.args,
243 output: outcome.output,
244 duration_ms: 0,
245 });
246 let mut result_record = serde_json::Map::new();
247 result_record.insert("index".to_string(), serde_json::json!(index));
248 result_record.insert("tool".to_string(), serde_json::json!(tool_record.tool));
249 result_record.insert(
250 "success".to_string(),
251 serde_json::json!(tool_record.output.is_success()),
252 );
253 result_record.insert(
254 "duration_ms".to_string(),
255 serde_json::json!(tool_record.duration_ms),
256 );
257 result_record.insert(
258 if tool_record.output.is_success() {
259 "result".to_string()
260 } else {
261 "error".to_string()
262 },
263 tool_record.output.value_for_projection(),
264 );
265 immediate_outcomes.push(Value::Object(result_record));
266 }
267
268 for overflow_index in BATCH_MAX_TOOL_CALLS
269 ..args
270 .get("tool_calls")
271 .and_then(|value| value.as_array())
272 .map(|value| value.len())
273 .unwrap_or_default()
274 {
275 immediate_outcomes.push(serde_json::json!({
276 "index": overflow_index,
277 "tool": args
278 .get("tool_calls")
279 .and_then(|value| value.as_array())
280 .and_then(|items| items.get(overflow_index))
281 .and_then(|item| item.get("tool"))
282 .and_then(|value| value.as_str())
283 .unwrap_or("unknown"),
284 "success": false,
285 "duration_ms": 0,
286 "error": "Maximum of 25 tool calls allowed in batch",
287 }));
288 }
289
290 immediate_outcomes.sort_by_key(|outcome| {
291 outcome
292 .get("index")
293 .and_then(|value| value.as_u64())
294 .unwrap_or(u64::MAX)
295 });
296 ToolResult::ok(serde_json::json!({
297 "results": immediate_outcomes,
298 }))
299}
300
301fn parse_batch_specs(args: &Value) -> Result<Vec<BatchCallSpec>, ToolResult> {
302 let Some(raw_calls) = args.get("tool_calls").and_then(|value| value.as_array()) else {
303 return Err(ToolResult::err_fmt(
304 "Missing required parameter: tool_calls",
305 ));
306 };
307 if raw_calls.is_empty() {
308 return Err(ToolResult::err_fmt(
309 "Invalid tool_calls: expected at least one call",
310 ));
311 }
312
313 let mut specs = Vec::with_capacity(raw_calls.len());
314 for (index, item) in raw_calls.iter().enumerate() {
315 let Some(object) = item.as_object() else {
316 return Err(ToolResult::err_fmt(format_args!(
317 "Invalid tool_calls[{index}]: expected object with tool and parameters"
318 )));
319 };
320 let Some(tool) = object
321 .get("tool")
322 .and_then(|value| value.as_str())
323 .map(str::trim)
324 .filter(|tool| !tool.is_empty())
325 else {
326 return Err(ToolResult::err_fmt(format_args!(
327 "Invalid tool_calls[{index}].tool: expected non-empty string"
328 )));
329 };
330 let parameters = object
331 .get("parameters")
332 .cloned()
333 .unwrap_or_else(|| serde_json::json!({}));
334 specs.push(BatchCallSpec {
335 index,
336 tool: tool.to_string(),
337 parameters,
338 });
339 }
340
341 Ok(specs)
342}
343
344pub struct StandardDriver;
354
355struct StandardToolCall {
356 call_id: String,
357 tool_name: String,
358 input_json: String,
359 replay: Option<ProviderReplayMeta>,
360}
361
362fn last_message_has_tool_result(ctx: &DriverContextView<'_>) -> bool {
363 ctx.messages().last().is_some_and(|message| {
364 matches!(message.role, MessageRole::User)
365 && message
366 .parts
367 .iter()
368 .any(|part| matches!(part.kind, PartKind::ToolResult))
369 })
370}
371
372impl ProtocolDriverHandle<lash_core::HostTurnProtocol> for StandardDriver {
373 fn prepare_protocol_iteration(&self, ctx: DriverContextView<'_>) -> Vec<DriverAction> {
374 vec![DriverAction::StartLlm {
375 request: ctx.project_llm_request(true),
376 driver_state: None,
377 }]
378 }
379
380 fn handle_llm_success(
381 &self,
382 ctx: DriverContextView<'_>,
383 _waiting: WaitingLlmState<lash_core::HostTurnProtocol>,
384 llm_response: LlmResponse,
385 text_streamed: bool,
386 ) -> Vec<DriverAction> {
387 let response_parts = normalized_response_parts(&llm_response);
388 let mut assistant_text = String::new();
389 let mut assistant_text_parts: Vec<(String, Option<ResponseTextMeta>)> = Vec::new();
390 let mut tool_calls: Vec<StandardToolCall> = Vec::new();
391 let mut reasoning_items: Vec<(usize, Option<ProviderReasoningReplay>, String)> = Vec::new();
399 let mut actions = Vec::new();
400
401 for part in response_parts {
402 match part {
403 LlmOutputPart::Text {
404 text,
405 response_meta,
406 } => {
407 if !text.is_empty() {
408 let previous_len = assistant_text.len();
409 append_assistant_text_part(&mut assistant_text, &text);
410 assistant_text_parts
411 .push((assistant_text[previous_len..].to_string(), response_meta));
412 if !text_streamed {
413 actions.push(DriverAction::Emit(SessionStreamEvent::TextDelta {
414 content: assistant_text[previous_len..].to_string(),
415 }));
416 }
417 }
418 }
419 LlmOutputPart::Reasoning { text, replay } => {
420 let trimmed = text.trim().to_string();
421 if trimmed.is_empty() && replay.as_ref().is_none_or(|meta| meta.is_empty()) {
424 continue;
425 }
426 reasoning_items.push((tool_calls.len(), replay, trimmed));
427 }
428 LlmOutputPart::ToolCall {
429 call_id,
430 tool_name,
431 input_json,
432 replay,
433 } => {
434 tool_calls.push(StandardToolCall {
435 call_id,
436 tool_name,
437 input_json,
438 replay,
439 });
440 }
441 }
442 }
443
444 actions.push(DriverAction::Emit(SessionStreamEvent::LlmResponse {
445 protocol_iteration: ctx.protocol_iteration(),
446 content: assistant_text.clone(),
447 duration_ms: 0,
448 }));
449
450 if tool_calls.is_empty() {
451 if assistant_text.trim().is_empty() && reasoning_items.is_empty() {
452 if last_message_has_tool_result(&ctx) {
453 actions.push(DriverAction::StartCheckpoint {
457 checkpoint: CheckpointKind::BeforeCompletion,
458 on_empty: CheckpointResumeAction::Finish(TurnOutcome::Finished(
459 TurnFinish::AssistantMessage {
460 text: String::new(),
461 },
462 )),
463 });
464 return actions;
465 }
466 actions.push(DriverAction::Emit(make_error_event(
467 "llm_provider",
468 Some("empty_response"),
469 "Model returned no assistant text or tool calls.",
470 None,
471 )));
472 actions.push(DriverAction::Finish(TurnOutcome::Stopped(
473 TurnStop::ProviderError,
474 )));
475 return actions;
476 }
477
478 let asst_id = fresh_message_id();
479 let mut parts_out = Vec::new();
480 for (_, meta, text) in reasoning_items {
481 parts_out.push(reasoning_part(&asst_id, parts_out.len(), text, meta));
482 }
483 for (content, response_meta) in assistant_text_parts {
484 if content.trim().is_empty() {
485 continue;
486 }
487 parts_out.push(Part {
488 id: format!("{}.p{}", asst_id, parts_out.len()),
489 kind: PartKind::Prose,
490 content,
491 attachment: None,
492 tool_call_id: None,
493 tool_name: None,
494 tool_replay: None,
495 prune_state: PruneState::Intact,
496 reasoning_meta: None,
497 response_meta,
498 });
499 }
500 if parts_out.is_empty() {
501 actions.push(DriverAction::Emit(make_error_event(
502 "llm_provider",
503 Some("empty_response"),
504 "Model returned no assistant text or tool calls.",
505 None,
506 )));
507 actions.push(DriverAction::Finish(TurnOutcome::Stopped(
508 TurnStop::ProviderError,
509 )));
510 return actions;
511 }
512 actions.push(DriverAction::StartCheckpoint {
513 checkpoint: CheckpointKind::BeforeCompletion,
514 on_empty: CheckpointResumeAction::Finish(TurnOutcome::Finished(
515 TurnFinish::AssistantMessage {
516 text: assistant_text.clone(),
517 },
518 )),
519 });
520 return actions;
521 }
522
523 let asst_id = fresh_message_id();
524 let mut assistant_parts = Vec::new();
525 for (content, response_meta) in assistant_text_parts {
526 if content.trim().is_empty() {
527 continue;
528 }
529 assistant_parts.push(Part {
530 id: format!("{}.p{}", asst_id, assistant_parts.len()),
531 kind: PartKind::Prose,
532 content,
533 attachment: None,
534 tool_call_id: None,
535 tool_name: None,
536 tool_replay: None,
537 prune_state: PruneState::Intact,
538 reasoning_meta: None,
539 response_meta,
540 });
541 }
542
543 let mut calls = Vec::new();
544 let mut reasoning_iter = reasoning_items.into_iter().peekable();
549 for (tool_index, tool_call) in tool_calls.into_iter().enumerate() {
550 while let Some((insert_index, _, _)) = reasoning_iter.peek() {
551 if *insert_index > tool_index {
552 break;
553 }
554 let (_, meta, text) = reasoning_iter.next().expect("peek ok");
555 assistant_parts.push(reasoning_part(&asst_id, assistant_parts.len(), text, meta));
556 }
557 assistant_parts.push(Part {
558 id: format!("{}.p{}", asst_id, assistant_parts.len()),
559 kind: PartKind::ToolCall,
560 content: tool_call.input_json.clone(),
561 attachment: None,
562 tool_call_id: Some(tool_call.call_id.clone()),
563 tool_name: Some(tool_call.tool_name.clone()),
564 tool_replay: tool_call.replay.clone(),
565 prune_state: PruneState::Intact,
566 reasoning_meta: None,
567 response_meta: None,
568 });
569
570 let args = serde_json::from_str::<Value>(&tool_call.input_json)
571 .unwrap_or_else(|_| serde_json::json!({}));
572 calls.push(PendingToolCall {
573 call_id: tool_call.call_id,
574 tool_name: tool_call.tool_name,
575 args,
576 replay: tool_call.replay,
577 });
578 }
579 for (_, meta, text) in reasoning_iter {
580 assistant_parts.push(reasoning_part(&asst_id, assistant_parts.len(), text, meta));
581 }
582
583 if !assistant_parts.is_empty() {
584 actions.push(DriverAction::AppendEvents(vec![conversation_event(
585 Message {
586 id: asst_id,
587 role: MessageRole::Assistant,
588 parts: shared_parts(assistant_parts),
589 origin: None,
590 },
591 )]));
592 }
593
594 actions.push(DriverAction::StartTools { calls });
595 actions
596 }
597
598 fn handle_tool_results(
599 &self,
600 ctx: DriverContextView<'_>,
601 completed: Vec<CompletedToolCall>,
602 ) -> Vec<DriverAction> {
603 let mut actions = Vec::new();
604 let mut result_parts = Vec::new();
605 let mut terminal_outcome = None;
606
607 for outcome in completed {
608 if terminal_outcome.is_none() && outcome.output.is_success() {
609 terminal_outcome = outcome.output.control.as_ref().and_then(|control| {
610 lash_core::turn_outcome_from_tool_control(&outcome.tool_name, control)
611 });
612 }
613
614 append_model_return_parts(&mut result_parts, outcome.model_return);
615 }
616
617 if !result_parts.is_empty() {
618 let user_id = fresh_message_id();
619 reassign_part_ids(&user_id, &mut result_parts);
620 actions.push(DriverAction::AppendEvents(vec![conversation_event(
621 Message {
622 id: user_id,
623 role: MessageRole::User,
624 parts: shared_parts(result_parts),
625 origin: None,
626 },
627 )]));
628 }
629
630 if let Some(outcome) = terminal_outcome {
631 actions.push(DriverAction::Finish(outcome));
632 return actions;
633 }
634
635 actions.push(DriverAction::AdvanceProtocolIteration);
636 let next_protocol_iteration = ctx.protocol_iteration() + 1;
637 if let Some(max_turns) = ctx.max_turns()
638 && next_protocol_iteration >= ctx.protocol_run_offset() + max_turns
639 {
640 let message_id = fresh_message_id();
641 actions.push(DriverAction::AppendEvents(vec![conversation_event(
642 turn_limit_exhausted_message(message_id, max_turns),
643 )]));
644 actions.push(DriverAction::Finish(TurnOutcome::Stopped(
645 TurnStop::MaxTurns,
646 )));
647 return actions;
648 }
649
650 actions.push(DriverAction::StartCheckpoint {
651 checkpoint: CheckpointKind::AfterWork,
652 on_empty: CheckpointResumeAction::PrepareIteration,
653 });
654 actions
655 }
656
657 fn handle_exec_result(
658 &self,
659 _ctx: DriverContextView<'_>,
660 _waiting: WaitingExecState<lash_core::HostTurnProtocol>,
661 _result: Result<lash_core::ExecResponse, String>,
662 ) -> Vec<DriverAction> {
663 Vec::new()
664 }
665}
666
667fn append_model_return_parts(parts: &mut Vec<Part>, model_return: lash_core::ModelToolReturn) {
668 for part in model_return.parts {
669 match part {
670 lash_core::ModelToolReturnPart::Text { text } => {
671 if text.is_empty() {
672 continue;
673 }
674 parts.push(Part {
675 id: String::new(),
676 kind: PartKind::ToolResult,
677 content: text,
678 attachment: None,
679 tool_call_id: Some(model_return.call_id.clone()),
680 tool_name: Some(model_return.tool_name.clone()),
681 tool_replay: None,
682 prune_state: PruneState::Intact,
683 reasoning_meta: None,
684 response_meta: None,
685 });
686 }
687 lash_core::ModelToolReturnPart::Attachment(source) => {
688 parts.push(Part {
689 id: String::new(),
690 kind: PartKind::Attachment,
691 content: String::new(),
692 attachment: Some(PartAttachment { source }),
693 tool_call_id: Some(model_return.call_id.clone()),
694 tool_name: Some(model_return.tool_name.clone()),
695 tool_replay: None,
696 prune_state: PruneState::Intact,
697 reasoning_meta: None,
698 response_meta: None,
699 });
700 }
701 }
702 }
703}
704
705fn conversation_event(message: Message) -> SessionHistoryRecord {
706 SessionHistoryRecord::Conversation(ConversationRecord::from_message(message))
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use lash_core::{
713 AttachmentId, AttachmentMeta, AttachmentSource, AttachmentTypeMetadata, MediaType,
714 ModelToolReturn, ToolCallOutput, ToolValue,
715 };
716 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
717 use tokio::sync::Barrier;
718 use tokio::time::{Duration, timeout};
719
720 fn attachment_source(id: &str) -> AttachmentSource {
721 AttachmentSource::stored(
722 AttachmentMeta::new(
723 AttachmentId::new(id),
724 MediaType::parse("image/png").unwrap(),
725 4,
726 Some(AttachmentTypeMetadata::image(Some(1), Some(1))),
727 Some("tiny".to_string()),
728 )
729 .as_ref(),
730 )
731 }
732
733 #[test]
734 fn standard_protocol_factory_id_is_stable_plugin_contract() {
735 let factory = StandardProtocolPluginFactory::new();
736
737 assert_eq!(factory.id(), STANDARD_PROTOCOL_PLUGIN_ID);
738 assert_eq!(factory.id(), "standard_protocol");
739 }
740
741 #[derive(Clone, Debug)]
742 struct BatchRuntimeProvider {
743 calls: Arc<AtomicUsize>,
744 saw_batch_result: Arc<AtomicBool>,
745 }
746
747 #[async_trait::async_trait]
748 impl lash_core::Provider for BatchRuntimeProvider {
749 fn kind(&self) -> &'static str {
750 "stub"
751 }
752
753 fn options(&self) -> lash_core::ProviderOptions {
754 lash_core::ProviderOptions::default()
755 }
756
757 fn set_options(&mut self, _options: lash_core::ProviderOptions) {}
758
759 fn serialize_config(&self) -> serde_json::Value {
760 serde_json::json!({})
761 }
762
763 async fn complete(
764 &mut self,
765 request: lash_core::LlmRequest,
766 ) -> Result<lash_core::LlmResponse, lash_core::LlmTransportError> {
767 let call_index = self.calls.fetch_add(1, Ordering::SeqCst);
768 if call_index == 0 {
769 return Ok(lash_core::LlmResponse {
770 parts: vec![lash_core::LlmOutputPart::ToolCall {
771 call_id: "batch-call".to_string(),
772 tool_name: "batch".to_string(),
773 input_json: serde_json::json!({
774 "tool_calls": [
775 {"tool": "alpha", "parameters": {}},
776 {"tool": "beta", "parameters": {"value": "fail"}}
777 ]
778 })
779 .to_string(),
780 replay: None,
781 }],
782 response_metadata: Default::default(),
783 ..lash_core::LlmResponse::default()
784 });
785 }
786
787 let projected_messages = format!("{:?}", request.messages);
788 if projected_messages.contains("alpha") && projected_messages.contains("beta failed") {
789 self.saw_batch_result.store(true, Ordering::SeqCst);
790 }
791 Ok(lash_core::LlmResponse {
792 full_text: "done".to_string(),
793 parts: vec![lash_core::LlmOutputPart::Text {
794 text: "done".to_string(),
795 response_meta: None,
796 }],
797 response_metadata: Default::default(),
798 ..lash_core::LlmResponse::default()
799 })
800 }
801
802 fn clone_boxed(&self) -> Box<dyn lash_core::Provider> {
803 Box::new(self.clone())
804 }
805 }
806
807 #[derive(Debug)]
808 struct BatchRuntimeTools {
809 barrier: Arc<Barrier>,
810 started: Arc<AtomicUsize>,
811 }
812
813 fn runtime_test_tool(name: &str) -> lash_core::ToolDefinition {
814 lash_core::ToolDefinition::raw(
815 format!("tool:{name}"),
816 name,
817 "",
818 serde_json::json!({
819 "type": "object",
820 "properties": {
821 "value": { "type": "string" }
822 },
823 "additionalProperties": true
824 }),
825 serde_json::json!({ "type": "string" }),
826 )
827 }
828
829 #[async_trait::async_trait]
830 impl ToolProvider for BatchRuntimeTools {
831 fn tool_manifests(&self) -> Vec<ToolManifest> {
832 vec![
833 runtime_test_tool("alpha").manifest(),
834 runtime_test_tool("beta").manifest(),
835 ]
836 }
837
838 fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
839 match name {
840 "alpha" | "beta" => Some(Arc::new(runtime_test_tool(name).contract())),
841 _ => None,
842 }
843 }
844
845 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
846 self.started.fetch_add(1, Ordering::SeqCst);
847 if timeout(Duration::from_millis(100), self.barrier.wait())
848 .await
849 .is_err()
850 {
851 return ToolResult::err_fmt("batch child tools did not run concurrently");
852 }
853 if call.name == "beta"
854 && call.args.get("value").and_then(|value| value.as_str()) == Some("fail")
855 {
856 return ToolResult::err_fmt("beta failed");
857 }
858 ToolResult::ok(serde_json::json!(call.name))
859 }
860 }
861
862 #[derive(Clone, Default)]
863 struct CountingEffectController {
864 kinds: Arc<std::sync::Mutex<Vec<lash_core::RuntimeEffectKind>>>,
865 }
866
867 impl CountingEffectController {
868 fn count(&self, kind: lash_core::RuntimeEffectKind) -> usize {
869 self.kinds
870 .lock()
871 .expect("effect kinds")
872 .iter()
873 .filter(|candidate| **candidate == kind)
874 .count()
875 }
876 }
877
878 #[derive(Default)]
879 struct DurableMemoryAttachmentStore {
880 inner: lash_core::InMemoryAttachmentStore,
881 }
882
883 #[async_trait::async_trait]
884 impl lash_core::AttachmentStore for DurableMemoryAttachmentStore {
885 fn persistence(&self) -> lash_core::AttachmentStorePersistence {
886 lash_core::AttachmentStorePersistence::Durable
887 }
888
889 async fn put(
890 &self,
891 bytes: Vec<u8>,
892 meta: lash_core::AttachmentCreateMeta,
893 ) -> Result<lash_core::AttachmentRef, lash_core::AttachmentStoreError> {
894 self.inner.put(bytes, meta).await
895 }
896
897 async fn get(
898 &self,
899 id: &lash_core::AttachmentId,
900 ) -> Result<lash_core::StoredAttachment, lash_core::AttachmentStoreError> {
901 self.inner.get(id).await
902 }
903
904 async fn delete(
905 &self,
906 id: &lash_core::AttachmentId,
907 ) -> Result<(), lash_core::AttachmentStoreError> {
908 self.inner.delete(id).await
909 }
910
911 async fn list(
912 &self,
913 ) -> Result<Vec<lash_core::StoredBlobRef>, lash_core::AttachmentStoreError> {
914 self.inner.list().await
915 }
916 }
917
918 #[derive(Default)]
919 struct DurableMemoryProcessEnvStore {
920 inner: lash_core::InMemoryProcessExecutionEnvStore,
921 }
922
923 #[async_trait::async_trait]
924 impl lash_core::ProcessExecutionEnvStore for DurableMemoryProcessEnvStore {
925 fn durability_tier(&self) -> lash_core::DurabilityTier {
926 lash_core::DurabilityTier::Durable
927 }
928
929 async fn put_process_execution_env(
930 &self,
931 env_ref: &lash_core::ProcessExecutionEnvRef,
932 bytes: &[u8],
933 ) -> Result<(), lash_core::PluginError> {
934 self.inner.put_process_execution_env(env_ref, bytes).await
935 }
936
937 async fn get_process_execution_env(
938 &self,
939 env_ref: &lash_core::ProcessExecutionEnvRef,
940 ) -> Result<Option<Vec<u8>>, lash_core::PluginError> {
941 self.inner.get_process_execution_env(env_ref).await
942 }
943 }
944
945 impl lash_core::AwaitEventResolver for CountingEffectController {
946 fn durability_tier(&self) -> lash_core::DurabilityTier {
947 lash_core::DurabilityTier::Durable
948 }
949 }
950
951 #[async_trait::async_trait]
952 impl lash_core::RuntimeEffectController for CountingEffectController {
953 async fn execute_effect(
954 &self,
955 envelope: lash_core::RuntimeEffectEnvelope,
956 local_executor: lash_core::RuntimeEffectLocalExecutor<'_>,
957 ) -> Result<lash_core::RuntimeEffectOutcome, lash_core::RuntimeEffectControllerError>
958 {
959 self.kinds
960 .lock()
961 .expect("effect kinds")
962 .push(envelope.command.kind());
963 if matches!(
964 &envelope.command,
965 lash_core::RuntimeEffectCommand::PeekAwaitEvent { .. }
966 ) {
967 return Ok(lash_core::RuntimeEffectOutcome::PeekAwaitEvent { resolution: None });
968 }
969 local_executor.execute(envelope).await
970 }
971 }
972
973 #[tokio::test]
974 async fn standard_batch_tool_rejects_nested_batch_inside_durable_attempt() {
975 let provider_calls = Arc::new(AtomicUsize::new(0));
976 let saw_batch_result = Arc::new(AtomicBool::new(false));
977 let provider = BatchRuntimeProvider {
978 calls: Arc::clone(&provider_calls),
979 saw_batch_result: Arc::clone(&saw_batch_result),
980 };
981 let provider_handle =
982 lash_core::ProviderHandle::new(lash_core::ProviderComponents::new(Box::new(provider)));
983 let mut host = lash_core::RuntimeHostConfig::in_memory();
984 host.providers.provider_resolver =
985 Arc::new(lash_core::SingleProviderResolver::new(provider_handle));
986 host.durability.attachment_store = Arc::new(lash_core::SessionAttachmentStore::ephemeral(
987 Arc::new(DurableMemoryAttachmentStore::default()),
988 ));
989 host.durability.process_env_store = Arc::new(DurableMemoryProcessEnvStore::default());
990 let started = Arc::new(AtomicUsize::new(0));
991 let factories: Vec<Arc<dyn lash_core::PluginFactory>> = vec![
992 Arc::new(StandardProtocolPluginFactory::new()),
993 Arc::new(lash_core::plugin::StaticPluginFactory::new(
994 "standard-batch-test-tools",
995 lash_core::PluginSpec::new().with_tool_provider(Arc::new(BatchRuntimeTools {
996 barrier: Arc::new(Barrier::new(2)),
997 started: Arc::clone(&started),
998 })),
999 )),
1000 ];
1001 let policy = lash_core::SessionPolicy {
1002 provider_id: "stub".to_string(),
1003 model: lash_core::ModelSpec::from_token_limits(
1004 "mock-model",
1005 Default::default(),
1006 200_000,
1007 None,
1008 )
1009 .expect("valid model"),
1010 ..lash_core::SessionPolicy::default()
1011 };
1012 let controller = CountingEffectController::default();
1013 let scoped_controller = lash_core::ScopedEffectController::shared(
1014 Arc::new(controller.clone()),
1015 lash_core::ExecutionScope::turn("standard-batch-session", "turn-1"),
1016 )
1017 .expect("scoped controller");
1018 let mut runtime = lash_core::LashRuntime::builder()
1019 .with_session_id("standard-batch-session")
1020 .with_policy(policy)
1021 .with_runtime_host(host)
1022 .with_plugin_factories(factories)
1023 .build()
1024 .await
1025 .expect("runtime");
1026
1027 let turn = runtime
1028 .stream_turn(
1029 lash_core::TurnInput::text("run the batch"),
1030 lash_core::TurnOptions::new(
1031 tokio_util::sync::CancellationToken::new(),
1032 scoped_controller,
1033 ),
1034 )
1035 .await
1036 .expect("turn");
1037
1038 assert!(matches!(turn.outcome, lash_core::TurnOutcome::Finished(_)));
1039 assert_eq!(provider_calls.load(Ordering::SeqCst), 2);
1040 assert_eq!(started.load(Ordering::SeqCst), 0);
1041 assert!(!saw_batch_result.load(Ordering::SeqCst));
1042 assert_eq!(controller.count(lash_core::RuntimeEffectKind::ToolBatch), 1);
1043 assert_eq!(
1044 controller.count(lash_core::RuntimeEffectKind::ToolAttempt),
1045 1
1046 );
1047 }
1048
1049 #[test]
1050 fn tool_attachment_round_trips_to_generic_part() {
1051 let attachment = attachment_source("att-1");
1052 let output = ToolCallOutput::success(ToolValue::Attachment(attachment.clone()));
1053 let model_return =
1054 ModelToolReturn::from_output("call-9".to_string(), "screenshot".to_string(), &output);
1055
1056 let mut parts: Vec<Part> = Vec::new();
1057 append_model_return_parts(&mut parts, model_return);
1058
1059 assert_eq!(parts.len(), 1, "single attachment yields single part");
1060 let part = &parts[0];
1061 assert!(matches!(part.kind, PartKind::Attachment));
1062 assert_eq!(part.content, "");
1063 assert_eq!(part.tool_call_id.as_deref(), Some("call-9"));
1064 assert_eq!(part.tool_name.as_deref(), Some("screenshot"));
1065 let part_attachment = part.attachment.as_ref().expect("attachment present");
1066 assert_eq!(part_attachment.source, attachment);
1067 }
1068
1069 #[test]
1070 fn tool_text_and_attachment_round_trip_preserves_order() {
1071 let attachment = attachment_source("att-2");
1072 let output = ToolCallOutput::success(ToolValue::Array(vec![
1073 ToolValue::String("before".into()),
1074 ToolValue::Attachment(attachment.clone()),
1075 ToolValue::String("after".into()),
1076 ]));
1077 let model_return =
1078 ModelToolReturn::from_output("call-10".to_string(), "snap".to_string(), &output);
1079
1080 let mut parts: Vec<Part> = Vec::new();
1081 append_model_return_parts(&mut parts, model_return);
1082
1083 assert_eq!(
1086 parts.len(),
1087 3,
1088 "text + attachment + text yields three parts"
1089 );
1090 assert!(matches!(parts[0].kind, PartKind::ToolResult));
1091 assert!(parts[0].content.starts_with("[\"before\""));
1092 assert!(matches!(parts[1].kind, PartKind::Attachment));
1093 assert_eq!(
1094 parts[1].attachment.as_ref().expect("attachment").source,
1095 attachment
1096 );
1097 assert!(matches!(parts[2].kind, PartKind::ToolResult));
1098 assert!(parts[2].content.ends_with("\"after\"]"));
1099 }
1100}