1use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11
12use serde::Deserialize;
13use serde_json::{json, Value};
14use tokio_util::sync::CancellationToken;
15
16use crate::actor::{
17 component, ingest_with_cancel, resume_with_cancel, Actor, ErasedComponent, PreparedTransition,
18 Settlement, Transition, TransitionProgram,
19};
20use crate::effect::{Effect, Schedule};
21use crate::error::ActorError;
22use crate::exit::Exit;
23use crate::fact::{Fact, NewFact};
24
25mod tool_effects;
26pub use tool_effects::{
27 tool_args_digest, ToolAdmissionIdentityV1, ToolEffectObservationV1, ToolExecutionOutcome,
28 ToolExecutionResultV1, ToolFileIdentityV1, ToolFileKind, ToolInvocationIdentityV1,
29 ToolObservationCompleteness, ToolPathChangeV1, ToolPreparedV1, ToolRecoveryPolicy,
30 MAX_TOOL_EFFECT_CANONICAL_BYTES, MAX_TOOL_EFFECT_PATHS, TOOL_PREPARED_SCHEMA,
31 TOOL_RESULT_SCHEMA,
32};
33
34pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ToolSpec {
38 pub name: String,
39 pub description: String,
40}
41
42#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
43pub struct ToolCall {
44 pub id: String,
45 pub name: String,
46 pub args: Value,
47 pub needs_confirmation: bool,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub text: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub reasoning: Option<String>,
56}
57
58#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
59#[serde(tag = "kind", rename_all = "snake_case")]
60pub enum ModelDecision {
61 Text {
62 text: String,
63 },
64 Tool {
65 call: ToolCall,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 also: Vec<ToolCall>,
69 },
70 Question {
71 question_id: String,
72 question: String,
73 allow_free_text: bool,
74 #[serde(default)]
77 options: Vec<String>,
78 },
79}
80
81#[derive(Debug, Clone)]
82pub struct CompletionRequest {
83 pub system: Vec<String>,
84 pub tools: Vec<ToolSpec>,
85 pub summary: String,
86 pub messages: Vec<String>,
87}
88
89pub trait Completion: Send + Sync {
98 fn complete(
99 &self,
100 request: CompletionRequest,
101 cancel: CancellationToken,
102 ) -> BoxFuture<Result<ModelDecision, ActorError>>;
103
104 fn reported_prompt_tokens(&self) -> Option<u64> {
108 None
109 }
110}
111
112#[derive(Debug, Clone)]
119pub struct ToolPreparationRequest {
120 pub identity: ToolInvocationIdentityV1,
121 pub call: ToolCall,
122}
123
124#[derive(Clone)]
125pub struct PreparedToolExecution {
126 pub prepared: ToolPreparedV1,
127 pub execute: Effect<ToolExecutionResultV1, ActorError, CodingServices>,
128}
129
130pub trait ToolRunner: Send + Sync + 'static {
131 fn run(
132 &self,
133 call: ToolCall,
134 cancel: CancellationToken,
135 ) -> BoxFuture<Result<Value, ActorError>>;
136
137 fn prepare(
141 self: Arc<Self>,
142 request: ToolPreparationRequest,
143 _cancel: CancellationToken,
144 ) -> BoxFuture<Result<PreparedToolExecution, ActorError>> {
145 Box::pin(async move {
146 let digest = tool_args_digest(&request.call.args)?;
147 let prepared = ToolPreparedV1 {
148 schema: TOOL_PREPARED_SCHEMA.into(),
149 identity: request.identity,
150 tool_name: request.call.name.clone(),
151 original_args_digest: digest.clone(),
152 effective_args_digest: digest,
153 recovery: ToolRecoveryPolicy::NeverReplay,
154 baseline: None,
155 };
156 let execution_prepared = prepared.clone();
157 let call = request.call;
158 let execute = Effect::from_async_observed(move |_services, cancel| {
159 let runner = Arc::clone(&self);
160 let call = call.clone();
161 let prepared = execution_prepared.clone();
162 async move {
163 let output = runner.run(call, cancel.clone()).await.map_err(Exit::Fail)?;
164 Ok(ToolExecutionResultV1 {
165 schema: TOOL_RESULT_SCHEMA.into(),
166 identity: prepared.identity.clone(),
167 prepared_digest: prepared.digest().map_err(Exit::Fail)?,
168 outcome: if cancel.is_cancelled() {
169 ToolExecutionOutcome::Cancelled
170 } else {
171 ToolExecutionOutcome::Succeeded
172 },
173 exit_code: None,
174 output,
175 effects: ToolEffectObservationV1::incomplete(
176 "legacy runner did not observe effects",
177 ),
178 })
179 }
180 });
181 Ok(PreparedToolExecution { prepared, execute })
182 })
183 }
184
185 fn can_parallelize(&self, _calls: &[ToolCall]) -> bool {
188 false
189 }
190}
191
192pub trait Compactor: Send + Sync {
193 fn compact(&self, messages: &[String]) -> BoxFuture<Result<String, ActorError>>;
194}
195
196#[derive(Clone)]
197pub struct CodingServices {
198 pub completion: Arc<dyn Completion>,
199 pub tools: Arc<dyn ToolRunner>,
200 pub compactor: Arc<dyn Compactor>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct HarnessConfig {
207 budget: u32,
208 compact_after_chars: usize,
209 step_limit: u32,
210 model_attempts: u32,
211 system: Vec<String>,
212 tools: Vec<ToolSpec>,
213 tool_round_cap: Option<u32>,
216 compact_after_tokens: Option<u64>,
219 compact_keep_tokens: usize,
222 duplicate_threshold: Option<u32>,
226 model_retry_delay: std::time::Duration,
229}
230
231impl HarnessConfig {
232 pub fn new(
233 budget: u32,
234 compact_after_chars: usize,
235 step_limit: u32,
236 model_attempts: u32,
237 system: Vec<String>,
238 tools: Vec<ToolSpec>,
239 ) -> Result<Self, ActorError> {
240 if step_limit == 0 {
241 return Err(ActorError::Config("step_limit must be at least 1".into()));
242 }
243 if model_attempts == 0 {
244 return Err(ActorError::Config(
245 "model_attempts must be at least 1".into(),
246 ));
247 }
248 Ok(Self {
249 budget,
250 compact_after_chars,
251 step_limit,
252 model_attempts,
253 system,
254 tools,
255 tool_round_cap: None,
256 compact_after_tokens: None,
257 compact_keep_tokens: 0,
258 duplicate_threshold: None,
259 model_retry_delay: std::time::Duration::ZERO,
260 })
261 }
262
263 pub fn with_token_compact(mut self, after_tokens: u64, keep_tokens: usize) -> Self {
266 self.compact_after_tokens = Some(after_tokens.max(1));
267 self.compact_keep_tokens = keep_tokens.max(1);
268 self.compact_after_chars = usize::MAX;
269 self
270 }
271
272 pub fn with_keep_tokens(mut self, keep_tokens: usize) -> Self {
275 self.compact_keep_tokens = keep_tokens;
276 self
277 }
278
279 pub(crate) fn with_token_compact_of(mut self, base: &HarnessConfig) -> Self {
281 if let Some(after_tokens) = base.compact_after_tokens {
282 self = self.with_token_compact(after_tokens, base.compact_keep_tokens);
283 }
284 self
285 }
286
287 pub fn with_duplicate_threshold(mut self, threshold: u32) -> Self {
289 self.duplicate_threshold = Some(threshold);
290 self
291 }
292
293 pub fn with_model_retry_delay(mut self, delay: std::time::Duration) -> Self {
295 self.model_retry_delay = delay;
296 self
297 }
298
299 pub fn with_tool_round_cap(mut self, cap: u32) -> Self {
302 self.tool_round_cap = Some(cap);
303 self
304 }
305
306 pub fn step_limit(&self) -> u32 {
307 self.step_limit
308 }
309
310 pub fn budget(&self) -> u32 {
311 self.budget
312 }
313
314 pub fn compact_after_chars(&self) -> usize {
315 self.compact_after_chars
316 }
317
318 pub fn model_attempts(&self) -> u32 {
319 self.model_attempts
320 }
321
322 pub fn system(&self) -> &[String] {
323 &self.system
324 }
325
326 pub fn tools(&self) -> &[ToolSpec] {
327 &self.tools
328 }
329
330 pub fn tool_round_cap(&self) -> Option<u32> {
331 self.tool_round_cap
332 }
333
334 pub fn model_retry_delay(&self) -> std::time::Duration {
335 self.model_retry_delay
336 }
337}
338
339#[derive(Debug, Clone, PartialEq)]
340pub struct PendingConfirmation {
341 pub tool_call_id: String,
342 pub name: String,
343 pub args: Value,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct PendingQuestion {
348 pub question_id: String,
349 pub question: String,
350 pub allow_free_text: bool,
351 pub options: Vec<String>,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum CodingPhase {
356 Idle,
357 Infer,
358 Compact,
359 Confirm,
360 Question,
361 Tool,
362 Deny,
363 Done,
364}
365
366#[derive(Debug, Clone, PartialEq)]
367pub struct CodingView {
368 pub system: Vec<String>,
369 pub tools: Vec<ToolSpec>,
370 pub pending_confirmation: Option<PendingConfirmation>,
371 pub pending_question: Option<PendingQuestion>,
372 pub assistant: Option<String>,
373 pub phase: CodingPhase,
374 pub schema_error: Option<String>,
375 pub tool_budget: Option<u32>,
377 pub compact_after_chars: Option<usize>,
379}
380
381impl CodingView {
382 pub fn empty() -> Self {
383 Self {
384 system: Vec::new(),
385 tools: Vec::new(),
386 pending_confirmation: None,
387 pending_question: None,
388 assistant: None,
389 phase: CodingPhase::Idle,
390 schema_error: None,
391 tool_budget: None,
392 compact_after_chars: None,
393 }
394 }
395}
396
397pub fn merge_coding_view(views: Vec<CodingView>) -> CodingView {
398 CodingView {
399 system: views.iter().flat_map(|view| view.system.clone()).collect(),
400 tools: views.iter().flat_map(|view| view.tools.clone()).collect(),
401 pending_confirmation: views
402 .iter()
403 .find_map(|view| view.pending_confirmation.clone()),
404 pending_question: views.iter().find_map(|view| view.pending_question.clone()),
405 assistant: views.iter().rev().find_map(|view| view.assistant.clone()),
406 phase: views
407 .iter()
408 .rev()
409 .find(|view| view.phase != CodingPhase::Idle)
410 .map(|view| view.phase)
411 .unwrap_or(CodingPhase::Idle),
412 schema_error: views.iter().find_map(|view| view.schema_error.clone()),
413 tool_budget: views.iter().find_map(|view| view.tool_budget),
414 compact_after_chars: views.iter().find_map(|view| view.compact_after_chars),
415 }
416}
417
418#[derive(Debug, Clone)]
419struct SchedulerState {
420 turn: u64,
421 cycle: u64,
422 phase: CodingPhase,
423 messages: Vec<String>,
424 summary: String,
425 tool_runs: u32,
426 compacted_awaiting_model: bool,
429 assistant: Option<String>,
430 pending_call: Option<ToolCall>,
431 queued: Vec<ToolCall>,
433 pending_question: Option<PendingQuestion>,
434 schema_error: Option<String>,
435 prompt_tokens: Option<u64>,
436 background_pending: Vec<String>,
439 background_truncated: bool,
442 preparations: Vec<ToolPreparedV1>,
443 completed_invocations: Vec<ToolInvocationIdentityV1>,
444 legacy_pending: bool,
445}
446
447const BACKGROUND_TRUNCATION_LINE: &str =
451 "[background] …pending arrivals truncated (background pending cap reached)";
452
453const BACKGROUND_PENDING_CAP: usize = 64;
456
457impl SchedulerState {
458 fn new() -> Self {
459 Self {
460 turn: 0,
461 cycle: 0,
462 phase: CodingPhase::Idle,
463 messages: Vec::new(),
464 summary: String::new(),
465 tool_runs: 0,
466 compacted_awaiting_model: false,
467 assistant: None,
468 pending_call: None,
469 queued: Vec::new(),
470 pending_question: None,
471 schema_error: None,
472 prompt_tokens: None,
473 background_pending: Vec::new(),
474 background_truncated: false,
475 preparations: Vec::new(),
476 completed_invocations: Vec::new(),
477 legacy_pending: false,
478 }
479 }
480
481 fn size(&self) -> usize {
482 self.summary.len()
483 + self.messages.iter().map(String::len).sum::<usize>()
484 + self
485 .background_pending
486 .iter()
487 .map(String::len)
488 .sum::<usize>()
489 }
490}
491
492fn flush_background(state: SchedulerState) -> SchedulerState {
501 if state.background_pending.is_empty() && !state.background_truncated {
502 return state;
503 }
504 let mut messages = state.messages.clone();
505 if state.background_truncated {
506 messages.push(BACKGROUND_TRUNCATION_LINE.to_string());
507 }
508 messages.extend(state.background_pending.iter().cloned());
509 SchedulerState {
510 messages,
511 background_pending: Vec::new(),
512 background_truncated: false,
513 ..state
514 }
515}
516
517fn on_background_output(state: SchedulerState, fact: &Fact) -> SchedulerState {
522 let payload = match serde_json::from_value::<BackgroundOutputPayload>(fact.payload.clone()) {
523 Ok(payload) => payload,
524 Err(error) => {
525 return SchedulerState {
526 phase: CodingPhase::Done,
527 schema_error: Some(error.to_string()),
528 ..state
529 }
530 }
531 };
532 let line = payload.render();
533 let mid_step = state.pending_call.is_some()
534 || !state.queued.is_empty()
535 || matches!(
536 state.phase,
537 CodingPhase::Infer
538 | CodingPhase::Tool
539 | CodingPhase::Confirm
540 | CodingPhase::Deny
541 | CodingPhase::Compact
542 );
543 if !mid_step {
544 let mut messages = state.messages.clone();
545 messages.push(line);
546 return SchedulerState { messages, ..state };
547 }
548 if state.background_pending.len() >= BACKGROUND_PENDING_CAP {
549 return SchedulerState {
550 background_truncated: true,
551 ..state
552 };
553 }
554 let mut background_pending = state.background_pending.clone();
555 background_pending.push(line);
556 SchedulerState {
557 background_pending,
558 ..state
559 }
560}
561
562#[derive(Deserialize)]
566struct BackgroundOutputPayload {
567 name: String,
568 line: String,
569 #[serde(default)]
572 event: Option<String>,
573 #[serde(default)]
575 stream: Option<String>,
576}
577
578impl BackgroundOutputPayload {
579 fn render(&self) -> String {
580 match self.event.as_deref() {
581 Some("truncated") => format!(
582 "[background:{}] …output truncated (recorded-line cap reached; the task keeps running but its output is no longer recorded)",
583 self.name
584 ),
585 Some("exited") => format!("[background:{}] exited: {}", self.name, self.line),
586 _ => {
587 if self.stream.as_deref() == Some("stderr") {
588 format!("[background:{}] (stderr) {}", self.name, self.line)
589 } else {
590 format!("[background:{}] {}", self.name, self.line)
591 }
592 }
593 }
594 }
595}
596
597fn after_input(state: SchedulerState, config: &HarnessConfig) -> SchedulerState {
598 let (prefix, _) = split_compact_tail(&state.messages, config.compact_keep_tokens);
599 let token_pressure = config
600 .compact_after_tokens
601 .is_some_and(|limit| state.prompt_tokens.is_some_and(|used| used >= limit));
602 let char_pressure =
603 config.compact_after_tokens.is_none() && state.size() >= config.compact_after_chars;
604 let needs_compact =
605 !prefix.is_empty() && (token_pressure || char_pressure) && !state.compacted_awaiting_model;
606 SchedulerState {
607 phase: if needs_compact {
608 CodingPhase::Compact
609 } else {
610 CodingPhase::Infer
611 },
612 assistant: None,
613 pending_call: None,
614 pending_question: None,
615 ..state
616 }
617}
618
619fn prompt_tokens_of(payload: &Value) -> Option<u64> {
620 payload
621 .get("prompt_tokens")
622 .and_then(|value| value.as_u64())
623 .filter(|tokens| *tokens > 0)
624}
625
626fn on_model(mut state: SchedulerState, config: &HarnessConfig, payload: &Value) -> SchedulerState {
627 let prompt_tokens = prompt_tokens_of(payload).or(state.prompt_tokens);
628 state.prompt_tokens = prompt_tokens;
629 state.compacted_awaiting_model = false;
630 let decided = match serde_json::from_value::<ModelDecision>(payload.clone()) {
631 Ok(decided) => decided,
632 Err(error) => {
633 state.phase = CodingPhase::Done;
634 state.schema_error = Some(error.to_string());
635 state.pending_call = None;
636 return state;
637 }
638 };
639 match decided {
640 ModelDecision::Text { text } => {
641 state.messages.push(format!("assistant\n{text}"));
644 SchedulerState {
645 phase: CodingPhase::Done,
646 assistant: Some(text),
647 pending_call: None,
648 ..state
649 }
650 }
651 ModelDecision::Question {
652 question_id,
653 question,
654 allow_free_text,
655 options,
656 } => {
657 state.messages.push(format!("assistant\n{question}"));
658 SchedulerState {
659 phase: CodingPhase::Question,
660 pending_question: Some(PendingQuestion {
661 question_id,
662 question,
663 allow_free_text,
664 options,
665 }),
666 pending_call: None,
667 ..state
668 }
669 }
670 ModelDecision::Tool { call, also } => {
671 let legacy_pending = match payload.get("tool_effect_protocol") {
672 None => true,
673 Some(value) if value.as_u64() == Some(1) => false,
674 Some(_) => {
675 state.schema_error =
676 Some("tool_effect_schema_unsupported: model tool protocol".into());
677 state.phase = CodingPhase::Done;
678 return state;
679 }
680 };
681 let mut calls = Vec::with_capacity(1 + also.len());
682 calls.push(call);
683 calls.extend(also);
684 let mut ids = std::collections::BTreeSet::new();
685 if calls.iter().any(|call| !ids.insert(call.id.as_str())) {
686 state.schema_error = Some(
687 "tool_effect_identity_mismatch: duplicate tool call id in model batch".into(),
688 );
689 state.phase = CodingPhase::Done;
690 return state;
691 }
692 let mut messages = state.messages.clone();
693 for call in &calls {
694 messages.push(format!("assistant\ntool {}\n{}", call.name, call.args));
695 }
696 let Some(first) = calls.first().cloned() else {
697 return SchedulerState {
698 phase: CodingPhase::Done,
699 schema_error: Some("model tool decision had no call".into()),
700 ..state
701 };
702 };
703 let queued = calls.into_iter().skip(1).collect();
704 let recorded = SchedulerState {
705 messages,
706 queued,
707 legacy_pending,
708 ..state
709 };
710 if recorded.tool_runs >= config.budget {
711 SchedulerState {
712 phase: CodingPhase::Deny,
713 pending_call: Some(first),
714 ..recorded
715 }
716 } else if first.needs_confirmation {
717 SchedulerState {
718 phase: CodingPhase::Confirm,
719 pending_call: Some(first),
720 ..recorded
721 }
722 } else {
723 SchedulerState {
724 phase: CodingPhase::Tool,
725 pending_call: Some(first),
726 ..recorded
727 }
728 }
729 }
730 }
731}
732
733#[derive(Deserialize)]
734struct TextPayload {
735 text: String,
736}
737
738#[derive(Deserialize)]
739struct SummaryPayload {
740 summary: String,
741 #[serde(default)]
742 kept: Vec<String>,
743}
744
745fn estimate_tokens(text: &str) -> usize {
746 text.len().div_ceil(4).max(1)
747}
748
749fn is_tool_result_line(message: &str) -> bool {
750 message.starts_with("tool\n") || message.starts_with("tool-error\n")
751}
752
753fn tool_result_line(ok: bool, output: &str) -> String {
754 if ok {
755 format!("tool\n{output}")
756 } else {
757 format!("tool-error\n{output}")
758 }
759}
760
761fn promote_queued(mut state: SchedulerState) -> SchedulerState {
762 if state.queued.is_empty() {
763 state.pending_call = None;
764 return state;
765 }
766 let next = state.queued.remove(0);
767 state.phase = if next.needs_confirmation {
768 CodingPhase::Confirm
769 } else {
770 CodingPhase::Tool
771 };
772 state.pending_call = Some(next);
773 state.pending_question = None;
774 state
775}
776
777fn split_compact_tail(messages: &[String], keep_tokens: usize) -> (Vec<String>, Vec<String>) {
784 if messages.is_empty() {
785 return (Vec::new(), Vec::new());
786 }
787 if keep_tokens == 0 {
788 let Some(tool_at) = messages
789 .iter()
790 .rposition(|message| is_tool_result_line(message))
791 else {
792 return (messages.to_vec(), Vec::new());
793 };
794 let start = if tool_at > 0 && messages[tool_at - 1].starts_with("assistant\ntool ") {
795 tool_at - 1
796 } else {
797 tool_at
798 };
799 return (messages[..start].to_vec(), messages[start..].to_vec());
800 }
801 const RECORD_PREFIX: &str = "assistant\ntool ";
802 let mut units = Vec::new();
803 let mut index = 0;
804 while index < messages.len() {
805 if !messages[index].starts_with(RECORD_PREFIX) {
806 units.push((index, index + 1));
807 index += 1;
808 continue;
809 }
810 let start = index;
811 while index < messages.len() && messages[index].starts_with(RECORD_PREFIX) {
812 index += 1;
813 }
814 let records = index - start;
815 let mut results = 0;
816 while index + results < messages.len() && is_tool_result_line(&messages[index + results]) {
817 results += 1;
818 }
819 if results > 0 {
820 units.push((start, index + results));
821 index += results;
822 } else {
823 index = start;
824 while index < start + records {
825 units.push((index, index + 1));
826 index += 1;
827 }
828 }
829 }
830 let mut start_unit = units.len() - 1;
831 let mut used = messages[units[start_unit].0..units[start_unit].1]
832 .iter()
833 .map(|line| estimate_tokens(line))
834 .sum::<usize>();
835 while start_unit > 0 {
836 let previous = start_unit - 1;
837 let extra = messages[units[previous].0..units[previous].1]
838 .iter()
839 .map(|line| estimate_tokens(line))
840 .sum::<usize>();
841 if used.saturating_add(extra) > keep_tokens {
842 break;
843 }
844 used = used.saturating_add(extra);
845 start_unit = previous;
846 }
847 let start = units[start_unit].0;
848 (messages[..start].to_vec(), messages[start..].to_vec())
849}
850
851fn duplicate_nudge(messages: &[String], call: &ToolCall, threshold: Option<u32>) -> Option<String> {
852 let threshold = threshold.filter(|value| *value > 0)?;
853 let line = format!("assistant\ntool {}\n{}", call.name, call.args);
854 let count = messages
855 .iter()
856 .filter(|message| message.as_str() == line)
857 .count();
858 if count <= threshold as usize {
859 return None;
860 }
861 Some(format!(
862 "Tool '{}' has been called {} times with identical arguments. Change your approach instead of repeating this call.",
863 call.name, count
864 ))
865}
866
867#[derive(Deserialize)]
868struct ConfirmationPayload {
869 tool_call_id: String,
870 approved: bool,
871}
872
873#[derive(Deserialize)]
874struct ToolResultPayload {
875 #[serde(default, rename = "toolCallId")]
876 tool_call_id: Option<String>,
877 #[serde(default)]
878 receipt: Option<ToolExecutionResultV1>,
879 ok: bool,
880 output: String,
881 #[serde(default, rename = "countRun")]
884 count_run: Option<bool>,
885}
886
887fn step(mut state: SchedulerState, fact: &Fact, config: &HarnessConfig) -> SchedulerState {
888 match fact.kind.as_str() {
889 "user.message" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
890 Ok(payload) => {
891 let state = flush_background(state);
894 after_input(
895 SchedulerState {
896 turn: state.turn + 1,
897 cycle: 0,
898 messages: {
899 let mut messages = state.messages.clone();
900 messages.push(format!("user\n{}", payload.text));
901 messages
902 },
903 tool_runs: 0,
904 ..state
905 },
906 config,
907 )
908 }
909 Err(error) => {
910 state.phase = CodingPhase::Done;
911 state.schema_error = Some(error.to_string());
912 state
913 }
914 },
915 "compaction.done" => match serde_json::from_value::<SummaryPayload>(fact.payload.clone()) {
916 Ok(payload) => SchedulerState {
917 summary: payload.summary,
918 compacted_awaiting_model: true,
919 prompt_tokens: None,
921 phase: CodingPhase::Infer,
922 messages: payload.kept,
923 ..state
924 },
925 Err(error) => {
926 state.phase = CodingPhase::Done;
927 state.schema_error = Some(error.to_string());
928 state
929 }
930 },
931 "model.turn" => on_model(state, config, &fact.payload),
932 "confirmation.answered" => {
933 let Some(call) = state.pending_call.clone() else {
934 return state;
935 };
936 match serde_json::from_value::<ConfirmationPayload>(fact.payload.clone()) {
937 Ok(payload) if payload.tool_call_id == call.id && payload.approved => {
938 SchedulerState {
939 phase: CodingPhase::Tool,
940 pending_question: None,
941 ..state
942 }
943 }
944 Ok(payload) if payload.tool_call_id == call.id => {
945 if state.queued.is_empty() {
946 SchedulerState {
947 phase: CodingPhase::Done,
948 assistant: Some("denied".into()),
949 pending_call: None,
950 ..state
951 }
952 } else {
953 let mut continued = SchedulerState {
954 cycle: state.cycle + 1,
955 messages: {
956 let mut messages = state.messages.clone();
957 messages.push(tool_result_line(false, "denied"));
958 messages
959 },
960 tool_runs: state.tool_runs.saturating_add(1),
961 ..state
962 };
963 continued = promote_queued(continued);
964 continued
965 }
966 }
967 Ok(_) => state,
968 Err(error) => {
969 state.schema_error = Some(error.to_string());
970 state.phase = CodingPhase::Done;
971 state
972 }
973 }
974 }
975 "tool.prepared" => {
976 let parsed = serde_json::from_value::<ToolPreparedV1>(
977 fact.payload.get("prepared").cloned().unwrap_or(Value::Null),
978 )
979 .map_err(|error| ActorError::Schema(error.to_string()))
980 .and_then(|prepared| {
981 prepared.validate()?;
982 let Some(call) = state
983 .pending_call
984 .as_ref()
985 .filter(|call| call.id == prepared.identity.tool_call_id)
986 .or_else(|| {
987 state
988 .queued
989 .iter()
990 .find(|call| call.id == prepared.identity.tool_call_id)
991 })
992 else {
993 return Err(ActorError::Schema(
994 "tool_effect_identity_mismatch: preparation has no pending call".into(),
995 ));
996 };
997 let batch = ready_tool_batch(
998 state.pending_call.as_ref().ok_or_else(|| {
999 ActorError::Schema("tool_effect_identity_mismatch: no selected call".into())
1000 })?,
1001 &state.queued,
1002 );
1003 let index = usize::try_from(prepared.identity.batch_index).map_err(|_| {
1004 ActorError::Schema("tool_effect_identity_mismatch: batch index overflow".into())
1005 })?;
1006 if batch
1007 .get(index)
1008 .is_none_or(|expected| expected.id != call.id)
1009 {
1010 return Err(ActorError::Schema(
1011 "tool_effect_identity_mismatch: wrong batch position".into(),
1012 ));
1013 }
1014 if prepared.identity.turn != state.turn
1016 || prepared.identity.cycle != state.cycle
1017 || call.name != prepared.tool_name
1018 || tool_args_digest(&call.args)? != prepared.original_args_digest
1019 {
1020 return Err(ActorError::Schema(
1021 "tool_effect_identity_mismatch: preparation differs from selected call"
1022 .into(),
1023 ));
1024 }
1025 if state
1026 .preparations
1027 .iter()
1028 .any(|earlier| earlier.identity.matches_scheduler(&prepared.identity))
1029 || state
1030 .completed_invocations
1031 .iter()
1032 .any(|earlier| earlier.matches_scheduler(&prepared.identity))
1033 {
1034 return Err(ActorError::Schema(
1035 "tool_effect_identity_mismatch: duplicate preparation".into(),
1036 ));
1037 }
1038 Ok(prepared)
1039 });
1040 match parsed {
1041 Ok(prepared) => state.preparations.push(prepared),
1042 Err(error) => {
1043 state.schema_error = Some(error.to_string());
1044 state.phase = CodingPhase::Done;
1045 }
1046 }
1047 state
1048 }
1049 "tool.result" if fact.payload.get("receipt") == Some(&Value::Null) => {
1050 state.schema_error = Some("tool_effect_schema_unsupported: null receipt".into());
1051 state.phase = CodingPhase::Done;
1052 state
1053 }
1054 "tool.result" => match serde_json::from_value::<ToolResultPayload>(fact.payload.clone()) {
1055 Ok(payload) => {
1056 if let Err(error) = validate_folded_result(&state, &payload) {
1057 state.schema_error = Some(error.to_string());
1058 state.phase = CodingPhase::Done;
1059 return state;
1060 }
1061 if let Some(receipt) = &payload.receipt {
1062 state
1063 .preparations
1064 .retain(|prepared| prepared.identity != receipt.identity);
1065 state.completed_invocations.push(receipt.identity.clone());
1066 }
1067 let counts = payload.count_run.unwrap_or(payload.ok);
1068 let folded = SchedulerState {
1069 tool_runs: state.tool_runs + u32::from(counts),
1070 cycle: state.cycle + 1,
1071 messages: {
1072 let mut messages = state.messages.clone();
1073 messages.push(tool_result_line(payload.ok, &payload.output));
1074 messages
1075 },
1076 ..state
1077 };
1078 let folded = promote_queued(folded);
1079 if folded.pending_call.is_none() {
1080 after_input(flush_background(folded), config)
1083 } else {
1084 folded
1085 }
1086 }
1087 Err(error) => {
1088 state.schema_error = Some(error.to_string());
1089 state.phase = CodingPhase::Done;
1090 state
1091 }
1092 },
1093 "question.answered" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
1094 Ok(payload) => {
1095 let state = flush_background(state);
1098 after_input(
1099 SchedulerState {
1100 cycle: state.cycle + 1,
1101 messages: {
1102 let mut messages = state.messages.clone();
1103 messages.push(format!("user\n{}", payload.text));
1104 messages
1105 },
1106 pending_question: None,
1107 ..state
1108 },
1109 config,
1110 )
1111 }
1112 Err(error) => {
1113 state.schema_error = Some(error.to_string());
1114 state.phase = CodingPhase::Done;
1115 state
1116 }
1117 },
1118 "budget.denied" => SchedulerState {
1119 phase: CodingPhase::Done,
1120 assistant: Some("budget".into()),
1121 pending_call: None,
1122 queued: Vec::new(),
1123 ..state
1124 },
1125 "background.output" => on_background_output(state, fact),
1126 _ => state,
1127 }
1128}
1129
1130fn view_of(state: &SchedulerState) -> CodingView {
1131 CodingView {
1132 pending_confirmation: match (&state.phase, &state.pending_call) {
1133 (CodingPhase::Confirm, Some(call)) => Some(PendingConfirmation {
1134 tool_call_id: call.id.clone(),
1135 name: call.name.clone(),
1136 args: call.args.clone(),
1137 }),
1138 _ => None,
1139 },
1140 pending_question: match state.phase {
1141 CodingPhase::Question => state.pending_question.clone(),
1142 _ => None,
1143 },
1144 assistant: state.assistant.clone(),
1145 phase: state.phase,
1146 schema_error: state.schema_error.clone(),
1147 ..CodingView::empty()
1148 }
1149}
1150
1151fn transitions_of(
1152 state: &SchedulerState,
1153 config: &HarnessConfig,
1154) -> Vec<Transition<CodingServices>> {
1155 if state.schema_error.is_some() {
1156 return Vec::new();
1157 }
1158 match state.phase {
1159 CodingPhase::Compact => {
1160 let (prefix, kept) = split_compact_tail(&state.messages, config.compact_keep_tokens);
1161 let mut to_summarize = prefix;
1162 if !state.summary.is_empty() {
1163 to_summarize.insert(0, state.summary.clone());
1164 }
1165 let (turn, cycle) = (state.turn, state.cycle);
1166 vec![Transition {
1167 key: format!("compact:{turn}:{cycle}"),
1168 run: Effect::from_async_observed(move |services: Arc<CodingServices>, cancel| {
1169 let to_summarize = to_summarize.clone();
1170 let kept = kept.clone();
1171 async move {
1172 tokio::select! {
1173 biased;
1174 _ = cancel.cancelled() => Err(Exit::Interrupt),
1175 summary = services.compactor.compact(&to_summarize) => {
1176 let summary = summary?;
1177 Ok(vec![NewFact {
1178 kind: "compaction.done".into(),
1179 key: format!("compaction:{turn}:{cycle}"),
1180 payload: json!({ "summary": summary, "kept": kept }),
1181 }])
1182 }
1183 }
1184 }
1185 })
1186 .into(),
1187 }]
1188 }
1189 CodingPhase::Infer => {
1190 let tools = match config.tool_round_cap {
1191 Some(cap) if state.tool_runs >= cap => Vec::new(),
1192 _ => config.tools.clone(),
1193 };
1194 let request = CompletionRequest {
1195 system: config.system.clone(),
1196 tools,
1197 summary: state.summary.clone(),
1198 messages: state.messages.clone(),
1199 };
1200 let turn = state.turn;
1201 let cycle = state.cycle;
1202 let attempts = config.model_attempts;
1203 let retry_delay = config.model_retry_delay;
1204 vec![Transition {
1205 key: format!("infer:{turn}:{cycle}"),
1206 run: Effect::from_async_observed(move |services: Arc<CodingServices>, cancel| {
1212 let request = request.clone();
1213 async move {
1214 if cancel.is_cancelled() {
1215 return interrupted_model_turn(turn, cycle);
1216 }
1217 let decided = services.completion.complete(request, cancel.clone()).await;
1218 let decided = match decided {
1219 Ok(decided) => decided,
1220 Err(ActorError::Defect(message)) => {
1221 return Err(Exit::Die(message));
1222 }
1223 Err(error) => return Err(Exit::Fail(error)),
1224 };
1225 let mut payload = serde_json::to_value(&decided)
1226 .map_err(|error| Exit::Die(error.to_string()))?;
1227 payload["tool_effect_protocol"] = json!(1);
1230 if let Some(tokens) = services.completion.reported_prompt_tokens() {
1231 payload["prompt_tokens"] = json!(tokens);
1232 }
1233 Ok(vec![NewFact {
1234 kind: "model.turn".into(),
1235 key: format!("model:{turn}:{cycle}"),
1236 payload,
1237 }])
1238 }
1239 })
1240 .retry(Schedule {
1241 remaining: attempts.saturating_sub(1),
1242 delay: retry_delay,
1243 })
1244 .into(),
1245 }]
1246 }
1247 CodingPhase::Tool => {
1248 let Some(call) = state.pending_call.clone() else {
1249 return Vec::new();
1250 };
1251 let batch = ready_tool_batch(&call, &state.queued);
1252 let turn = state.turn;
1253 let cycle = state.cycle;
1254 let unresolved: Vec<_> = state
1258 .preparations
1259 .iter()
1260 .filter(|prepared| {
1261 prepared.identity.turn == turn
1262 && batch
1263 .iter()
1264 .any(|call| call.id == prepared.identity.tool_call_id)
1265 })
1266 .cloned()
1267 .collect();
1268 if !unresolved.is_empty() {
1269 let key = format!("tool-reconcile:{turn}:{cycle}:{}", call.id);
1270 return vec![Transition {
1271 key,
1272 run: Effect::from_async_observed(move |_services, _cancel| {
1273 let unresolved = unresolved.clone();
1274 async move {
1275 unresolved
1276 .iter()
1277 .map(|prepared| {
1278 let result = ToolExecutionResultV1::unknown(
1279 prepared,
1280 "prepared invocation has no committed terminal result",
1281 )
1282 .map_err(Exit::Fail)?;
1283 receipt_fact(prepared, result).map_err(Exit::Fail)
1284 })
1285 .collect()
1286 }
1287 })
1288 .into(),
1289 }];
1290 }
1291 if state.legacy_pending {
1292 return vec![Transition {
1293 key: format!("tool-legacy-unresolved:{turn}:{cycle}:{}", call.id),
1294 run: Effect::succeed(batch.iter().map(|call| {
1295 let mut fact = tool_result_fact(turn, cycle, &call.id, false,
1296 "tool_effect_unresolved: legacy invocation has no authoritative execution evidence".into(), true);
1297 fact.payload["unresolvedLegacy"] = json!(true);
1298 fact
1299 }).collect()).into(),
1300 }];
1301 }
1302 let messages = state.messages.clone();
1303 let threshold = config.duplicate_threshold;
1304 let key = batch
1305 .iter()
1306 .map(|call| call.id.as_str())
1307 .collect::<Vec<_>>()
1308 .join("+");
1309 vec![Transition {
1310 key: format!("tool:{turn}:{cycle}:{key}"),
1311 run: TransitionProgram::Prepared(Effect::from_async_observed(
1312 move |services: Arc<CodingServices>, cancel| {
1313 let batch = batch.clone();
1314 let messages = messages.clone();
1315 async move {
1316 prepare_tool_batch(
1317 services, batch, &messages, threshold, turn, cycle, cancel,
1318 )
1319 .await
1320 }
1321 },
1322 )),
1323 }]
1324 }
1325 CodingPhase::Deny => {
1326 let Some(call) = state.pending_call.clone() else {
1327 return Vec::new();
1328 };
1329 vec![Transition {
1330 key: format!("budget:{}:{}:{}", state.turn, state.cycle, call.id),
1331 run: Effect::succeed(vec![NewFact {
1332 kind: "budget.denied".into(),
1333 key: format!("budget-denied:{}:{}:{}", state.turn, state.cycle, call.id),
1334 payload: json!({ "toolCallId": call.id }),
1335 }])
1336 .into(),
1337 }]
1338 }
1339 CodingPhase::Idle | CodingPhase::Confirm | CodingPhase::Question | CodingPhase::Done => {
1340 Vec::new()
1341 }
1342 }
1343}
1344
1345fn ready_tool_batch(pending: &ToolCall, queued: &[ToolCall]) -> Vec<ToolCall> {
1346 let mut batch = vec![pending.clone()];
1347 for call in queued {
1348 if call.needs_confirmation {
1349 break;
1350 }
1351 batch.push(call.clone());
1352 }
1353 batch
1354}
1355
1356fn tool_output_text(value: &Value) -> String {
1357 match value {
1358 Value::String(text) => text.clone(),
1359 other => other.to_string(),
1360 }
1361}
1362
1363fn interrupted_model_turn(turn: u64, cycle: u64) -> Result<Vec<NewFact>, Exit<ActorError>> {
1364 let decided = ModelDecision::Text {
1365 text: "(Response interrupted by the user.)".into(),
1366 };
1367 let payload = serde_json::to_value(&decided).map_err(|error| Exit::Die(error.to_string()))?;
1368 Ok(vec![NewFact {
1369 kind: "model.turn".into(),
1370 key: format!("model:{turn}:{cycle}"),
1371 payload,
1372 }])
1373}
1374
1375fn tool_result_fact(
1376 turn: u64,
1377 cycle: u64,
1378 id: &str,
1379 ok: bool,
1380 output: String,
1381 count_run: bool,
1382) -> NewFact {
1383 let mut payload = json!({ "toolCallId": id, "ok": ok, "output": output });
1384 if count_run {
1385 payload["countRun"] = json!(true);
1386 }
1387 NewFact {
1388 kind: "tool.result".into(),
1389 key: format!("tool-result:{turn}:{cycle}:{id}"),
1390 payload,
1391 }
1392}
1393
1394fn receipt_fact(
1395 prepared: &ToolPreparedV1,
1396 result: ToolExecutionResultV1,
1397) -> Result<NewFact, ActorError> {
1398 result.ensure_prepared(prepared)?;
1399 let ok = result.outcome == ToolExecutionOutcome::Succeeded;
1400 let mut fact = tool_result_fact(
1401 prepared.identity.turn,
1402 prepared.identity.cycle,
1403 &prepared.identity.tool_call_id,
1404 ok,
1405 tool_output_text(&result.output),
1406 !ok,
1407 );
1408 fact.payload["receipt"] =
1409 serde_json::to_value(result).map_err(|error| ActorError::Schema(error.to_string()))?;
1410 Ok(fact)
1411}
1412
1413fn validate_folded_result(
1414 state: &SchedulerState,
1415 payload: &ToolResultPayload,
1416) -> Result<(), ActorError> {
1417 if let Some(receipt) = &payload.receipt {
1418 receipt.validate()?;
1419 }
1420 let Some(call) = &state.pending_call else {
1421 return Err(ActorError::Schema(
1422 "tool_effect_identity_mismatch: result has no pending call".into(),
1423 ));
1424 };
1425 if payload
1426 .tool_call_id
1427 .as_ref()
1428 .is_some_and(|id| id != &call.id)
1429 {
1430 return Err(ActorError::Schema(
1431 "tool_effect_identity_mismatch: wrong pending call".into(),
1432 ));
1433 }
1434 if let Some(receipt) = &payload.receipt {
1435 let prepared = state
1436 .preparations
1437 .iter()
1438 .find(|prepared| prepared.identity == receipt.identity)
1439 .ok_or_else(|| {
1440 ActorError::Schema(
1441 "tool_effect_identity_mismatch: result has no preparation".into(),
1442 )
1443 })?;
1444 receipt.ensure_prepared(prepared)?;
1445 if receipt.identity.turn != state.turn
1446 || receipt.identity.tool_call_id != call.id
1447 || payload.ok != (receipt.outcome == ToolExecutionOutcome::Succeeded)
1448 || payload.output != tool_output_text(&receipt.output)
1449 {
1450 return Err(ActorError::Schema(
1451 "tool_effect_identity_mismatch: terminal projection differs".into(),
1452 ));
1453 }
1454 } else if state.preparations.iter().any(|prepared| {
1455 prepared.identity.turn == state.turn && prepared.identity.tool_call_id == call.id
1456 }) {
1457 return Err(ActorError::Schema(
1458 "tool_effect_identity_mismatch: prepared call needs a typed result".into(),
1459 ));
1460 }
1461 Ok(())
1462}
1463
1464#[allow(clippy::too_many_arguments)]
1465async fn prepare_tool_batch(
1466 services: Arc<CodingServices>,
1467 batch: Vec<ToolCall>,
1468 messages: &[String],
1469 threshold: Option<u32>,
1470 turn: u64,
1471 cycle: u64,
1472 cancel: CancellationToken,
1473) -> Result<PreparedTransition<CodingServices>, Exit<ActorError>> {
1474 tokio::task::yield_now().await;
1475 let mut prepared_batch = Vec::with_capacity(batch.len());
1476 let mut accepted: Vec<&ToolCall> = Vec::new();
1477 for (index, call) in batch.iter().enumerate() {
1478 let identity = ToolInvocationIdentityV1 {
1479 turn,
1480 cycle,
1481 tool_call_id: call.id.clone(),
1482 batch_index: u32::try_from(index)
1483 .map_err(|_| Exit::Fail(ActorError::Schema("tool batch index overflow".into())))?,
1484 admission: None,
1485 };
1486 let nudge = if threshold.is_some()
1487 && accepted
1488 .iter()
1489 .any(|earlier| earlier.name == call.name && earlier.args == call.args)
1490 {
1491 Some(format!("Tool '{}' has been called 2 times with identical arguments. Change your approach instead of repeating this call.", call.name))
1492 } else {
1493 duplicate_nudge(messages, call, threshold)
1494 };
1495 accepted.push(call);
1496 let mut execution = Arc::clone(&services.tools)
1497 .prepare(
1498 ToolPreparationRequest {
1499 identity: identity.clone(),
1500 call: call.clone(),
1501 },
1502 cancel.clone(),
1503 )
1504 .await
1505 .map_err(Exit::Fail)?;
1506 execution.prepared.validate().map_err(Exit::Fail)?;
1507 if !execution.prepared.identity.matches_scheduler(&identity)
1508 || execution.prepared.tool_name != call.name
1509 || execution.prepared.original_args_digest
1510 != tool_args_digest(&call.args).map_err(Exit::Fail)?
1511 {
1512 return Err(Exit::Fail(ActorError::Schema(
1513 "tool_effect_identity_mismatch: runner changed selected intent".into(),
1514 )));
1515 }
1516 if let Some(nudge) = nudge {
1517 let result = ToolExecutionResultV1 {
1520 schema: TOOL_RESULT_SCHEMA.into(),
1521 identity: execution.prepared.identity.clone(),
1522 prepared_digest: execution.prepared.digest().map_err(Exit::Fail)?,
1523 outcome: ToolExecutionOutcome::Denied,
1524 exit_code: None,
1525 output: Value::String(nudge),
1526 effects: ToolEffectObservationV1::complete(Vec::new()),
1527 };
1528 execution.execute = Effect::succeed(result);
1529 }
1530 prepared_batch.push(execution);
1531 }
1532 let facts = prepared_batch
1533 .iter()
1534 .map(|execution| {
1535 Ok(NewFact {
1536 kind: "tool.prepared".into(),
1537 key: format!("tool-prepared:{}", execution.prepared.identity.key()?),
1538 payload: json!({ "prepared": execution.prepared }),
1539 })
1540 })
1541 .collect::<Result<Vec<_>, ActorError>>()
1542 .map_err(Exit::Fail)?;
1543 let parallel = batch.len() > 1 && services.tools.can_parallelize(&batch);
1544 let prepared_batch = Arc::new(prepared_batch);
1545 let run = Effect::from_async_observed(move |services: Arc<CodingServices>, cancel| {
1546 let prepared_batch = Arc::clone(&prepared_batch);
1547 async move {
1548 let results = if parallel {
1549 run_prepared_parallel(&prepared_batch, services, cancel).await
1550 } else {
1551 let mut results = Vec::with_capacity(prepared_batch.len());
1552 for execution in prepared_batch.iter() {
1553 results.push(
1554 run_prepared_one(execution, Arc::clone(&services), cancel.clone()).await,
1555 );
1556 }
1557 results
1558 };
1559 prepared_batch
1560 .iter()
1561 .zip(results)
1562 .map(|(execution, result)| {
1563 let result = match result {
1564 Ok(result) => match result.ensure_prepared(&execution.prepared) {
1565 Ok(()) => result,
1566 Err(error) => ToolExecutionResultV1::unknown(
1567 &execution.prepared,
1568 format!("runner receipt was refused: {error}"),
1569 )
1570 .map_err(Exit::Fail)?,
1571 },
1572 Err(error) => ToolExecutionResultV1::unknown(
1573 &execution.prepared,
1574 format!("execution did not return an authoritative result: {error:?}"),
1575 )
1576 .map_err(Exit::Fail)?,
1577 };
1578 receipt_fact(&execution.prepared, result).map_err(Exit::Fail)
1579 })
1580 .collect()
1581 }
1582 });
1583 Ok(PreparedTransition { facts, run })
1584}
1585
1586async fn run_prepared_one(
1587 execution: &PreparedToolExecution,
1588 services: Arc<CodingServices>,
1589 cancel: CancellationToken,
1590) -> Result<ToolExecutionResultV1, Exit<ActorError>> {
1591 if cancel.is_cancelled() {
1592 return Ok(ToolExecutionResultV1 {
1593 schema: TOOL_RESULT_SCHEMA.into(),
1594 identity: execution.prepared.identity.clone(),
1595 prepared_digest: execution.prepared.digest().map_err(Exit::Fail)?,
1596 outcome: ToolExecutionOutcome::Cancelled,
1597 exit_code: None,
1598 output: Value::String("cancelled".into()),
1599 effects: ToolEffectObservationV1::complete(Vec::new()),
1600 });
1601 }
1602 execution
1604 .execute
1605 .clone()
1606 .run_with_cancel(services, cancel)
1607 .await
1608 .0
1609}
1610
1611async fn run_prepared_parallel(
1612 executions: &[PreparedToolExecution],
1613 services: Arc<CodingServices>,
1614 cancel: CancellationToken,
1615) -> Vec<Result<ToolExecutionResultV1, Exit<ActorError>>> {
1616 let mut set = tokio::task::JoinSet::new();
1617 let mut ids = std::collections::HashMap::new();
1618 for (index, execution) in executions.iter().cloned().enumerate() {
1619 let services = Arc::clone(&services);
1620 let cancel = cancel.clone();
1621 let task = set.spawn(async move { run_prepared_one(&execution, services, cancel).await });
1622 ids.insert(task.id(), index);
1623 }
1624 let mut results: Vec<_> = executions.iter().map(|_| None).collect();
1625 while let Some(joined) = set.join_next_with_id().await {
1626 match joined {
1627 Ok((id, result)) => {
1628 if let Some(index) = ids.remove(&id) {
1629 results[index] = Some(result);
1630 }
1631 }
1632 Err(error) => {
1633 if let Some(index) = ids.remove(&error.id()) {
1634 results[index] = Some(Err(Exit::Die(error.to_string())));
1635 }
1636 }
1637 }
1638 }
1639 results
1640 .into_iter()
1641 .map(|result| result.unwrap_or_else(|| Err(Exit::Die("tool batch lost a result".into()))))
1642 .collect()
1643}
1644
1645#[cfg(test)]
1647#[allow(clippy::too_many_arguments)]
1648async fn run_tool_batch(
1649 services: Arc<CodingServices>,
1650 batch: Vec<ToolCall>,
1651 messages: &[String],
1652 threshold: Option<u32>,
1653 turn: u64,
1654 cycle: u64,
1655 cancel: CancellationToken,
1656) -> Result<Vec<NewFact>, Exit<ActorError>> {
1657 let prepared = prepare_tool_batch(
1658 Arc::clone(&services),
1659 batch,
1660 messages,
1661 threshold,
1662 turn,
1663 cycle,
1664 cancel.clone(),
1665 )
1666 .await?;
1667 prepared.run.run_with_cancel(services, cancel).await.0
1668}
1669
1670pub fn coding_scheduler(config: HarnessConfig) -> ErasedComponent<CodingServices, CodingView> {
1672 let step_config = config.clone();
1673 let output_config = config;
1674 component(
1675 SchedulerState::new,
1676 move |state, fact| step(state, fact, &step_config),
1677 move |state| (view_of(state), transitions_of(state, &output_config)),
1678 )
1679}
1680
1681pub fn coding_actor(config: HarnessConfig) -> Actor<CodingServices, CodingView> {
1686 let spec = crate::compose::MetaHarnessSpec {
1687 name: "a3s-code",
1688 budget: config.budget(),
1689 compact_after_chars: config.compact_after_chars(),
1690 step_limit: config.step_limit(),
1691 model_attempts: config.model_attempts(),
1692 system: config.system().to_vec(),
1693 tools: config.tools().to_vec(),
1694 tool_round_cap: config.tool_round_cap(),
1695 parts: Vec::new(),
1696 };
1697 crate::compose::HarnessGraph::from_spec(spec, || coding_scheduler(config.clone())).into_actor()
1698}
1699
1700pub fn message_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
1701 NewFact {
1702 kind: "user.message".into(),
1703 key: key.into(),
1704 payload: json!({ "text": text.into() }),
1705 }
1706}
1707
1708pub fn confirm_fact(
1709 key: impl Into<String>,
1710 tool_call_id: impl Into<String>,
1711 approved: bool,
1712) -> NewFact {
1713 NewFact {
1714 kind: "confirmation.answered".into(),
1715 key: key.into(),
1716 payload: json!({ "tool_call_id": tool_call_id.into(), "approved": approved }),
1717 }
1718}
1719
1720pub fn answer_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
1721 NewFact {
1722 kind: "question.answered".into(),
1723 key: key.into(),
1724 payload: json!({ "text": text.into() }),
1725 }
1726}
1727
1728pub fn background_output_fact(
1736 name: &str,
1737 line: &str,
1738 task_seq: u64,
1739 event: Option<&str>,
1740 stream: Option<&str>,
1741) -> NewFact {
1742 let mut payload = json!({ "name": name, "line": line });
1743 if let Some(event) = event {
1744 payload["event"] = json!(event);
1745 }
1746 if let Some(stream) = stream {
1747 payload["stream"] = json!(stream);
1748 }
1749 NewFact {
1750 kind: "background.output".into(),
1751 key: format!("bg:{name}:{task_seq}"),
1752 payload,
1753 }
1754}
1755
1756pub async fn ingest_coding(
1757 actor: &Actor<CodingServices, CodingView>,
1758 log: &dyn crate::log::LogStore,
1759 services: Arc<CodingServices>,
1760 thread_id: &str,
1761 fact: NewFact,
1762 limit: u32,
1763) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1764 ingest_coding_with_cancel(
1765 actor,
1766 log,
1767 services,
1768 thread_id,
1769 fact,
1770 limit,
1771 &CancellationToken::new(),
1772 )
1773 .await
1774}
1775
1776pub async fn ingest_coding_with_cancel(
1777 actor: &Actor<CodingServices, CodingView>,
1778 log: &dyn crate::log::LogStore,
1779 services: Arc<CodingServices>,
1780 thread_id: &str,
1781 fact: NewFact,
1782 limit: u32,
1783 cancel: &CancellationToken,
1784) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1785 ingest_with_cancel(actor, log, services, thread_id, fact, limit, cancel).await
1786}
1787
1788pub async fn resume_coding(
1789 actor: &Actor<CodingServices, CodingView>,
1790 log: &dyn crate::log::LogStore,
1791 services: Arc<CodingServices>,
1792 thread_id: &str,
1793 limit: u32,
1794) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1795 resume_coding_with_cancel(
1796 actor,
1797 log,
1798 services,
1799 thread_id,
1800 limit,
1801 &CancellationToken::new(),
1802 )
1803 .await
1804}
1805
1806pub async fn resume_coding_with_cancel(
1807 actor: &Actor<CodingServices, CodingView>,
1808 log: &dyn crate::log::LogStore,
1809 services: Arc<CodingServices>,
1810 thread_id: &str,
1811 limit: u32,
1812 cancel: &CancellationToken,
1813) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1814 resume_with_cancel(actor, log, services, thread_id, limit, cancel).await
1815}
1816
1817#[cfg(test)]
1818mod batch_overlap_tests {
1819 use super::*;
1826 use serde_json::json;
1827 use std::sync::atomic::{AtomicUsize, Ordering};
1828 use std::sync::Arc;
1829 use std::time::{Duration, Instant};
1830 use tokio::sync::Barrier;
1831
1832 struct NeverModel;
1833
1834 impl Completion for NeverModel {
1835 fn complete(
1836 &self,
1837 _request: CompletionRequest,
1838 _cancel: CancellationToken,
1839 ) -> BoxFuture<Result<ModelDecision, ActorError>> {
1840 Box::pin(async {
1841 Ok(ModelDecision::Text {
1842 text: "unused".into(),
1843 })
1844 })
1845 }
1846 }
1847
1848 struct NeverCompact;
1849
1850 impl Compactor for NeverCompact {
1851 fn compact(&self, _messages: &[String]) -> BoxFuture<Result<String, ActorError>> {
1852 Box::pin(async { Ok(String::new()) })
1853 }
1854 }
1855
1856 struct BarrierInner {
1857 barrier: Barrier,
1858 entered: AtomicUsize,
1859 }
1860
1861 struct BarrierTools {
1862 inner: Arc<BarrierInner>,
1863 allow_parallel: bool,
1864 }
1865
1866 impl ToolRunner for BarrierTools {
1867 fn run(
1868 &self,
1869 call: ToolCall,
1870 _cancel: CancellationToken,
1871 ) -> BoxFuture<Result<Value, ActorError>> {
1872 let inner = Arc::clone(&self.inner);
1873 Box::pin(async move {
1874 inner.entered.fetch_add(1, Ordering::SeqCst);
1875 let _ = inner.barrier.wait().await;
1877 Ok(json!(format!("passed-{}", call.id)))
1878 })
1879 }
1880
1881 fn can_parallelize(&self, _calls: &[ToolCall]) -> bool {
1882 self.allow_parallel
1883 }
1884 }
1885
1886 struct SleepTools {
1887 per_call: Duration,
1888 allow_parallel: bool,
1889 }
1890
1891 impl ToolRunner for SleepTools {
1892 fn run(
1893 &self,
1894 call: ToolCall,
1895 _cancel: CancellationToken,
1896 ) -> BoxFuture<Result<Value, ActorError>> {
1897 let per_call = self.per_call;
1898 Box::pin(async move {
1899 tokio::time::sleep(per_call).await;
1900 Ok(json!(format!("slept-{}", call.id)))
1901 })
1902 }
1903
1904 fn can_parallelize(&self, _calls: &[ToolCall]) -> bool {
1905 self.allow_parallel
1906 }
1907 }
1908
1909 fn services(tools: Arc<dyn ToolRunner>) -> Arc<CodingServices> {
1910 Arc::new(CodingServices {
1911 completion: Arc::new(NeverModel),
1912 tools,
1913 compactor: Arc::new(NeverCompact),
1914 })
1915 }
1916
1917 fn read_batch(count: usize) -> Vec<ToolCall> {
1918 (0..count)
1919 .map(|index| ToolCall {
1920 id: format!("r{index}"),
1921 name: "read".into(),
1922 args: json!({ "file_path": format!("f{index}.rs") }),
1923 needs_confirmation: false,
1924 text: None,
1925 reasoning: None,
1926 })
1927 .collect()
1928 }
1929
1930 #[tokio::test]
1931 async fn parallel_batch_overlaps_or_fails_closed() {
1932 let parties = 4;
1933 let tools = Arc::new(BarrierTools {
1934 inner: Arc::new(BarrierInner {
1935 barrier: Barrier::new(parties),
1936 entered: AtomicUsize::new(0),
1937 }),
1938 allow_parallel: true,
1939 });
1940 let entered = Arc::clone(&tools.inner);
1941 let facts = tokio::time::timeout(
1942 Duration::from_secs(10),
1943 run_tool_batch(
1944 services(tools),
1945 read_batch(parties),
1946 &[],
1947 None,
1948 1,
1949 1,
1950 CancellationToken::new(),
1951 ),
1952 )
1953 .await
1954 .expect("SERIALIZED: the batch never overlapped — the executor ran calls one after another while the adapter marked them parallel-safe")
1955 .expect("batch executor failed");
1956 assert_eq!(facts.len(), parties);
1957 for (fact, index) in facts.iter().zip(0..) {
1958 assert_eq!(fact.kind, "tool.result");
1959 assert_eq!(fact.key, format!("tool-result:1:1:r{index}"));
1960 assert_eq!(fact.payload["output"], json!(format!("passed-r{index}")));
1961 assert_eq!(fact.payload["ok"], json!(true));
1962 }
1963 assert_eq!(
1964 entered.entered.load(Ordering::SeqCst),
1965 parties,
1966 "every party must have entered the barrier"
1967 );
1968 }
1969
1970 #[tokio::test]
1971 async fn duplicate_call_twins_stay_parked_even_in_parallel_batches() {
1972 let parties = 2;
1976 let tools = Arc::new(BarrierTools {
1977 inner: Arc::new(BarrierInner {
1978 barrier: Barrier::new(parties),
1979 entered: AtomicUsize::new(0),
1980 }),
1981 allow_parallel: true,
1982 });
1983 let mut batch = read_batch(parties);
1984 batch.push(batch[0].clone());
1985 let facts = tokio::time::timeout(
1986 Duration::from_secs(10),
1987 run_tool_batch(
1988 services(tools),
1989 batch,
1990 &[],
1991 Some(1),
1992 1,
1993 1,
1994 CancellationToken::new(),
1995 ),
1996 )
1997 .await
1998 .expect("SERIALIZED: distinct calls must overlap")
1999 .expect("batch executor failed");
2000 assert_eq!(facts.len(), 3);
2001 assert_eq!(facts[0].payload["ok"], json!(true));
2002 assert_eq!(facts[1].payload["ok"], json!(true));
2003 assert_eq!(facts[2].payload["ok"], json!(false));
2004 assert_eq!(
2005 facts[2].payload["countRun"],
2006 json!(true),
2007 "the parked twin still counts toward the budget"
2008 );
2009 }
2010
2011 #[tokio::test]
2015 async fn parallel_batch_fanout_wall_clock_is_recorded() {
2016 let per_call = Duration::from_millis(60);
2017 let parallel_tools = Arc::new(SleepTools {
2018 per_call,
2019 allow_parallel: true,
2020 });
2021 let serial_tools = Arc::new(SleepTools {
2022 per_call,
2023 allow_parallel: false,
2024 });
2025 let batch = read_batch(8);
2026
2027 let started = Instant::now();
2028 run_tool_batch(
2029 services(parallel_tools),
2030 batch.clone(),
2031 &[],
2032 None,
2033 1,
2034 1,
2035 CancellationToken::new(),
2036 )
2037 .await
2038 .expect("parallel arm failed");
2039 let parallel_ms = started.elapsed().as_millis();
2040
2041 let started = Instant::now();
2042 run_tool_batch(
2043 services(serial_tools),
2044 batch,
2045 &[],
2046 None,
2047 1,
2048 1,
2049 CancellationToken::new(),
2050 )
2051 .await
2052 .expect("serial arm failed");
2053 let serial_ms = started.elapsed().as_millis();
2054
2055 println!(
2056 "WCMEAS2 fan-out: batch=8 per_call=60ms parallel={parallel_ms}ms serial={serial_ms}ms"
2057 );
2058 }
2059}
2060
2061#[cfg(test)]
2062mod background_fold_tests {
2063 use super::*;
2069
2070 fn config() -> HarnessConfig {
2071 HarnessConfig::new(16, 1_000_000, 8, 1, Vec::new(), Vec::new()).unwrap()
2072 }
2073
2074 fn fact(seq: u64, new_fact: NewFact) -> Fact {
2075 Fact {
2076 seq,
2077 kind: new_fact.kind,
2078 key: new_fact.key,
2079 cause: None,
2080 payload: new_fact.payload,
2081 }
2082 }
2083
2084 fn bg(seq: u64, name: &str, line: &str, task_seq: u64) -> Fact {
2085 fact(
2086 seq,
2087 background_output_fact(name, line, task_seq, None, None),
2088 )
2089 }
2090
2091 fn tool_call_fact(seq: u64, name: &str, id: &str) -> Fact {
2092 fact(
2093 seq,
2094 NewFact {
2095 kind: "model.turn".into(),
2096 key: "model:1:0".into(),
2097 payload: serde_json::to_value(ModelDecision::Tool {
2098 call: ToolCall {
2099 id: id.into(),
2100 needs_confirmation: false,
2101 name: name.into(),
2102 args: serde_json::json!({}),
2103 text: None,
2104 reasoning: None,
2105 },
2106 also: Vec::new(),
2107 })
2108 .unwrap(),
2109 },
2110 )
2111 }
2112
2113 fn tool_result_fact(seq: u64) -> Fact {
2114 fact(
2115 seq,
2116 NewFact {
2117 kind: "tool.result".into(),
2118 key: "tr".into(),
2119 payload: serde_json::json!({ "ok": true, "output": "ok" }),
2120 },
2121 )
2122 }
2123
2124 fn fold(facts: Vec<Fact>) -> SchedulerState {
2125 let mut state = SchedulerState::new();
2126 for item in facts {
2127 state = step(state, &item, &config());
2128 }
2129 state
2130 }
2131
2132 #[test]
2133 fn arrival_during_tool_batch_lands_after_its_result() {
2134 let state = fold(vec![
2135 fact(1, message_fact("m-1", "start the watch")),
2136 tool_call_fact(2, "background", "call-1"),
2137 bg(3, "watch", "BUILD DONE", 1),
2138 tool_result_fact(4),
2139 ]);
2140 let tool_at = state
2141 .messages
2142 .iter()
2143 .position(|line| line.starts_with("assistant\ntool background"))
2144 .expect("assistant tool line present");
2145 let result_at = state
2146 .messages
2147 .iter()
2148 .position(|line| line.starts_with("tool\n"))
2149 .expect("tool result present");
2150 let bg_at = state
2151 .messages
2152 .iter()
2153 .position(|line| line == "[background:watch] BUILD DONE")
2154 .expect("arrival folded");
2155 assert_eq!(result_at, tool_at + 1, "tool result must be adjacent");
2158 assert!(bg_at > result_at, "arrival must follow the completed pair");
2159 assert!(state.background_pending.is_empty());
2160 }
2161
2162 #[test]
2163 fn arrival_during_model_call_waits_for_the_next_completed_step() {
2164 let state = fold(vec![
2167 fact(1, message_fact("m-1", "watch")),
2168 bg(2, "watch", "early", 1),
2169 fact(
2170 3,
2171 NewFact {
2172 kind: "model.turn".into(),
2173 key: "model:1:0".into(),
2174 payload: serde_json::to_value(ModelDecision::Text {
2175 text: "answer without seeing it".into(),
2176 })
2177 .unwrap(),
2178 },
2179 ),
2180 ]);
2181 assert!(
2182 !state
2183 .messages
2184 .iter()
2185 .any(|line| line == "[background:watch] early"),
2186 "the in-flight call did not see the arrival; the fold must not claim it did"
2187 );
2188 assert_eq!(state.background_pending, vec!["[background:watch] early"]);
2189 let state = step(state, &fact(4, message_fact("m-2", "next turn")), &config());
2191 let flushed_at = state
2192 .messages
2193 .iter()
2194 .position(|line| line == "[background:watch] early")
2195 .expect("flushed");
2196 let user_at = state
2197 .messages
2198 .iter()
2199 .position(|line| line == "user\nnext turn")
2200 .expect("new user line");
2201 assert!(flushed_at < user_at);
2202 assert!(state.background_pending.is_empty());
2203 }
2204
2205 #[test]
2206 fn arrival_while_parked_lands_directly() {
2207 let state = fold(vec![
2208 fact(1, message_fact("m-1", "watch")),
2209 fact(
2210 2,
2211 NewFact {
2212 kind: "model.turn".into(),
2213 key: "model:1:0".into(),
2214 payload: serde_json::to_value(ModelDecision::Text {
2215 text: "done asking".into(),
2216 })
2217 .unwrap(),
2218 },
2219 ),
2220 bg(3, "watch", "late", 1),
2221 ]);
2222 assert_eq!(state.messages.last().unwrap(), "[background:watch] late");
2223 assert!(state.background_pending.is_empty());
2224 }
2225
2226 #[test]
2227 fn pending_cap_records_one_truncation_marker() {
2228 let mut facts = vec![
2229 fact(1, message_fact("m-1", "watch")),
2230 tool_call_fact(2, "background", "call-1"),
2231 ];
2232 for index in 0..(BACKGROUND_PENDING_CAP + 10) {
2233 facts.push(bg(
2234 3 + index as u64,
2235 "watch",
2236 &format!("line-{index}"),
2237 index as u64 + 1,
2238 ));
2239 }
2240 facts.push(tool_result_fact(3 + (BACKGROUND_PENDING_CAP + 10) as u64));
2241 let state = fold(facts);
2242 let markers = state
2243 .messages
2244 .iter()
2245 .filter(|line| line.as_str() == BACKGROUND_TRUNCATION_LINE)
2246 .count();
2247 assert_eq!(markers, 1, "exactly one truncation marker");
2248 assert!(
2249 !state.messages.iter().any(|line| line.contains("line-70")),
2250 "overflow arrivals are dropped, not buffered unbounded"
2251 );
2252 assert!(state.messages.iter().any(|line| line.contains("line-0")));
2253 assert!(state.background_pending.is_empty());
2254 }
2255
2256 #[test]
2257 fn exited_and_stderr_events_render_dispositions() {
2258 let state = fold(vec![
2259 fact(
2260 1,
2261 background_output_fact("watch", "exit status: 0", 9, Some("exited"), None),
2262 ),
2263 fact(
2264 2,
2265 background_output_fact("watch", "boom", 10, None, Some("stderr")),
2266 ),
2267 ]);
2268 assert!(state
2269 .messages
2270 .iter()
2271 .any(|line| line == "[background:watch] exited: exit status: 0"));
2272 assert!(state
2273 .messages
2274 .iter()
2275 .any(|line| line == "[background:watch] (stderr) boom"));
2276 }
2277
2278 #[test]
2279 fn malformed_background_fact_fails_the_fold() {
2280 let state = fold(vec![Fact {
2281 seq: 1,
2282 kind: "background.output".into(),
2283 key: "bg:watch:1".into(),
2284 cause: None,
2285 payload: serde_json::json!({ "unexpected": true }),
2286 }]);
2287 assert!(state.schema_error.is_some());
2288 assert_eq!(state.phase, CodingPhase::Done);
2289 }
2290}
2291
2292#[cfg(test)]
2293mod conversation_history_tests;