1use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11
12use serde::Deserialize;
13use serde_json::{json, Value};
14
15use crate::actor::{component, ingest, resume, Actor, ErasedComponent, Settlement, Transition};
16use crate::effect::{Effect, Schedule};
17use crate::error::ActorError;
18use crate::exit::Exit;
19use crate::fact::{Fact, NewFact};
20
21pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ToolSpec {
25 pub name: String,
26 pub description: String,
27}
28
29#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
30pub struct ToolCall {
31 pub id: String,
32 pub name: String,
33 pub args: Value,
34 pub needs_confirmation: bool,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub text: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub reasoning: Option<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
46#[serde(tag = "kind", rename_all = "snake_case")]
47pub enum ModelDecision {
48 Text {
49 text: String,
50 },
51 Tool {
52 call: ToolCall,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 also: Vec<ToolCall>,
56 },
57 Question {
58 question_id: String,
59 question: String,
60 allow_free_text: bool,
61 #[serde(default)]
64 options: Vec<String>,
65 },
66}
67
68#[derive(Debug, Clone)]
69pub struct CompletionRequest {
70 pub system: Vec<String>,
71 pub tools: Vec<ToolSpec>,
72 pub summary: String,
73 pub messages: Vec<String>,
74}
75
76pub trait Completion: Send + Sync {
77 fn complete(&self, request: CompletionRequest) -> BoxFuture<Result<ModelDecision, ActorError>>;
78
79 fn reported_prompt_tokens(&self) -> Option<u64> {
83 None
84 }
85}
86
87pub trait ToolRunner: Send + Sync {
88 fn run(&self, call: ToolCall) -> BoxFuture<Result<Value, ActorError>>;
89
90 fn can_parallelize(&self, _calls: &[ToolCall]) -> bool {
93 false
94 }
95}
96
97pub trait Compactor: Send + Sync {
98 fn compact(&self, messages: &[String]) -> BoxFuture<Result<String, ActorError>>;
99}
100
101#[derive(Clone)]
102pub struct CodingServices {
103 pub completion: Arc<dyn Completion>,
104 pub tools: Arc<dyn ToolRunner>,
105 pub compactor: Arc<dyn Compactor>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct HarnessConfig {
112 budget: u32,
113 compact_after_chars: usize,
114 step_limit: u32,
115 model_attempts: u32,
116 system: Vec<String>,
117 tools: Vec<ToolSpec>,
118 tool_round_cap: Option<u32>,
121 compact_after_tokens: Option<u64>,
124 compact_keep_tokens: usize,
127 duplicate_threshold: Option<u32>,
131 model_retry_delay: std::time::Duration,
134}
135
136impl HarnessConfig {
137 pub fn new(
138 budget: u32,
139 compact_after_chars: usize,
140 step_limit: u32,
141 model_attempts: u32,
142 system: Vec<String>,
143 tools: Vec<ToolSpec>,
144 ) -> Result<Self, ActorError> {
145 if step_limit == 0 {
146 return Err(ActorError::Config("step_limit must be at least 1".into()));
147 }
148 if model_attempts == 0 {
149 return Err(ActorError::Config(
150 "model_attempts must be at least 1".into(),
151 ));
152 }
153 Ok(Self {
154 budget,
155 compact_after_chars,
156 step_limit,
157 model_attempts,
158 system,
159 tools,
160 tool_round_cap: None,
161 compact_after_tokens: None,
162 compact_keep_tokens: 0,
163 duplicate_threshold: None,
164 model_retry_delay: std::time::Duration::ZERO,
165 })
166 }
167
168 pub fn with_token_compact(mut self, after_tokens: u64, keep_tokens: usize) -> Self {
171 self.compact_after_tokens = Some(after_tokens.max(1));
172 self.compact_keep_tokens = keep_tokens.max(1);
173 self.compact_after_chars = usize::MAX;
174 self
175 }
176
177 pub fn with_keep_tokens(mut self, keep_tokens: usize) -> Self {
180 self.compact_keep_tokens = keep_tokens;
181 self
182 }
183
184 pub fn with_duplicate_threshold(mut self, threshold: u32) -> Self {
186 self.duplicate_threshold = Some(threshold);
187 self
188 }
189
190 pub fn with_model_retry_delay(mut self, delay: std::time::Duration) -> Self {
192 self.model_retry_delay = delay;
193 self
194 }
195
196 pub fn with_tool_round_cap(mut self, cap: u32) -> Self {
199 self.tool_round_cap = Some(cap);
200 self
201 }
202
203 pub fn step_limit(&self) -> u32 {
204 self.step_limit
205 }
206
207 pub fn budget(&self) -> u32 {
208 self.budget
209 }
210
211 pub fn compact_after_chars(&self) -> usize {
212 self.compact_after_chars
213 }
214
215 pub fn model_attempts(&self) -> u32 {
216 self.model_attempts
217 }
218
219 pub fn system(&self) -> &[String] {
220 &self.system
221 }
222
223 pub fn tools(&self) -> &[ToolSpec] {
224 &self.tools
225 }
226
227 pub fn tool_round_cap(&self) -> Option<u32> {
228 self.tool_round_cap
229 }
230
231 pub fn model_retry_delay(&self) -> std::time::Duration {
232 self.model_retry_delay
233 }
234}
235
236#[derive(Debug, Clone, PartialEq)]
237pub struct PendingConfirmation {
238 pub tool_call_id: String,
239 pub name: String,
240 pub args: Value,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct PendingQuestion {
245 pub question_id: String,
246 pub question: String,
247 pub allow_free_text: bool,
248 pub options: Vec<String>,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum CodingPhase {
253 Idle,
254 Infer,
255 Compact,
256 Confirm,
257 Question,
258 Tool,
259 Deny,
260 Done,
261}
262
263#[derive(Debug, Clone, PartialEq)]
264pub struct CodingView {
265 pub system: Vec<String>,
266 pub tools: Vec<ToolSpec>,
267 pub pending_confirmation: Option<PendingConfirmation>,
268 pub pending_question: Option<PendingQuestion>,
269 pub assistant: Option<String>,
270 pub phase: CodingPhase,
271 pub schema_error: Option<String>,
272 pub tool_budget: Option<u32>,
274 pub compact_after_chars: Option<usize>,
276}
277
278impl CodingView {
279 pub fn empty() -> Self {
280 Self {
281 system: Vec::new(),
282 tools: Vec::new(),
283 pending_confirmation: None,
284 pending_question: None,
285 assistant: None,
286 phase: CodingPhase::Idle,
287 schema_error: None,
288 tool_budget: None,
289 compact_after_chars: None,
290 }
291 }
292}
293
294pub fn merge_coding_view(views: Vec<CodingView>) -> CodingView {
295 CodingView {
296 system: views.iter().flat_map(|view| view.system.clone()).collect(),
297 tools: views.iter().flat_map(|view| view.tools.clone()).collect(),
298 pending_confirmation: views
299 .iter()
300 .find_map(|view| view.pending_confirmation.clone()),
301 pending_question: views.iter().find_map(|view| view.pending_question.clone()),
302 assistant: views.iter().rev().find_map(|view| view.assistant.clone()),
303 phase: views
304 .iter()
305 .rev()
306 .find(|view| view.phase != CodingPhase::Idle)
307 .map(|view| view.phase)
308 .unwrap_or(CodingPhase::Idle),
309 schema_error: views.iter().find_map(|view| view.schema_error.clone()),
310 tool_budget: views.iter().find_map(|view| view.tool_budget),
311 compact_after_chars: views.iter().find_map(|view| view.compact_after_chars),
312 }
313}
314
315#[derive(Debug, Clone)]
316struct SchedulerState {
317 turn: u64,
318 cycle: u64,
319 phase: CodingPhase,
320 messages: Vec<String>,
321 summary: String,
322 tool_runs: u32,
323 compacted_turn: u64,
324 assistant: Option<String>,
325 pending_call: Option<ToolCall>,
326 queued: Vec<ToolCall>,
328 pending_question: Option<PendingQuestion>,
329 schema_error: Option<String>,
330 prompt_tokens: Option<u64>,
331}
332
333impl SchedulerState {
334 fn new() -> Self {
335 Self {
336 turn: 0,
337 cycle: 0,
338 phase: CodingPhase::Idle,
339 messages: Vec::new(),
340 summary: String::new(),
341 tool_runs: 0,
342 compacted_turn: 0,
343 assistant: None,
344 pending_call: None,
345 queued: Vec::new(),
346 pending_question: None,
347 schema_error: None,
348 prompt_tokens: None,
349 }
350 }
351
352 fn size(&self) -> usize {
353 self.summary.len() + self.messages.iter().map(String::len).sum::<usize>()
354 }
355}
356
357fn after_input(state: SchedulerState, config: &HarnessConfig) -> SchedulerState {
358 let (prefix, _) = split_compact_tail(&state.messages, config.compact_keep_tokens);
359 let token_pressure = config
360 .compact_after_tokens
361 .is_some_and(|limit| state.prompt_tokens.is_some_and(|used| used >= limit));
362 let char_pressure =
363 config.compact_after_tokens.is_none() && state.size() >= config.compact_after_chars;
364 let needs_compact = !prefix.is_empty()
365 && (token_pressure || char_pressure)
366 && state.compacted_turn != state.turn;
367 SchedulerState {
368 phase: if needs_compact {
369 CodingPhase::Compact
370 } else {
371 CodingPhase::Infer
372 },
373 assistant: None,
374 pending_call: None,
375 pending_question: None,
376 ..state
377 }
378}
379
380fn prompt_tokens_of(payload: &Value) -> Option<u64> {
381 payload
382 .get("prompt_tokens")
383 .and_then(|value| value.as_u64())
384 .filter(|tokens| *tokens > 0)
385}
386
387fn on_model(mut state: SchedulerState, config: &HarnessConfig, payload: &Value) -> SchedulerState {
388 let prompt_tokens = prompt_tokens_of(payload).or(state.prompt_tokens);
389 state.prompt_tokens = prompt_tokens;
390 let decided = match serde_json::from_value::<ModelDecision>(payload.clone()) {
391 Ok(decided) => decided,
392 Err(error) => {
393 state.phase = CodingPhase::Done;
394 state.schema_error = Some(error.to_string());
395 state.pending_call = None;
396 return state;
397 }
398 };
399 match decided {
400 ModelDecision::Text { text } => SchedulerState {
401 phase: CodingPhase::Done,
402 assistant: Some(text),
403 pending_call: None,
404 ..state
405 },
406 ModelDecision::Question {
407 question_id,
408 question,
409 allow_free_text,
410 options,
411 } => SchedulerState {
412 phase: CodingPhase::Question,
413 pending_question: Some(PendingQuestion {
414 question_id,
415 question,
416 allow_free_text,
417 options,
418 }),
419 pending_call: None,
420 ..state
421 },
422 ModelDecision::Tool { call, also } => {
423 let mut calls = Vec::with_capacity(1 + also.len());
424 calls.push(call);
425 calls.extend(also);
426 let mut messages = state.messages.clone();
427 for call in &calls {
428 messages.push(format!("assistant\ntool {}\n{}", call.name, call.args));
429 }
430 let Some(first) = calls.first().cloned() else {
431 return SchedulerState {
432 phase: CodingPhase::Done,
433 schema_error: Some("model tool decision had no call".into()),
434 ..state
435 };
436 };
437 let queued = calls.into_iter().skip(1).collect();
438 let recorded = SchedulerState {
439 messages,
440 queued,
441 ..state
442 };
443 if recorded.tool_runs >= config.budget {
444 SchedulerState {
445 phase: CodingPhase::Deny,
446 pending_call: Some(first),
447 ..recorded
448 }
449 } else if first.needs_confirmation {
450 SchedulerState {
451 phase: CodingPhase::Confirm,
452 pending_call: Some(first),
453 ..recorded
454 }
455 } else {
456 SchedulerState {
457 phase: CodingPhase::Tool,
458 pending_call: Some(first),
459 ..recorded
460 }
461 }
462 }
463 }
464}
465
466#[derive(Deserialize)]
467struct TextPayload {
468 text: String,
469}
470
471#[derive(Deserialize)]
472struct SummaryPayload {
473 summary: String,
474 #[serde(default)]
475 kept: Vec<String>,
476}
477
478fn estimate_tokens(text: &str) -> usize {
479 text.len().div_ceil(4).max(1)
480}
481
482fn is_tool_result_line(message: &str) -> bool {
483 message.starts_with("tool\n") || message.starts_with("tool-error\n")
484}
485
486fn tool_result_line(ok: bool, output: &str) -> String {
487 if ok {
488 format!("tool\n{output}")
489 } else {
490 format!("tool-error\n{output}")
491 }
492}
493
494fn promote_queued(mut state: SchedulerState) -> SchedulerState {
495 if state.queued.is_empty() {
496 state.pending_call = None;
497 return state;
498 }
499 let next = state.queued.remove(0);
500 state.phase = if next.needs_confirmation {
501 CodingPhase::Confirm
502 } else {
503 CodingPhase::Tool
504 };
505 state.pending_call = Some(next);
506 state.pending_question = None;
507 state
508}
509
510fn split_compact_tail(messages: &[String], keep_tokens: usize) -> (Vec<String>, Vec<String>) {
514 if messages.is_empty() {
515 return (Vec::new(), Vec::new());
516 }
517 if keep_tokens == 0 {
518 let Some(tool_at) = messages
519 .iter()
520 .rposition(|message| is_tool_result_line(message))
521 else {
522 return (messages.to_vec(), Vec::new());
523 };
524 let start = if tool_at > 0 && messages[tool_at - 1].starts_with("assistant\ntool ") {
525 tool_at - 1
526 } else {
527 tool_at
528 };
529 return (messages[..start].to_vec(), messages[start..].to_vec());
530 }
531 let mut units = Vec::new();
532 let mut index = 0;
533 while index < messages.len() {
534 if index + 1 < messages.len()
535 && messages[index].starts_with("assistant\ntool ")
536 && is_tool_result_line(&messages[index + 1])
537 {
538 units.push((index, index + 2));
539 index += 2;
540 } else {
541 units.push((index, index + 1));
542 index += 1;
543 }
544 }
545 let mut start_unit = units.len() - 1;
546 let mut used = messages[units[start_unit].0..units[start_unit].1]
547 .iter()
548 .map(|line| estimate_tokens(line))
549 .sum::<usize>();
550 while start_unit > 0 {
551 let previous = start_unit - 1;
552 let extra = messages[units[previous].0..units[previous].1]
553 .iter()
554 .map(|line| estimate_tokens(line))
555 .sum::<usize>();
556 if used.saturating_add(extra) > keep_tokens {
557 break;
558 }
559 used = used.saturating_add(extra);
560 start_unit = previous;
561 }
562 let start = units[start_unit].0;
563 (messages[..start].to_vec(), messages[start..].to_vec())
564}
565
566fn duplicate_nudge(messages: &[String], call: &ToolCall, threshold: Option<u32>) -> Option<String> {
567 let threshold = threshold.filter(|value| *value > 0)?;
568 let line = format!("assistant\ntool {}\n{}", call.name, call.args);
569 let count = messages
570 .iter()
571 .filter(|message| message.as_str() == line)
572 .count();
573 if count <= threshold as usize {
574 return None;
575 }
576 Some(format!(
577 "Tool '{}' has been called {} times with identical arguments. Change your approach instead of repeating this call.",
578 call.name, count
579 ))
580}
581
582#[derive(Deserialize)]
583struct ConfirmationPayload {
584 tool_call_id: String,
585 approved: bool,
586}
587
588#[derive(Deserialize)]
589struct ToolResultPayload {
590 ok: bool,
591 output: String,
592 #[serde(default, rename = "countRun")]
595 count_run: Option<bool>,
596}
597
598fn step(mut state: SchedulerState, fact: &Fact, config: &HarnessConfig) -> SchedulerState {
599 match fact.kind.as_str() {
600 "user.message" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
601 Ok(payload) => after_input(
602 SchedulerState {
603 turn: state.turn + 1,
604 cycle: 0,
605 messages: {
606 let mut messages = state.messages.clone();
607 messages.push(format!("user\n{}", payload.text));
608 messages
609 },
610 tool_runs: 0,
611 ..state
612 },
613 config,
614 ),
615 Err(error) => {
616 state.phase = CodingPhase::Done;
617 state.schema_error = Some(error.to_string());
618 state
619 }
620 },
621 "compaction.done" => match serde_json::from_value::<SummaryPayload>(fact.payload.clone()) {
622 Ok(payload) => SchedulerState {
623 summary: payload.summary,
624 compacted_turn: state.turn,
625 phase: CodingPhase::Infer,
626 messages: payload.kept,
627 ..state
628 },
629 Err(error) => {
630 state.phase = CodingPhase::Done;
631 state.schema_error = Some(error.to_string());
632 state
633 }
634 },
635 "model.turn" => on_model(state, config, &fact.payload),
636 "confirmation.answered" => {
637 let Some(call) = state.pending_call.clone() else {
638 return state;
639 };
640 match serde_json::from_value::<ConfirmationPayload>(fact.payload.clone()) {
641 Ok(payload) if payload.tool_call_id == call.id && payload.approved => {
642 SchedulerState {
643 phase: CodingPhase::Tool,
644 pending_question: None,
645 ..state
646 }
647 }
648 Ok(payload) if payload.tool_call_id == call.id => {
649 if state.queued.is_empty() {
650 SchedulerState {
651 phase: CodingPhase::Done,
652 assistant: Some("denied".into()),
653 pending_call: None,
654 ..state
655 }
656 } else {
657 let mut continued = SchedulerState {
658 cycle: state.cycle + 1,
659 messages: {
660 let mut messages = state.messages.clone();
661 messages.push(tool_result_line(false, "denied"));
662 messages
663 },
664 tool_runs: state.tool_runs.saturating_add(1),
665 ..state
666 };
667 continued = promote_queued(continued);
668 continued
669 }
670 }
671 Ok(_) => state,
672 Err(error) => {
673 state.schema_error = Some(error.to_string());
674 state.phase = CodingPhase::Done;
675 state
676 }
677 }
678 }
679 "tool.result" => match serde_json::from_value::<ToolResultPayload>(fact.payload.clone()) {
680 Ok(payload) => {
681 let counts = payload.count_run.unwrap_or(payload.ok);
682 let folded = SchedulerState {
683 tool_runs: state.tool_runs + u32::from(counts),
684 cycle: state.cycle + 1,
685 messages: {
686 let mut messages = state.messages.clone();
687 messages.push(tool_result_line(payload.ok, &payload.output));
688 messages
689 },
690 ..state
691 };
692 let folded = promote_queued(folded);
693 if folded.pending_call.is_none() {
694 after_input(folded, config)
695 } else {
696 folded
697 }
698 }
699 Err(error) => {
700 state.schema_error = Some(error.to_string());
701 state.phase = CodingPhase::Done;
702 state
703 }
704 },
705 "question.answered" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
706 Ok(payload) => after_input(
707 SchedulerState {
708 cycle: state.cycle + 1,
709 messages: {
710 let mut messages = state.messages.clone();
711 messages.push(format!("user\n{}", payload.text));
712 messages
713 },
714 pending_question: None,
715 ..state
716 },
717 config,
718 ),
719 Err(error) => {
720 state.schema_error = Some(error.to_string());
721 state.phase = CodingPhase::Done;
722 state
723 }
724 },
725 "budget.denied" => SchedulerState {
726 phase: CodingPhase::Done,
727 assistant: Some("budget".into()),
728 pending_call: None,
729 queued: Vec::new(),
730 ..state
731 },
732 _ => state,
733 }
734}
735
736fn view_of(state: &SchedulerState) -> CodingView {
737 CodingView {
738 pending_confirmation: match (&state.phase, &state.pending_call) {
739 (CodingPhase::Confirm, Some(call)) => Some(PendingConfirmation {
740 tool_call_id: call.id.clone(),
741 name: call.name.clone(),
742 args: call.args.clone(),
743 }),
744 _ => None,
745 },
746 pending_question: match state.phase {
747 CodingPhase::Question => state.pending_question.clone(),
748 _ => None,
749 },
750 assistant: state.assistant.clone(),
751 phase: state.phase,
752 schema_error: state.schema_error.clone(),
753 ..CodingView::empty()
754 }
755}
756
757fn transitions_of(
758 state: &SchedulerState,
759 config: &HarnessConfig,
760) -> Vec<Transition<CodingServices>> {
761 match state.phase {
762 CodingPhase::Compact => {
763 let (prefix, kept) = split_compact_tail(&state.messages, config.compact_keep_tokens);
764 let mut to_summarize = prefix;
765 if !state.summary.is_empty() {
766 to_summarize.insert(0, state.summary.clone());
767 }
768 let turn = state.turn;
769 vec![Transition {
770 key: format!("compact:{turn}"),
771 run: Effect::from_async(move |services: Arc<CodingServices>, _cancel| {
772 let to_summarize = to_summarize.clone();
773 let kept = kept.clone();
774 async move {
775 let summary = services.compactor.compact(&to_summarize).await?;
776 Ok(vec![NewFact {
777 kind: "compaction.done".into(),
778 key: format!("compaction:{turn}"),
779 payload: json!({ "summary": summary, "kept": kept }),
780 }])
781 }
782 }),
783 }]
784 }
785 CodingPhase::Infer => {
786 let tools = match config.tool_round_cap {
787 Some(cap) if state.tool_runs >= cap => Vec::new(),
788 _ => config.tools.clone(),
789 };
790 let request = CompletionRequest {
791 system: config.system.clone(),
792 tools,
793 summary: state.summary.clone(),
794 messages: state.messages.clone(),
795 };
796 let turn = state.turn;
797 let cycle = state.cycle;
798 let attempts = config.model_attempts;
799 let retry_delay = config.model_retry_delay;
800 vec![Transition {
801 key: format!("infer:{turn}:{cycle}"),
802 run: Effect::from_async(move |services: Arc<CodingServices>, _cancel| {
803 let request = request.clone();
804 async move {
805 let decided = match services.completion.complete(request).await {
806 Ok(decided) => decided,
807 Err(ActorError::Defect(message)) => return Err(Exit::Die(message)),
808 Err(error) => return Err(Exit::Fail(error)),
809 };
810 let mut payload = serde_json::to_value(&decided)
811 .map_err(|error| Exit::Die(error.to_string()))?;
812 if let Some(tokens) = services.completion.reported_prompt_tokens() {
813 payload["prompt_tokens"] = json!(tokens);
814 }
815 Ok(vec![NewFact {
816 kind: "model.turn".into(),
817 key: format!("model:{turn}:{cycle}"),
818 payload,
819 }])
820 }
821 })
822 .retry(Schedule {
823 remaining: attempts.saturating_sub(1),
824 delay: retry_delay,
825 }),
826 }]
827 }
828 CodingPhase::Tool => {
829 let Some(call) = state.pending_call.clone() else {
830 return Vec::new();
831 };
832 let batch = ready_tool_batch(&call, &state.queued);
833 let turn = state.turn;
834 let cycle = state.cycle;
835 let messages = state.messages.clone();
836 let threshold = config.duplicate_threshold;
837 let key = batch
838 .iter()
839 .map(|call| call.id.as_str())
840 .collect::<Vec<_>>()
841 .join("+");
842 vec![Transition {
843 key: format!("tool:{turn}:{cycle}:{key}"),
844 run: Effect::from_async(move |services: Arc<CodingServices>, _cancel| {
845 let batch = batch.clone();
846 let messages = messages.clone();
847 async move {
848 run_tool_batch(services, batch, &messages, threshold, turn, cycle).await
849 }
850 }),
851 }]
852 }
853 CodingPhase::Deny => {
854 let Some(call) = state.pending_call.clone() else {
855 return Vec::new();
856 };
857 vec![Transition {
858 key: format!("budget:{}:{}:{}", state.turn, state.cycle, call.id),
859 run: Effect::succeed(vec![NewFact {
860 kind: "budget.denied".into(),
861 key: format!("budget-denied:{}:{}:{}", state.turn, state.cycle, call.id),
862 payload: json!({ "toolCallId": call.id }),
863 }]),
864 }]
865 }
866 CodingPhase::Idle | CodingPhase::Confirm | CodingPhase::Question | CodingPhase::Done => {
867 Vec::new()
868 }
869 }
870}
871
872fn ready_tool_batch(pending: &ToolCall, queued: &[ToolCall]) -> Vec<ToolCall> {
873 let mut batch = vec![pending.clone()];
874 for call in queued {
875 if call.needs_confirmation {
876 break;
877 }
878 batch.push(call.clone());
879 }
880 batch
881}
882
883fn tool_output_text(value: &Value) -> String {
884 match value {
885 Value::String(text) => text.clone(),
886 other => other.to_string(),
887 }
888}
889
890fn tool_result_fact(
891 turn: u64,
892 cycle: u64,
893 id: &str,
894 ok: bool,
895 output: String,
896 count_run: bool,
897) -> NewFact {
898 let mut payload = json!({ "toolCallId": id, "ok": ok, "output": output });
899 if count_run {
900 payload["countRun"] = json!(true);
901 }
902 NewFact {
903 kind: "tool.result".into(),
904 key: format!("tool-result:{turn}:{cycle}:{id}"),
905 payload,
906 }
907}
908
909async fn run_tool_batch(
910 services: Arc<CodingServices>,
911 batch: Vec<ToolCall>,
912 messages: &[String],
913 threshold: Option<u32>,
914 turn: u64,
915 cycle: u64,
916) -> Result<Vec<NewFact>, Exit<ActorError>> {
917 let mut nudges = Vec::with_capacity(batch.len());
918 let mut runnable = Vec::new();
919 for call in &batch {
920 if let Some(nudge) = duplicate_nudge(messages, call, threshold) {
921 nudges.push(Some(nudge));
922 } else {
923 nudges.push(None);
924 runnable.push(call.clone());
925 }
926 }
927 let outputs = if runnable.len() > 1 && services.tools.can_parallelize(&runnable) {
928 run_parallel(&services.tools, runnable).await?
929 } else {
930 let mut outputs = Vec::with_capacity(runnable.len());
931 for call in runnable {
932 outputs.push(services.tools.run(call).await.map_err(Exit::Fail)?);
933 }
934 outputs
935 };
936 let mut ran = outputs.into_iter();
937 let mut facts = Vec::with_capacity(batch.len());
938 for (call, nudge) in batch.iter().zip(nudges) {
939 if let Some(nudge) = nudge {
940 facts.push(tool_result_fact(turn, cycle, &call.id, false, nudge, true));
941 continue;
942 }
943 let output = ran
944 .next()
945 .ok_or_else(|| Exit::Die("tool batch lost a result".into()))?;
946 facts.push(tool_result_fact(
947 turn,
948 cycle,
949 &call.id,
950 true,
951 tool_output_text(&output),
952 false,
953 ));
954 }
955 Ok(facts)
956}
957
958async fn run_parallel(
959 tools: &Arc<dyn ToolRunner>,
960 calls: Vec<ToolCall>,
961) -> Result<Vec<Value>, Exit<ActorError>> {
962 let mut set = tokio::task::JoinSet::new();
963 for (index, call) in calls.into_iter().enumerate() {
964 let tools = Arc::clone(tools);
965 set.spawn(async move { (index, tools.run(call).await) });
966 }
967 let mut indexed = Vec::new();
968 while let Some(joined) = set.join_next().await {
969 indexed.push(joined.map_err(|error| Exit::Die(error.to_string()))?);
970 }
971 indexed.sort_by_key(|(index, _)| *index);
972 indexed
973 .into_iter()
974 .map(|(_, result)| result.map_err(Exit::Fail))
975 .collect()
976}
977
978pub fn coding_scheduler(config: HarnessConfig) -> ErasedComponent<CodingServices, CodingView> {
980 let step_config = config.clone();
981 let output_config = config;
982 component(
983 SchedulerState::new,
984 move |state, fact| step(state, fact, &step_config),
985 move |state| (view_of(state), transitions_of(state, &output_config)),
986 )
987}
988
989pub fn coding_actor(config: HarnessConfig) -> Actor<CodingServices, CodingView> {
994 let spec = crate::compose::MetaHarnessSpec {
995 name: "a3s-code",
996 budget: config.budget(),
997 compact_after_chars: config.compact_after_chars(),
998 step_limit: config.step_limit(),
999 model_attempts: config.model_attempts(),
1000 system: config.system().to_vec(),
1001 tools: config.tools().to_vec(),
1002 tool_round_cap: config.tool_round_cap(),
1003 parts: Vec::new(),
1004 };
1005 crate::compose::HarnessGraph::from_spec(spec, || coding_scheduler(config.clone())).into_actor()
1006}
1007
1008pub fn message_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
1009 NewFact {
1010 kind: "user.message".into(),
1011 key: key.into(),
1012 payload: json!({ "text": text.into() }),
1013 }
1014}
1015
1016pub fn confirm_fact(
1017 key: impl Into<String>,
1018 tool_call_id: impl Into<String>,
1019 approved: bool,
1020) -> NewFact {
1021 NewFact {
1022 kind: "confirmation.answered".into(),
1023 key: key.into(),
1024 payload: json!({ "tool_call_id": tool_call_id.into(), "approved": approved }),
1025 }
1026}
1027
1028pub fn answer_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
1029 NewFact {
1030 kind: "question.answered".into(),
1031 key: key.into(),
1032 payload: json!({ "text": text.into() }),
1033 }
1034}
1035
1036pub async fn ingest_coding(
1037 actor: &Actor<CodingServices, CodingView>,
1038 log: &dyn crate::log::LogStore,
1039 services: Arc<CodingServices>,
1040 thread_id: &str,
1041 fact: NewFact,
1042 limit: u32,
1043) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1044 ingest(actor, log, services, thread_id, fact, limit).await
1045}
1046
1047pub async fn resume_coding(
1048 actor: &Actor<CodingServices, CodingView>,
1049 log: &dyn crate::log::LogStore,
1050 services: Arc<CodingServices>,
1051 thread_id: &str,
1052 limit: u32,
1053) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1054 resume(actor, log, services, thread_id, limit).await
1055}