Skip to main content

a3s_effect/
coding.rs

1//! Coding harness projected from the log.
2//!
3//! The scheduler does not keep a parked question or a tool confirmation in
4//! process memory. Those waits are phases of the fold. The runtime runs a
5//! transition only when the fold enables one. Answering or confirming appends
6//! a fact, and the next `resume` derives the tool or model call from that fact.
7
8use 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    /// Prose that accompanied the call. The fold does not branch on it.
36    /// Replaying it is what lets the next model call see its own plan.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub text: Option<String>,
39    /// Provider reasoning that accompanied the call. The fold does not branch
40    /// on it. Absent on logs written before this field existed.
41    #[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        /// Further calls from the same model message. Empty on older logs.
54        #[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        /// Options the host renders after the log is reopened. Absent JSON
62        /// fields decode as an empty list.
63        #[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    /// Provider-reported prompt tokens for the completion that just returned.
80    /// `None` when the provider omitted usage. The infer transition copies this
81    /// onto the `model.turn` fact so a later fold can compact from it.
82    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    /// True when every call in the batch can run at the same time.
91    /// The default keeps calls in order, one after another.
92    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/// Configuration checked before an actor exists. A zero step limit or a zero
109/// model attempt count cannot construct a harness.
110#[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    /// After this many successful tool results in the current turn, the next
119    /// completion request carries an empty tool list. `None` does not cap.
120    tool_round_cap: Option<u32>,
121    /// Compact when the previous model turn reported at least this many prompt
122    /// tokens. `None` keeps the character threshold.
123    compact_after_tokens: Option<u64>,
124    /// Recent transcript to keep verbatim, in estimated tokens. `0` keeps only
125    /// the latest tool call and its result.
126    compact_keep_tokens: usize,
127    /// After this many identical tool calls, the next one is not executed.
128    /// The model receives a tool result that asks it to change approach.
129    /// `None` does not check.
130    duplicate_threshold: Option<u32>,
131    /// Wait this long before replaying a failed model call. `ZERO` retries
132    /// immediately. The host sets a jittered delay for provider failures.
133    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    /// Compact from provider prompt tokens and keep a recent token budget.
169    /// The character threshold no longer fires on this config.
170    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    /// Keep this many estimated tokens of recent transcript when a character
178    /// threshold fires. `0` keeps only the latest tool pair.
179    pub fn with_keep_tokens(mut self, keep_tokens: usize) -> Self {
180        self.compact_keep_tokens = keep_tokens;
181        self
182    }
183
184    /// Soft stop for an identical tool call. The call is not executed.
185    pub fn with_duplicate_threshold(mut self, threshold: u32) -> Self {
186        self.duplicate_threshold = Some(threshold);
187        self
188    }
189
190    /// Delay before a failed model call is tried again.
191    pub fn with_model_retry_delay(mut self, delay: std::time::Duration) -> Self {
192        self.model_retry_delay = delay;
193        self
194    }
195
196    /// One later completion sees no tools once this many tool results exist.
197    /// The caller does not inject a user message to force that completion.
198    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    /// Composed budget policy slot (`budget(...)` part).
273    pub tool_budget: Option<u32>,
274    /// Composed compaction threshold slot (`compact(...)` part).
275    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    /// Calls from the same model message that have not run yet.
327    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
510/// Older lines are summarized. `keep_tokens == 0` retains only the newest tool
511/// call and its result. A positive budget walks backward by tool pairs and
512/// keeps every recent pair that still fits, always including the newest pair.
513fn 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    /// When set, this result counts toward the tool ceiling even if `ok` is false.
593    /// Absent logs count only successful results, which is the older behavior.
594    #[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
978/// Infer-loop scheduler (model / tool / compact / budget deny phases).
979pub 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
989/// Stock Meta Harness: `system + tools + budget + compact + infer([scheduler])`.
990///
991/// Equivalent to the pre-composition three-component actor for control
992/// behavior; budget/compact also appear as explicit tree parts.
993pub 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}