Skip to main content

a3s_effect/
compose.rs

1//! Meta Harness composition: mount Moore components on one fact log.
2//!
3//! Hosts assemble `system`, `tools`, `budget`, `compact`, and `infer([...])`
4//! the way Tardigrade mounts `components: [...]`. The product default remains
5//! [`coding_actor`](crate::coding::coding_actor), which is sugar for the stock
6//! tree. Nested `infer` mounts namespace child transition keys so siblings
7//! cannot collide (`DuplicateTransition`). The stock Infer part mounts the
8//! scheduler **without** a key prefix so existing fact-log causes stay stable.
9
10use std::sync::Arc;
11
12use crate::actor::{component, Actor, ErasedComponent, Transition};
13use crate::coding::{merge_coding_view, CodingServices, CodingView, HarnessConfig, ToolSpec};
14use crate::error::ActorError;
15
16/// Documented merge slots for the coding Meta Harness view.
17///
18/// Same shape as [`CodingView`]. Prefer this name in composition APIs.
19pub type HarnessView = CodingView;
20
21/// Prefix every enabled transition key.
22pub fn namespace_transitions<S>(
23    prefix: &str,
24    transitions: Vec<Transition<S>>,
25) -> Vec<Transition<S>> {
26    let prefix = prefix.trim_matches('/');
27    transitions
28        .into_iter()
29        .map(|transition| Transition {
30            key: if prefix.is_empty() {
31                transition.key
32            } else {
33                format!("{prefix}/{}", transition.key)
34            },
35            run: transition.run,
36        })
37        .collect()
38}
39
40/// Wrap a component so every transition it enables carries `prefix/`.
41pub fn with_key_namespace<S, V>(
42    prefix: impl Into<String>,
43    inner: ErasedComponent<S, V>,
44) -> ErasedComponent<S, V>
45where
46    S: Send + Sync + 'static,
47    V: 'static,
48{
49    let prefix = prefix.into();
50    let project = inner.project;
51    ErasedComponent {
52        project: Arc::new(move |facts| {
53            let (view, transitions) = (project)(facts)?;
54            Ok((view, namespace_transitions(&prefix, transitions)))
55        }),
56    }
57}
58
59/// Mount several components as one. Views merge left-to-right with `merge`;
60/// transitions are concatenated then namespaced under `prefix`.
61pub fn mount<S, V>(
62    prefix: impl Into<String>,
63    children: Vec<ErasedComponent<S, V>>,
64    merge: impl Fn(Vec<V>) -> V + Send + Sync + 'static,
65) -> ErasedComponent<S, V>
66where
67    S: Send + Sync + 'static,
68    V: 'static,
69{
70    let prefix = prefix.into();
71    let merge = Arc::new(merge);
72    let children = Arc::new(children);
73    ErasedComponent {
74        project: Arc::new(move |facts| {
75            let mut views = Vec::with_capacity(children.len());
76            let mut transitions = Vec::new();
77            for child in children.iter() {
78                let (view, enabled) = (child.project)(facts)?;
79                views.push(view);
80                transitions.extend(enabled);
81            }
82            let view = (merge)(views);
83            Ok((view, namespace_transitions(&prefix, transitions)))
84        }),
85    }
86}
87
88/// Tardigrade-style nested `infer([...])` with a key-space prefix.
89///
90/// Use for host-authored subtrees. The stock Meta Harness Infer part mounts
91/// the scheduler without this prefix so persisted cause keys stay stable.
92pub fn infer(
93    children: Vec<ErasedComponent<CodingServices, HarnessView>>,
94) -> ErasedComponent<CodingServices, HarnessView> {
95    mount("infer", children, merge_coding_view)
96}
97
98/// System-prompt slot component.
99pub fn system(prompts: Vec<String>) -> ErasedComponent<CodingServices, HarnessView> {
100    component(
101        || (),
102        |state, _fact| state,
103        move |_state| {
104            (
105                CodingView {
106                    system: prompts.clone(),
107                    ..CodingView::empty()
108                },
109                Vec::new(),
110            )
111        },
112    )
113}
114
115/// Tool-catalog slot component.
116pub fn tools(specs: Vec<ToolSpec>) -> ErasedComponent<CodingServices, HarnessView> {
117    component(
118        || (),
119        |state, _fact| state,
120        move |_state| {
121            (
122                CodingView {
123                    tools: specs.clone(),
124                    ..CodingView::empty()
125                },
126                Vec::new(),
127            )
128        },
129    )
130}
131
132/// First-class budget policy slot (compose-tree presence + view observability).
133///
134/// The stock infer scheduler still enforces the numeric limit from
135/// [`HarnessConfig`].
136pub fn budget(limit: u32) -> ErasedComponent<CodingServices, HarnessView> {
137    component(
138        move || limit,
139        |state, _fact| state,
140        move |state| {
141            (
142                CodingView {
143                    tool_budget: Some(*state),
144                    ..CodingView::empty()
145                },
146                Vec::new(),
147            )
148        },
149    )
150}
151
152/// First-class compaction threshold slot.
153pub fn compact(after_chars: usize) -> ErasedComponent<CodingServices, HarnessView> {
154    component(
155        move || after_chars,
156        |state, _fact| state,
157        move |state| {
158            (
159                CodingView {
160                    compact_after_chars: Some(*state),
161                    ..CodingView::empty()
162                },
163                Vec::new(),
164            )
165        },
166    )
167}
168
169/// Declarative Meta Harness recipe admitted by Code / SDKs.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct MetaHarnessSpec {
172    pub name: &'static str,
173    pub budget: u32,
174    pub compact_after_chars: usize,
175    pub step_limit: u32,
176    pub model_attempts: u32,
177    pub system: Vec<String>,
178    pub tools: Vec<ToolSpec>,
179    pub tool_round_cap: Option<u32>,
180    /// Ordered part ids. Empty means the stock tree
181    /// `system + tools + budget + compact + infer(scheduler)`.
182    pub parts: Vec<HarnessPartId>,
183}
184
185/// Stock part identifiers for compose recipes.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum HarnessPartId {
189    System,
190    Tools,
191    Budget,
192    Compact,
193    Infer,
194}
195
196impl Default for MetaHarnessSpec {
197    fn default() -> Self {
198        Self {
199            name: "a3s-code",
200            budget: 8,
201            compact_after_chars: 1_000_000,
202            step_limit: 32,
203            model_attempts: 2,
204            system: Vec::new(),
205            tools: Vec::new(),
206            tool_round_cap: None,
207            parts: Vec::new(),
208        }
209    }
210}
211
212impl MetaHarnessSpec {
213    pub fn into_config(self) -> Result<HarnessConfig, ActorError> {
214        let mut config = HarnessConfig::new(
215            self.budget,
216            self.compact_after_chars,
217            self.step_limit,
218            self.model_attempts,
219            self.system,
220            self.tools,
221        )?;
222        if let Some(cap) = self.tool_round_cap {
223            config = config.with_tool_round_cap(cap);
224        }
225        Ok(config)
226    }
227
228    pub fn resolved_parts(&self) -> Vec<HarnessPartId> {
229        if self.parts.is_empty() {
230            vec![
231                HarnessPartId::System,
232                HarnessPartId::Tools,
233                HarnessPartId::Budget,
234                HarnessPartId::Compact,
235                HarnessPartId::Infer,
236            ]
237        } else {
238            self.parts.clone()
239        }
240    }
241}
242
243/// Build an actor from an ordered list of already-constructed components.
244pub fn compose_coding_actor(
245    name: &'static str,
246    components: Vec<ErasedComponent<CodingServices, HarnessView>>,
247) -> Actor<CodingServices, HarnessView> {
248    Actor::new(name, components, merge_coding_view)
249}
250
251fn compose_meta_harness_with<F>(
252    spec: MetaHarnessSpec,
253    mut make_scheduler: F,
254) -> Actor<CodingServices, HarnessView>
255where
256    F: FnMut() -> ErasedComponent<CodingServices, HarnessView>,
257{
258    let name = spec.name;
259    let parts = spec.resolved_parts();
260    let system_prompts = spec.system.clone();
261    let tool_specs = spec.tools.clone();
262    let budget_limit = spec.budget;
263    let compact_after = spec.compact_after_chars;
264
265    let mut components = Vec::new();
266    for part in parts {
267        match part {
268            HarnessPartId::System => components.push(system(system_prompts.clone())),
269            HarnessPartId::Tools => components.push(tools(tool_specs.clone())),
270            HarnessPartId::Budget => components.push(budget(budget_limit)),
271            HarnessPartId::Compact => components.push(compact(compact_after)),
272            // Stock Infer mounts the scheduler without a key prefix so cause
273            // keys (`infer:1:0`, `compact:1`, …) remain stable across resumes.
274            HarnessPartId::Infer => components.push(make_scheduler()),
275        }
276    }
277    compose_coding_actor(name, components)
278}
279
280/// Admitted harness graph: stock coding actor or a composed tree.
281pub struct HarnessGraph {
282    actor: Actor<CodingServices, HarnessView>,
283}
284
285impl HarnessGraph {
286    pub fn from_actor(actor: Actor<CodingServices, HarnessView>) -> Self {
287        Self { actor }
288    }
289
290    pub fn coding(config: HarnessConfig) -> Self {
291        Self {
292            actor: crate::coding::coding_actor(config),
293        }
294    }
295
296    /// Compose from a declarative spec. `make_scheduler` is called once per
297    /// `Infer` part (stock recipes include Infer exactly once).
298    pub fn from_spec<F>(spec: MetaHarnessSpec, mut make_scheduler: F) -> Self
299    where
300        F: FnMut() -> ErasedComponent<CodingServices, HarnessView>,
301    {
302        Self {
303            actor: compose_meta_harness_with(spec, &mut make_scheduler),
304        }
305    }
306
307    pub fn actor(&self) -> &Actor<CodingServices, HarnessView> {
308        &self.actor
309    }
310
311    pub fn into_actor(self) -> Actor<CodingServices, HarnessView> {
312        self.actor
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::coding::coding_scheduler;
320    use crate::effect::Effect;
321
322    #[test]
323    fn namespace_prefixes_keys() {
324        let transitions = namespace_transitions(
325            "infer",
326            vec![Transition::<()> {
327                key: "compact:1".into(),
328                run: Effect::succeed(Vec::new()),
329            }],
330        );
331        assert_eq!(transitions[0].key, "infer/compact:1");
332    }
333
334    #[test]
335    fn stock_spec_parts_include_budget_and_compact() {
336        let spec = MetaHarnessSpec::default();
337        assert_eq!(
338            spec.resolved_parts(),
339            vec![
340                HarnessPartId::System,
341                HarnessPartId::Tools,
342                HarnessPartId::Budget,
343                HarnessPartId::Compact,
344                HarnessPartId::Infer,
345            ]
346        );
347    }
348
349    #[test]
350    fn compose_meta_harness_builds() {
351        let config = HarnessConfig::new(
352            2,
353            100,
354            8,
355            1,
356            vec!["sys".into()],
357            vec![ToolSpec {
358                name: "read".into(),
359                description: "r".into(),
360            }],
361        )
362        .unwrap();
363        let spec = MetaHarnessSpec {
364            name: "test",
365            budget: 2,
366            compact_after_chars: 100,
367            step_limit: 8,
368            model_attempts: 1,
369            system: vec!["sys".into()],
370            tools: config.tools().to_vec(),
371            tool_round_cap: None,
372            parts: Vec::new(),
373        };
374        let graph = HarnessGraph::from_spec(spec, || coding_scheduler(config.clone()));
375        assert_eq!(graph.actor().name, "test");
376    }
377
378    #[test]
379    fn custom_compose_includes_host_component() {
380        let host = component(
381            || (),
382            |state, _fact| state,
383            |_state| {
384                (
385                    CodingView {
386                        system: vec!["host-slot".into()],
387                        ..CodingView::empty()
388                    },
389                    Vec::new(),
390                )
391            },
392        );
393        let actor = compose_coding_actor(
394            "custom",
395            vec![
396                system(vec!["base".into()]),
397                tools(Vec::new()),
398                budget(1),
399                compact(64),
400                host,
401            ],
402        );
403        assert_eq!(actor.name, "custom");
404    }
405}