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    },
54    Question {
55        question_id: String,
56        question: String,
57        allow_free_text: bool,
58        /// Options the host renders after the log is reopened. Absent JSON
59        /// fields decode as an empty list.
60        #[serde(default)]
61        options: Vec<String>,
62    },
63}
64
65#[derive(Debug, Clone)]
66pub struct CompletionRequest {
67    pub system: Vec<String>,
68    pub tools: Vec<ToolSpec>,
69    pub summary: String,
70    pub messages: Vec<String>,
71}
72
73pub trait Completion: Send + Sync {
74    fn complete(&self, request: CompletionRequest) -> BoxFuture<Result<ModelDecision, ActorError>>;
75}
76
77pub trait ToolRunner: Send + Sync {
78    fn run(&self, call: ToolCall) -> BoxFuture<Result<Value, ActorError>>;
79}
80
81pub trait Compactor: Send + Sync {
82    fn compact(&self, messages: &[String]) -> BoxFuture<Result<String, ActorError>>;
83}
84
85#[derive(Clone)]
86pub struct CodingServices {
87    pub completion: Arc<dyn Completion>,
88    pub tools: Arc<dyn ToolRunner>,
89    pub compactor: Arc<dyn Compactor>,
90}
91
92/// Configuration checked before an actor exists. A zero step limit or a zero
93/// model attempt count cannot construct a harness.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct HarnessConfig {
96    budget: u32,
97    compact_after_chars: usize,
98    step_limit: u32,
99    model_attempts: u32,
100    system: Vec<String>,
101    tools: Vec<ToolSpec>,
102    /// After this many successful tool results in the current turn, the next
103    /// completion request carries an empty tool list. `None` does not cap.
104    tool_round_cap: Option<u32>,
105}
106
107impl HarnessConfig {
108    pub fn new(
109        budget: u32,
110        compact_after_chars: usize,
111        step_limit: u32,
112        model_attempts: u32,
113        system: Vec<String>,
114        tools: Vec<ToolSpec>,
115    ) -> Result<Self, ActorError> {
116        if step_limit == 0 {
117            return Err(ActorError::Config("step_limit must be at least 1".into()));
118        }
119        if model_attempts == 0 {
120            return Err(ActorError::Config(
121                "model_attempts must be at least 1".into(),
122            ));
123        }
124        Ok(Self {
125            budget,
126            compact_after_chars,
127            step_limit,
128            model_attempts,
129            system,
130            tools,
131            tool_round_cap: None,
132        })
133    }
134
135    /// One later completion sees no tools once this many tool results exist.
136    /// The caller does not inject a user message to force that completion.
137    pub fn with_tool_round_cap(mut self, cap: u32) -> Self {
138        self.tool_round_cap = Some(cap);
139        self
140    }
141
142    pub fn step_limit(&self) -> u32 {
143        self.step_limit
144    }
145
146    pub fn budget(&self) -> u32 {
147        self.budget
148    }
149
150    pub fn compact_after_chars(&self) -> usize {
151        self.compact_after_chars
152    }
153
154    pub fn model_attempts(&self) -> u32 {
155        self.model_attempts
156    }
157
158    pub fn system(&self) -> &[String] {
159        &self.system
160    }
161
162    pub fn tools(&self) -> &[ToolSpec] {
163        &self.tools
164    }
165
166    pub fn tool_round_cap(&self) -> Option<u32> {
167        self.tool_round_cap
168    }
169}
170
171#[derive(Debug, Clone, PartialEq)]
172pub struct PendingConfirmation {
173    pub tool_call_id: String,
174    pub name: String,
175    pub args: Value,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct PendingQuestion {
180    pub question_id: String,
181    pub question: String,
182    pub allow_free_text: bool,
183    pub options: Vec<String>,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum CodingPhase {
188    Idle,
189    Infer,
190    Compact,
191    Confirm,
192    Question,
193    Tool,
194    Deny,
195    Done,
196}
197
198#[derive(Debug, Clone, PartialEq)]
199pub struct CodingView {
200    pub system: Vec<String>,
201    pub tools: Vec<ToolSpec>,
202    pub pending_confirmation: Option<PendingConfirmation>,
203    pub pending_question: Option<PendingQuestion>,
204    pub assistant: Option<String>,
205    pub phase: CodingPhase,
206    pub schema_error: Option<String>,
207    /// Composed budget policy slot (`budget(...)` part).
208    pub tool_budget: Option<u32>,
209    /// Composed compaction threshold slot (`compact(...)` part).
210    pub compact_after_chars: Option<usize>,
211}
212
213impl CodingView {
214    pub fn empty() -> Self {
215        Self {
216            system: Vec::new(),
217            tools: Vec::new(),
218            pending_confirmation: None,
219            pending_question: None,
220            assistant: None,
221            phase: CodingPhase::Idle,
222            schema_error: None,
223            tool_budget: None,
224            compact_after_chars: None,
225        }
226    }
227}
228
229pub fn merge_coding_view(views: Vec<CodingView>) -> CodingView {
230    CodingView {
231        system: views.iter().flat_map(|view| view.system.clone()).collect(),
232        tools: views.iter().flat_map(|view| view.tools.clone()).collect(),
233        pending_confirmation: views
234            .iter()
235            .find_map(|view| view.pending_confirmation.clone()),
236        pending_question: views.iter().find_map(|view| view.pending_question.clone()),
237        assistant: views.iter().rev().find_map(|view| view.assistant.clone()),
238        phase: views
239            .iter()
240            .rev()
241            .find(|view| view.phase != CodingPhase::Idle)
242            .map(|view| view.phase)
243            .unwrap_or(CodingPhase::Idle),
244        schema_error: views.iter().find_map(|view| view.schema_error.clone()),
245        tool_budget: views.iter().find_map(|view| view.tool_budget),
246        compact_after_chars: views.iter().find_map(|view| view.compact_after_chars),
247    }
248}
249
250#[derive(Debug, Clone)]
251struct SchedulerState {
252    turn: u64,
253    cycle: u64,
254    phase: CodingPhase,
255    messages: Vec<String>,
256    summary: String,
257    tool_runs: u32,
258    compacted_turn: u64,
259    assistant: Option<String>,
260    pending_call: Option<ToolCall>,
261    pending_question: Option<PendingQuestion>,
262    schema_error: Option<String>,
263}
264
265impl SchedulerState {
266    fn new() -> Self {
267        Self {
268            turn: 0,
269            cycle: 0,
270            phase: CodingPhase::Idle,
271            messages: Vec::new(),
272            summary: String::new(),
273            tool_runs: 0,
274            compacted_turn: 0,
275            assistant: None,
276            pending_call: None,
277            pending_question: None,
278            schema_error: None,
279        }
280    }
281
282    fn size(&self) -> usize {
283        self.summary.len() + self.messages.iter().map(String::len).sum::<usize>()
284    }
285}
286
287fn after_input(state: SchedulerState, config: &HarnessConfig) -> SchedulerState {
288    let needs_compact =
289        state.size() >= config.compact_after_chars && state.compacted_turn != state.turn;
290    SchedulerState {
291        phase: if needs_compact {
292            CodingPhase::Compact
293        } else {
294            CodingPhase::Infer
295        },
296        assistant: None,
297        pending_call: None,
298        pending_question: None,
299        ..state
300    }
301}
302
303fn on_model(mut state: SchedulerState, config: &HarnessConfig, payload: &Value) -> SchedulerState {
304    let decided = match serde_json::from_value::<ModelDecision>(payload.clone()) {
305        Ok(decided) => decided,
306        Err(error) => {
307            state.phase = CodingPhase::Done;
308            state.schema_error = Some(error.to_string());
309            state.pending_call = None;
310            return state;
311        }
312    };
313    match decided {
314        ModelDecision::Text { text } => SchedulerState {
315            phase: CodingPhase::Done,
316            assistant: Some(text),
317            pending_call: None,
318            ..state
319        },
320        ModelDecision::Question {
321            question_id,
322            question,
323            allow_free_text,
324            options,
325        } => SchedulerState {
326            phase: CodingPhase::Question,
327            pending_question: Some(PendingQuestion {
328                question_id,
329                question,
330                allow_free_text,
331                options,
332            }),
333            pending_call: None,
334            ..state
335        },
336        ModelDecision::Tool { call } => {
337            if state.tool_runs >= config.budget {
338                SchedulerState {
339                    phase: CodingPhase::Deny,
340                    pending_call: Some(call),
341                    ..state
342                }
343            } else if call.needs_confirmation {
344                SchedulerState {
345                    phase: CodingPhase::Confirm,
346                    pending_call: Some(call),
347                    ..state
348                }
349            } else {
350                SchedulerState {
351                    phase: CodingPhase::Tool,
352                    pending_call: Some(call),
353                    ..state
354                }
355            }
356        }
357    }
358}
359
360#[derive(Deserialize)]
361struct TextPayload {
362    text: String,
363}
364
365#[derive(Deserialize)]
366struct SummaryPayload {
367    summary: String,
368}
369
370#[derive(Deserialize)]
371struct ConfirmationPayload {
372    tool_call_id: String,
373    approved: bool,
374}
375
376#[derive(Deserialize)]
377struct ToolResultPayload {
378    ok: bool,
379    output: String,
380}
381
382fn step(mut state: SchedulerState, fact: &Fact, config: &HarnessConfig) -> SchedulerState {
383    match fact.kind.as_str() {
384        "user.message" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
385            Ok(payload) => after_input(
386                SchedulerState {
387                    turn: state.turn + 1,
388                    cycle: 0,
389                    messages: {
390                        let mut messages = state.messages.clone();
391                        messages.push(format!("user\n{}", payload.text));
392                        messages
393                    },
394                    tool_runs: 0,
395                    ..state
396                },
397                config,
398            ),
399            Err(error) => {
400                state.phase = CodingPhase::Done;
401                state.schema_error = Some(error.to_string());
402                state
403            }
404        },
405        "compaction.done" => match serde_json::from_value::<SummaryPayload>(fact.payload.clone()) {
406            Ok(payload) => SchedulerState {
407                summary: payload.summary,
408                compacted_turn: state.turn,
409                phase: CodingPhase::Infer,
410                messages: Vec::new(),
411                ..state
412            },
413            Err(error) => {
414                state.phase = CodingPhase::Done;
415                state.schema_error = Some(error.to_string());
416                state
417            }
418        },
419        "model.turn" => on_model(state, config, &fact.payload),
420        "confirmation.answered" => {
421            let Some(call) = state.pending_call.clone() else {
422                return state;
423            };
424            match serde_json::from_value::<ConfirmationPayload>(fact.payload.clone()) {
425                Ok(payload) if payload.tool_call_id == call.id && payload.approved => {
426                    SchedulerState {
427                        phase: CodingPhase::Tool,
428                        pending_question: None,
429                        ..state
430                    }
431                }
432                Ok(payload) if payload.tool_call_id == call.id => SchedulerState {
433                    phase: CodingPhase::Done,
434                    assistant: Some("denied".into()),
435                    pending_call: None,
436                    ..state
437                },
438                Ok(_) => state,
439                Err(error) => {
440                    state.schema_error = Some(error.to_string());
441                    state.phase = CodingPhase::Done;
442                    state
443                }
444            }
445        }
446        "tool.result" => match serde_json::from_value::<ToolResultPayload>(fact.payload.clone()) {
447            Ok(payload) => after_input(
448                SchedulerState {
449                    tool_runs: state.tool_runs + u32::from(payload.ok),
450                    cycle: state.cycle + 1,
451                    messages: {
452                        let mut messages = state.messages.clone();
453                        messages.push(format!("tool\n{}", payload.output));
454                        messages
455                    },
456                    pending_call: None,
457                    ..state
458                },
459                config,
460            ),
461            Err(error) => {
462                state.schema_error = Some(error.to_string());
463                state.phase = CodingPhase::Done;
464                state
465            }
466        },
467        "question.answered" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
468            Ok(payload) => after_input(
469                SchedulerState {
470                    cycle: state.cycle + 1,
471                    messages: {
472                        let mut messages = state.messages.clone();
473                        messages.push(format!("user\n{}", payload.text));
474                        messages
475                    },
476                    pending_question: None,
477                    ..state
478                },
479                config,
480            ),
481            Err(error) => {
482                state.schema_error = Some(error.to_string());
483                state.phase = CodingPhase::Done;
484                state
485            }
486        },
487        "budget.denied" => SchedulerState {
488            phase: CodingPhase::Done,
489            assistant: Some("budget".into()),
490            pending_call: None,
491            ..state
492        },
493        _ => state,
494    }
495}
496
497fn view_of(state: &SchedulerState) -> CodingView {
498    CodingView {
499        pending_confirmation: match (&state.phase, &state.pending_call) {
500            (CodingPhase::Confirm, Some(call)) => Some(PendingConfirmation {
501                tool_call_id: call.id.clone(),
502                name: call.name.clone(),
503                args: call.args.clone(),
504            }),
505            _ => None,
506        },
507        pending_question: match state.phase {
508            CodingPhase::Question => state.pending_question.clone(),
509            _ => None,
510        },
511        assistant: state.assistant.clone(),
512        phase: state.phase,
513        schema_error: state.schema_error.clone(),
514        ..CodingView::empty()
515    }
516}
517
518fn transitions_of(
519    state: &SchedulerState,
520    config: &HarnessConfig,
521) -> Vec<Transition<CodingServices>> {
522    match state.phase {
523        CodingPhase::Compact => {
524            let mut messages = state.messages.clone();
525            if !state.summary.is_empty() {
526                messages.insert(0, state.summary.clone());
527            }
528            let turn = state.turn;
529            vec![Transition {
530                key: format!("compact:{turn}"),
531                run: Effect::from_async(move |services: Arc<CodingServices>, _cancel| {
532                    let messages = messages.clone();
533                    async move {
534                        let summary = services.compactor.compact(&messages).await?;
535                        Ok(vec![NewFact {
536                            kind: "compaction.done".into(),
537                            key: format!("compaction:{turn}"),
538                            payload: json!({ "summary": summary }),
539                        }])
540                    }
541                }),
542            }]
543        }
544        CodingPhase::Infer => {
545            let tools = match config.tool_round_cap {
546                Some(cap) if state.tool_runs >= cap => Vec::new(),
547                _ => config.tools.clone(),
548            };
549            let request = CompletionRequest {
550                system: config.system.clone(),
551                tools,
552                summary: state.summary.clone(),
553                messages: state.messages.clone(),
554            };
555            let turn = state.turn;
556            let cycle = state.cycle;
557            let attempts = config.model_attempts;
558            vec![Transition {
559                key: format!("infer:{turn}:{cycle}"),
560                run: Effect::from_async(move |services: Arc<CodingServices>, _cancel| {
561                    let request = request.clone();
562                    async move {
563                        let decided = match services.completion.complete(request).await {
564                            Ok(decided) => decided,
565                            Err(ActorError::Defect(message)) => return Err(Exit::Die(message)),
566                            Err(error) => return Err(Exit::Fail(error)),
567                        };
568                        Ok(vec![NewFact {
569                            kind: "model.turn".into(),
570                            key: format!("model:{turn}:{cycle}"),
571                            payload: serde_json::to_value(&decided)
572                                .map_err(|error| Exit::Die(error.to_string()))?,
573                        }])
574                    }
575                })
576                .retry(Schedule {
577                    remaining: attempts.saturating_sub(1),
578                    delay: std::time::Duration::ZERO,
579                }),
580            }]
581        }
582        CodingPhase::Tool => {
583            let Some(call) = state.pending_call.clone() else {
584                return Vec::new();
585            };
586            let turn = state.turn;
587            let cycle = state.cycle;
588            vec![Transition {
589                key: format!("tool:{turn}:{cycle}:{}", call.id),
590                run: Effect::from_async(move |services: Arc<CodingServices>, _cancel| {
591                    let call = call.clone();
592                    async move {
593                        let output = services.tools.run(call.clone()).await?;
594                        let output = match output {
595                            Value::String(text) => text,
596                            other => other.to_string(),
597                        };
598                        Ok(vec![NewFact {
599                            kind: "tool.result".into(),
600                            key: format!("tool-result:{turn}:{cycle}:{}", call.id),
601                            payload: json!({ "toolCallId": call.id, "ok": true, "output": output }),
602                        }])
603                    }
604                }),
605            }]
606        }
607        CodingPhase::Deny => {
608            let Some(call) = state.pending_call.clone() else {
609                return Vec::new();
610            };
611            vec![Transition {
612                key: format!("budget:{}:{}:{}", state.turn, state.cycle, call.id),
613                run: Effect::succeed(vec![NewFact {
614                    kind: "budget.denied".into(),
615                    key: format!("budget-denied:{}:{}:{}", state.turn, state.cycle, call.id),
616                    payload: json!({ "toolCallId": call.id }),
617                }]),
618            }]
619        }
620        CodingPhase::Idle | CodingPhase::Confirm | CodingPhase::Question | CodingPhase::Done => {
621            Vec::new()
622        }
623    }
624}
625
626/// Infer-loop scheduler (model / tool / compact / budget deny phases).
627pub fn coding_scheduler(config: HarnessConfig) -> ErasedComponent<CodingServices, CodingView> {
628    let step_config = config.clone();
629    let output_config = config;
630    component(
631        SchedulerState::new,
632        move |state, fact| step(state, fact, &step_config),
633        move |state| (view_of(state), transitions_of(state, &output_config)),
634    )
635}
636
637/// Stock Meta Harness: `system + tools + budget + compact + infer([scheduler])`.
638///
639/// Equivalent to the pre-composition three-component actor for control
640/// behavior; budget/compact also appear as explicit tree parts.
641pub fn coding_actor(config: HarnessConfig) -> Actor<CodingServices, CodingView> {
642    let spec = crate::compose::MetaHarnessSpec {
643        name: "a3s-code",
644        budget: config.budget(),
645        compact_after_chars: config.compact_after_chars(),
646        step_limit: config.step_limit(),
647        model_attempts: config.model_attempts(),
648        system: config.system().to_vec(),
649        tools: config.tools().to_vec(),
650        tool_round_cap: config.tool_round_cap(),
651        parts: Vec::new(),
652    };
653    crate::compose::HarnessGraph::from_spec(spec, || coding_scheduler(config.clone())).into_actor()
654}
655
656pub fn message_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
657    NewFact {
658        kind: "user.message".into(),
659        key: key.into(),
660        payload: json!({ "text": text.into() }),
661    }
662}
663
664pub fn confirm_fact(
665    key: impl Into<String>,
666    tool_call_id: impl Into<String>,
667    approved: bool,
668) -> NewFact {
669    NewFact {
670        kind: "confirmation.answered".into(),
671        key: key.into(),
672        payload: json!({ "tool_call_id": tool_call_id.into(), "approved": approved }),
673    }
674}
675
676pub fn answer_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
677    NewFact {
678        kind: "question.answered".into(),
679        key: key.into(),
680        payload: json!({ "text": text.into() }),
681    }
682}
683
684pub async fn ingest_coding(
685    actor: &Actor<CodingServices, CodingView>,
686    log: &dyn crate::log::LogStore,
687    services: Arc<CodingServices>,
688    thread_id: &str,
689    fact: NewFact,
690    limit: u32,
691) -> Result<Settlement<CodingView>, Exit<ActorError>> {
692    ingest(actor, log, services, thread_id, fact, limit).await
693}
694
695pub async fn resume_coding(
696    actor: &Actor<CodingServices, CodingView>,
697    log: &dyn crate::log::LogStore,
698    services: Arc<CodingServices>,
699    thread_id: &str,
700    limit: u32,
701) -> Result<Settlement<CodingView>, Exit<ActorError>> {
702    resume(actor, log, services, thread_id, limit).await
703}