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