Skip to main content

agentd/runtime/
env.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **environment data** a system-prompt template renders, and the built-in
3//! default template that renders it.
4//!
5//! The runtime exposes what it knows as *data* and the shape lives in a
6//! template, so the same knobs an operator has (loops, conditions, limits,
7//! field access) are the ones the built-in default uses. Nothing the default
8//! renders is reachable only from Rust.
9//!
10//! ## The ordering is a cache decision, not a taste one
11//!
12//! Providers cache on the literal prefix of a request, so a section that
13//! changes between turns invalidates the cache for everything after it. The
14//! default template is therefore ordered by VOLATILITY:
15//!
16//! 1. persona + instruction — change only on reload
17//! 2. workflows, services, streams, subagent templates — configuration
18//! 3. skills catalogue — configuration, plus per-context bodies
19//! 4. peers, signals, memory — live state, changing turn to turn
20//!
21//! A custom template that puts `{{#each signals.waiting}}` near the top will
22//! still work; it will just miss the cache on most turns, which on a busy
23//! instance is real money.
24
25use super::reactor::Runtime;
26use crate::engine::template::Data;
27use serde_json::{Value, json};
28
29/// How many entries of a list the DATA carries. The default template renders
30/// what it is given rather than slicing (slicing is CEL, and the default must
31/// work on a build without the `cel` feature) — so the cap lives here, where
32/// it also bounds the work of building the data at all.
33const CAP_LIST: usize = 16;
34const CAP_PEERS: usize = 24;
35
36/// The built-in template. It is written in the same language an operator
37/// gets, and `agentd --context-template` prints it — so overriding starts
38/// from a copy rather than from a guess.
39pub const DEFAULT_TEMPLATE: &str = r#"You are {{instance}}, an autonomous, durable agent (agentd). You act by calling tools and reply when done.
40{{#if tools.internal_text}}Internal tools ({{tools.internal_text}}) are executed by your runtime and are durable; other tools come from connected MCP servers.
41{{/if}}Be concise and factual; never invent tool results.
42{{#if instruction}}
43## Instruction
44{{instruction}}
45{{/if}}{{#if extra}}
46{{extra}}
47{{/if}}{{#if workflows}}
48## Workflows
49{{#each workflows}}- {{this.name}}{{#if this.description}}: {{this.description}}{{/if}}
50{{/each}}{{/if}}{{#if services}}
51## Services (the external services this deployment may use)
52{{#each services}}- {{this.name}}{{#if this.tags_text}} [{{this.tags_text}}]{{/if}}{{#if this.tools_text}} — tools: {{this.tools_text}}{{/if}}{{#if this.rate}} (rate {{this.rate}}){{/if}}
53{{/each}}{{#if egress_closed}}Egress is CLOSED: only these services are reachable.
54{{/if}}{{/if}}{{#if streams}}
55## Streams (durable events; publish with an emit step, consume with a stream start)
56{{#each streams}}- {{this}}
57{{/each}}{{/if}}{{#if templates}}
58## Subagent templates (spawn with subagent.run {template, params})
59{{#each templates}}- {{this.name}} ({{this.tier}}){{#if this.params_text}} — params: {{this.params_text}}{{/if}}
60{{/each}}{{/if}}{{#if skills}}
61{{skills}}
62{{/if}}{{#if peers}}
63## Peers (agents reachable with a2a.send / a2a.delegate)
64{{#each peers}}- {{this.name}}{{#if this.note}} ({{this.note}}){{/if}}
65{{/each}}{{/if}}{{#if signals.any}}
66## Signals (durable coordination; deliver with workflow.signal)
67{{#each signals.waiting}}- waiting: {{this.name}} (run {{this.run}}, step {{this.step}})
68{{/each}}{{#each signals.recent}}- fired recently: {{this}}
69{{/each}}{{/if}}{{#if memory.keys_text}}
70## Memory
71Keys you can read with memory.get: {{memory.keys_text}}
72{{/if}}"#;
73
74impl Runtime {
75    /// Everything a prompt template may read. Cheap to build (the memory-key
76    /// hint is the only store read, and it is bounded).
77    pub(crate) fn prompt_data(
78        &self,
79        ctx: Option<&crate::context::ContextState>,
80        extra: Option<&str>,
81    ) -> Data {
82        let mut d = Data::new();
83        d.insert("instance".into(), json!(self.instance));
84        d.insert(
85            "instruction".into(),
86            json!(self.instruction.text.trim().to_string()),
87        );
88        d.insert("extra".into(), json!(extra.unwrap_or("")));
89        let internal = self.granted_internal_tools();
90        d.insert(
91            "tools".into(),
92            json!({"internal": internal, "internal_text": internal.join(", ")}),
93        );
94
95        // --- configuration-derived (stable across turns) --------------------
96        d.insert(
97            "workflows".into(),
98            Value::Array(
99                self.workflows
100                    .values()
101                    .map(|w| json!({"name": w.name, "description": w.description}))
102                    .collect(),
103            ),
104        );
105        d.insert(
106            "services".into(),
107            Value::Array(
108                self.settings
109                    .services
110                    .iter()
111                    .take(CAP_LIST)
112                    .map(|(name, e)| {
113                        let tags: Vec<&str> = e
114                            .tags
115                            .values()
116                            .flatten()
117                            .map(String::as_str)
118                            .collect::<std::collections::BTreeSet<_>>()
119                            .into_iter()
120                            .collect();
121                        let tools: Vec<String> = e.allow.clone().unwrap_or_default();
122                        json!({"name": name, "kind": e.kind.as_str(),
123                               "tags": tags, "tags_text": tags.join(", "),
124                               "tools": tools, "tools_text": tools.join(", "),
125                               "rate": e.rate})
126                    })
127                    .collect(),
128            ),
129        );
130        d.insert(
131            "egress_closed".into(),
132            json!(self.settings.security.egress == crate::config::v2::Egress::Closed),
133        );
134        d.insert(
135            "streams".into(),
136            Value::Array(
137                self.settings
138                    .streams
139                    .keys()
140                    .take(CAP_LIST)
141                    .map(|k| json!(k))
142                    .collect(),
143            ),
144        );
145        d.insert(
146            "templates".into(),
147            Value::Array(
148                self.settings
149                    .subagents
150                    .templates
151                    .iter()
152                    .take(CAP_LIST)
153                    .map(|(name, t)| {
154                        let tier = if t
155                            .instruction
156                            .lines()
157                            .any(|l| l.trim_start().starts_with(":::"))
158                        {
159                            "instance"
160                        } else {
161                            "flat"
162                        };
163                        let params: Vec<String> = t
164                            .params
165                            .iter()
166                            .map(|(p, spec)| {
167                                if spec.required {
168                                    format!("{p} (required)")
169                                } else {
170                                    p.clone()
171                                }
172                            })
173                            .collect();
174                        json!({"name": name, "tier": tier,
175                               "params": params, "params_text": params.join(", ")})
176                    })
177                    .collect(),
178            ),
179        );
180        // The skills catalogue + this context's loaded bodies stay pre-rendered
181        // prose: they are authored text, not a list to reshape.
182        let mut skills = String::new();
183        if let Some(cat) = self.skills.render_catalogue() {
184            skills.push_str(&cat);
185        }
186        if let Some(c) = ctx {
187            let bodies: Vec<&crate::context::skills::SkillBody> = c
188                .skills
189                .iter()
190                .filter_map(|r| self.skills.body(&r.hash))
191                .collect();
192            if let Some(b) = crate::context::skills::render_bodies(&bodies) {
193                if !skills.is_empty() {
194                    skills.push('\n');
195                }
196                skills.push_str(&b);
197            }
198        }
199        d.insert("skills".into(), json!(skills.trim_end()));
200
201        // --- live state (volatile; last, so the prefix above stays cached) ---
202        let mut peers: Vec<Value> = self
203            .settings
204            .a2a
205            .peers
206            .iter()
207            .map(|p| json!({"name": p.name, "note": Value::Null}))
208            .collect();
209        for rec in self.subagents.values() {
210            if rec.tier.as_deref() == Some("instance")
211                && !super::reactor::is_terminal_status(&rec.status)
212            {
213                peers.push(json!({"name": rec.handle, "note": format!(
214                    "instance child of template '{}', {}",
215                    rec.template.as_deref().unwrap_or("?"), rec.status)}));
216            }
217        }
218        peers.truncate(CAP_PEERS);
219        d.insert("peers".into(), Value::Array(peers));
220
221        let mut waiting: Vec<Value> = Vec::new();
222        for (rid, run) in &self.runs {
223            for (sid, st) in &run.steps {
224                if st.status == crate::engine::run::StepStatus::Suspended
225                    && let Some(w) = &st.wait
226                    && w["kind"] == "signal"
227                    && let Some(name) = w["signal"].as_str()
228                {
229                    waiting.push(json!({"name": name, "run": rid, "step": sid}));
230                }
231            }
232        }
233        waiting.truncate(CAP_LIST);
234        let recent: Vec<Value> = self
235            .recent_signals
236            .keys()
237            .rev()
238            .take(8)
239            .map(|k| json!(k))
240            .collect();
241        let any = !waiting.is_empty() || !recent.is_empty();
242        d.insert(
243            "signals".into(),
244            json!({"waiting": waiting, "recent": recent, "any": any}),
245        );
246
247        let keys = self.memory_keys_hint().unwrap_or_default();
248        d.insert(
249            "memory".into(),
250            json!({"keys": keys, "keys_text": keys.join(", ")}),
251        );
252        d
253    }
254
255    /// The internal tools this instance ACTUALLY grants, as tool-name families.
256    ///
257    /// Derived from the live registry rather than a fixed list, so an instance
258    /// that narrows `agent.tools.internal` is never briefed on a family it
259    /// would then be refused — the persona and the gate agree by construction.
260    fn granted_internal_tools(&self) -> Vec<String> {
261        let mut families: Vec<String> = Vec::new();
262        for t in self.registry.iter() {
263            if t.class != crate::registry::ToolClass::Internal
264                || t.disabled
265                || !self
266                    .registry
267                    .allowed(&crate::registry::Caller::Root, &t.name)
268            {
269                continue;
270            }
271            let fam = match t.name.split_once('.') {
272                Some((head, _)) => format!("{head}.*"),
273                None => t.name.clone(),
274            };
275            if !families.contains(&fam) {
276                families.push(fam);
277            }
278        }
279        families.sort();
280        families
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn the_default_template_needs_no_cel() {
290        // The default must render on a build WITHOUT `--features cel`, or
291        // every such build silently ships an empty system prompt. Caps and
292        // joins therefore live in the data, not in template expressions.
293        let t = crate::context::prompt::Template::parse(DEFAULT_TEMPLATE)
294            .expect("the built-in template parses");
295        assert!(
296            !t.needs_cel,
297            "the default template must use bare paths only — it renders on every build"
298        );
299        assert!(
300            t.reads("instruction"),
301            "the default carries standing policy"
302        );
303    }
304
305    #[test]
306    fn the_default_renders_stable_before_volatile() {
307        // Providers cache on the literal prefix: a block that changes every
308        // turn invalidates everything after it.
309        let pos = |needle: &str| {
310            DEFAULT_TEMPLATE
311                .find(needle)
312                .unwrap_or_else(|| panic!("default template lost {needle}"))
313        };
314        assert!(pos("## Instruction") < pos("## Services"));
315        assert!(pos("## Services") < pos("## Peers"));
316        assert!(pos("## Peers") < pos("## Signals"));
317        assert!(pos("## Signals") < pos("## Memory"));
318    }
319}