Skip to main content

repl_core/dsl/
reasoning_builtins.rs

1//! Core reasoning builtins for the DSL
2//!
3//! Provides async builtin functions that bridge the DSL with the
4//! reasoning loop infrastructure: `reason`, `llm_call`, `parse_json`,
5//! `delegate`, and `tool_call`.
6
7use crate::dsl::evaluator::DslValue;
8use crate::error::{ReplError, Result};
9use std::collections::HashMap;
10use std::sync::Arc;
11use symbi_runtime::reasoning::agent_registry::AgentRegistry;
12use symbi_runtime::reasoning::inference::InferenceProvider;
13
14/// Shared state for async reasoning builtins.
15#[derive(Clone, Default)]
16pub struct ReasoningBuiltinContext {
17    /// Inference provider for LLM calls.
18    pub provider: Option<Arc<dyn InferenceProvider>>,
19    /// Agent registry for multi-agent composition.
20    pub agent_registry: Option<Arc<AgentRegistry>>,
21}
22
23/// Execute the `reason` builtin: runs a full reasoning loop.
24///
25/// Arguments (positional or named):
26/// - system: string — system prompt
27/// - user: string — user message
28/// - max_iterations: integer (optional, default 10)
29/// - max_tokens: integer (optional, default 100000)
30///
31/// Returns a map with keys: response, iterations, total_tokens, termination_reason.
32pub async fn builtin_reason(args: &[DslValue], ctx: &ReasoningBuiltinContext) -> Result<DslValue> {
33    let provider = ctx
34        .provider
35        .as_ref()
36        .ok_or_else(|| ReplError::Execution("No inference provider configured".into()))?;
37
38    let (system, user, max_iterations, max_tokens) = parse_reason_args(args)?;
39
40    use symbi_runtime::reasoning::circuit_breaker::CircuitBreakerRegistry;
41    use symbi_runtime::reasoning::context_manager::DefaultContextManager;
42    use symbi_runtime::reasoning::conversation::{Conversation, ConversationMessage};
43    use symbi_runtime::reasoning::executor::DefaultActionExecutor;
44    use symbi_runtime::reasoning::loop_types::{BufferedJournal, LoopConfig};
45    use symbi_runtime::reasoning::policy_bridge::DefaultPolicyGate;
46    use symbi_runtime::reasoning::reasoning_loop::ReasoningLoopRunner;
47    use symbi_runtime::types::AgentId;
48
49    let runner = ReasoningLoopRunner {
50        provider: Arc::clone(provider),
51        policy_gate: Arc::new(DefaultPolicyGate::permissive()),
52        executor: Arc::new(DefaultActionExecutor::default()),
53        context_manager: Arc::new(DefaultContextManager::default()),
54        circuit_breakers: Arc::new(CircuitBreakerRegistry::default()),
55        journal: Arc::new(BufferedJournal::new(1000)),
56        knowledge_bridge: None,
57    };
58
59    let mut conv = Conversation::with_system(&system);
60    conv.push(ConversationMessage::user(&user));
61
62    let config = LoopConfig {
63        max_iterations,
64        max_total_tokens: max_tokens,
65        ..Default::default()
66    };
67
68    let result = runner.run(AgentId::new(), conv, config).await;
69
70    let mut map = HashMap::new();
71    map.insert("response".to_string(), DslValue::String(result.output));
72    map.insert(
73        "iterations".to_string(),
74        DslValue::Integer(result.iterations as i64),
75    );
76    map.insert(
77        "total_tokens".to_string(),
78        DslValue::Integer(result.total_usage.total_tokens as i64),
79    );
80    map.insert(
81        "termination_reason".to_string(),
82        DslValue::String(format!("{:?}", result.termination_reason)),
83    );
84
85    Ok(DslValue::Map(map))
86}
87
88/// Execute the `llm_call` builtin: one-shot LLM call.
89///
90/// Arguments:
91/// - prompt: string — the prompt to send
92/// - model: string (optional) — model override
93/// - temperature: number (optional)
94/// - max_tokens: integer (optional)
95///
96/// Returns a string.
97pub async fn builtin_llm_call(
98    args: &[DslValue],
99    ctx: &ReasoningBuiltinContext,
100) -> Result<DslValue> {
101    let provider = ctx
102        .provider
103        .as_ref()
104        .ok_or_else(|| ReplError::Execution("No inference provider configured".into()))?;
105
106    let prompt = match args.first() {
107        Some(DslValue::String(s)) => s.clone(),
108        Some(DslValue::Map(map)) => map
109            .get("prompt")
110            .and_then(|v| match v {
111                DslValue::String(s) => Some(s.clone()),
112                _ => None,
113            })
114            .ok_or_else(|| ReplError::Execution("llm_call requires 'prompt' argument".into()))?,
115        _ => {
116            return Err(ReplError::Execution(
117                "llm_call requires a string prompt".into(),
118            ))
119        }
120    };
121
122    use symbi_runtime::reasoning::conversation::{Conversation, ConversationMessage};
123    use symbi_runtime::reasoning::inference::InferenceOptions;
124
125    let mut conv = Conversation::new();
126    conv.push(ConversationMessage::user(&prompt));
127
128    let options = InferenceOptions::default();
129    let response = provider
130        .complete(&conv, &options)
131        .await
132        .map_err(|e| ReplError::Execution(format!("LLM call failed: {}", e)))?;
133
134    Ok(DslValue::String(response.content))
135}
136
137/// Execute the `parse_json` builtin: parse a string as JSON.
138///
139/// Arguments:
140/// - text: string — the JSON text to parse
141///
142/// Returns a DslValue (Map, List, String, Number, Boolean, or Null).
143pub fn builtin_parse_json(args: &[DslValue]) -> Result<DslValue> {
144    let text = match args.first() {
145        Some(DslValue::String(s)) => s,
146        _ => {
147            return Err(ReplError::Execution(
148                "parse_json requires a string argument".into(),
149            ))
150        }
151    };
152
153    let value: serde_json::Value = serde_json::from_str(text)
154        .map_err(|e| ReplError::Execution(format!("JSON parse error: {}", e)))?;
155
156    Ok(json_to_dsl_value(&value))
157}
158
159/// Execute the `tool_call` builtin: explicit tool invocation.
160///
161/// Arguments:
162/// - name: string — tool name
163/// - args: map — tool arguments
164///
165/// Returns the tool result as a string.
166pub async fn builtin_tool_call(
167    args: &[DslValue],
168    _ctx: &ReasoningBuiltinContext,
169) -> Result<DslValue> {
170    let (name, arguments) = match args {
171        [DslValue::String(name), DslValue::Map(args_map)] => {
172            let json_args: serde_json::Map<String, serde_json::Value> = args_map
173                .iter()
174                .map(|(k, v)| (k.clone(), v.to_json()))
175                .collect();
176            (
177                name.clone(),
178                serde_json::Value::Object(json_args).to_string(),
179            )
180        }
181        [DslValue::String(name), DslValue::String(args_str)] => (name.clone(), args_str.clone()),
182        [DslValue::String(name)] => (name.clone(), "{}".to_string()),
183        _ => {
184            return Err(ReplError::Execution(
185                "tool_call requires (name: string, args?: map|string)".into(),
186            ))
187        }
188    };
189
190    // In a full setup, this would go through ToolInvocationEnforcer.
191    // For now, return a structured result indicating the tool call was made.
192    let mut result = HashMap::new();
193    result.insert("tool".to_string(), DslValue::String(name));
194    result.insert("arguments".to_string(), DslValue::String(arguments));
195    result.insert(
196        "status".to_string(),
197        DslValue::String("executed".to_string()),
198    );
199
200    Ok(DslValue::Map(result))
201}
202
203/// Execute the `delegate` builtin: send a message to another agent.
204///
205/// Arguments:
206/// - agent: string — agent name
207/// - message: string — message to send
208/// - timeout: duration (optional)
209///
210/// Returns the agent's response as a string.
211pub async fn builtin_delegate(
212    args: &[DslValue],
213    ctx: &ReasoningBuiltinContext,
214) -> Result<DslValue> {
215    let (agent_name, message) = match args {
216        [DslValue::String(agent), DslValue::String(msg)] => (agent.clone(), msg.clone()),
217        [DslValue::Map(map)] => {
218            let agent = map
219                .get("agent")
220                .and_then(|v| match v {
221                    DslValue::String(s) => Some(s.clone()),
222                    _ => None,
223                })
224                .ok_or_else(|| ReplError::Execution("delegate requires 'agent' argument".into()))?;
225            let msg = map
226                .get("message")
227                .and_then(|v| match v {
228                    DslValue::String(s) => Some(s.clone()),
229                    _ => None,
230                })
231                .ok_or_else(|| {
232                    ReplError::Execution("delegate requires 'message' argument".into())
233                })?;
234            (agent, msg)
235        }
236        _ => {
237            return Err(ReplError::Execution(
238                "delegate requires (agent: string, message: string)".into(),
239            ))
240        }
241    };
242
243    // Use inference provider to simulate delegation (each agent is a separate conversation)
244    let provider = ctx
245        .provider
246        .as_ref()
247        .ok_or_else(|| ReplError::Execution("No inference provider configured".into()))?;
248
249    use symbi_runtime::reasoning::conversation::{Conversation, ConversationMessage};
250    use symbi_runtime::reasoning::inference::InferenceOptions;
251
252    let mut conv = Conversation::with_system(format!(
253        "You are agent '{}'. Respond to the delegated task.",
254        agent_name
255    ));
256    conv.push(ConversationMessage::user(&message));
257
258    let response = provider
259        .complete(&conv, &InferenceOptions::default())
260        .await
261        .map_err(|e| {
262            ReplError::Execution(format!("Delegation to '{}' failed: {}", agent_name, e))
263        })?;
264
265    Ok(DslValue::String(response.content))
266}
267
268// --- Helper functions ---
269
270fn parse_reason_args(args: &[DslValue]) -> Result<(String, String, u32, u32)> {
271    match args {
272        // Named arguments via map
273        [DslValue::Map(map)] => {
274            let system = map
275                .get("system")
276                .and_then(|v| match v {
277                    DslValue::String(s) => Some(s.clone()),
278                    _ => None,
279                })
280                .ok_or_else(|| ReplError::Execution("reason requires 'system' argument".into()))?;
281            let user = map
282                .get("user")
283                .and_then(|v| match v {
284                    DslValue::String(s) => Some(s.clone()),
285                    _ => None,
286                })
287                .ok_or_else(|| ReplError::Execution("reason requires 'user' argument".into()))?;
288            let max_iterations = map
289                .get("max_iterations")
290                .and_then(|v| match v {
291                    DslValue::Integer(i) => Some(*i as u32),
292                    DslValue::Number(n) => Some(*n as u32),
293                    _ => None,
294                })
295                .unwrap_or(10);
296            let max_tokens = map
297                .get("max_tokens")
298                .and_then(|v| match v {
299                    DslValue::Integer(i) => Some(*i as u32),
300                    DslValue::Number(n) => Some(*n as u32),
301                    _ => None,
302                })
303                .unwrap_or(100_000);
304            Ok((system, user, max_iterations, max_tokens))
305        }
306        // Positional: system, user
307        [DslValue::String(system), DslValue::String(user)] => {
308            Ok((system.clone(), user.clone(), 10, 100_000))
309        }
310        // Positional: system, user, max_iterations
311        [DslValue::String(system), DslValue::String(user), DslValue::Integer(max_iter)] => {
312            Ok((system.clone(), user.clone(), *max_iter as u32, 100_000))
313        }
314        _ => Err(ReplError::Execution(
315            "reason requires (system: string, user: string, [max_iterations?, max_tokens?])".into(),
316        )),
317    }
318}
319
320/// Convert a serde_json::Value to a DslValue.
321pub fn json_to_dsl_value(value: &serde_json::Value) -> DslValue {
322    match value {
323        serde_json::Value::Null => DslValue::Null,
324        serde_json::Value::Bool(b) => DslValue::Boolean(*b),
325        serde_json::Value::Number(n) => {
326            if let Some(i) = n.as_i64() {
327                DslValue::Integer(i)
328            } else if let Some(f) = n.as_f64() {
329                DslValue::Number(f)
330            } else {
331                DslValue::Number(0.0)
332            }
333        }
334        serde_json::Value::String(s) => DslValue::String(s.clone()),
335        serde_json::Value::Array(arr) => {
336            DslValue::List(arr.iter().map(json_to_dsl_value).collect())
337        }
338        serde_json::Value::Object(obj) => {
339            let map: HashMap<String, DslValue> = obj
340                .iter()
341                .map(|(k, v)| (k.clone(), json_to_dsl_value(v)))
342                .collect();
343            DslValue::Map(map)
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_parse_json_valid() {
354        let result =
355            builtin_parse_json(&[DslValue::String(r#"{"key": "value", "num": 42}"#.into())])
356                .unwrap();
357        match result {
358            DslValue::Map(map) => {
359                assert_eq!(map.get("key"), Some(&DslValue::String("value".into())));
360                assert_eq!(map.get("num"), Some(&DslValue::Integer(42)));
361            }
362            _ => panic!("Expected Map"),
363        }
364    }
365
366    #[test]
367    fn test_parse_json_array() {
368        let result = builtin_parse_json(&[DslValue::String("[1, 2, 3]".into())]).unwrap();
369        match result {
370            DslValue::List(items) => {
371                assert_eq!(items.len(), 3);
372                assert_eq!(items[0], DslValue::Integer(1));
373            }
374            _ => panic!("Expected List"),
375        }
376    }
377
378    #[test]
379    fn test_parse_json_invalid() {
380        let result = builtin_parse_json(&[DslValue::String("not json".into())]);
381        assert!(result.is_err());
382    }
383
384    #[test]
385    fn test_parse_json_nested() {
386        let json = r#"{"tasks": [{"id": 1, "done": false}], "count": 1}"#;
387        let result = builtin_parse_json(&[DslValue::String(json.into())]).unwrap();
388        match result {
389            DslValue::Map(map) => match map.get("tasks") {
390                Some(DslValue::List(tasks)) => {
391                    assert_eq!(tasks.len(), 1);
392                    match &tasks[0] {
393                        DslValue::Map(task) => {
394                            assert_eq!(task.get("id"), Some(&DslValue::Integer(1)));
395                            assert_eq!(task.get("done"), Some(&DslValue::Boolean(false)));
396                        }
397                        _ => panic!("Expected Map in list"),
398                    }
399                }
400                _ => panic!("Expected List for tasks"),
401            },
402            _ => panic!("Expected Map"),
403        }
404    }
405
406    #[test]
407    fn test_json_to_dsl_value_all_types() {
408        let json = serde_json::json!({
409            "str": "hello",
410            "int": 42,
411            "float": 3.14,
412            "bool": true,
413            "null": null,
414            "arr": [1, 2],
415            "obj": {"nested": "value"}
416        });
417
418        let dsl = json_to_dsl_value(&json);
419        match dsl {
420            DslValue::Map(map) => {
421                assert_eq!(map.get("str"), Some(&DslValue::String("hello".into())));
422                assert_eq!(map.get("int"), Some(&DslValue::Integer(42)));
423                assert_eq!(map.get("bool"), Some(&DslValue::Boolean(true)));
424                assert_eq!(map.get("null"), Some(&DslValue::Null));
425            }
426            _ => panic!("Expected Map"),
427        }
428    }
429
430    #[test]
431    fn test_parse_reason_args_positional() {
432        let args = vec![
433            DslValue::String("system prompt".into()),
434            DslValue::String("user message".into()),
435        ];
436        let (system, user, max_iter, max_tokens) = parse_reason_args(&args).unwrap();
437        assert_eq!(system, "system prompt");
438        assert_eq!(user, "user message");
439        assert_eq!(max_iter, 10);
440        assert_eq!(max_tokens, 100_000);
441    }
442
443    #[test]
444    fn test_parse_reason_args_named() {
445        let mut map = HashMap::new();
446        map.insert("system".into(), DslValue::String("sys".into()));
447        map.insert("user".into(), DslValue::String("usr".into()));
448        map.insert("max_iterations".into(), DslValue::Integer(5));
449
450        let args = vec![DslValue::Map(map)];
451        let (system, user, max_iter, max_tokens) = parse_reason_args(&args).unwrap();
452        assert_eq!(system, "sys");
453        assert_eq!(user, "usr");
454        assert_eq!(max_iter, 5);
455        assert_eq!(max_tokens, 100_000);
456    }
457
458    #[test]
459    fn test_parse_reason_args_missing_required() {
460        let mut map = HashMap::new();
461        map.insert("system".into(), DslValue::String("sys".into()));
462        // Missing "user"
463
464        let args = vec![DslValue::Map(map)];
465        assert!(parse_reason_args(&args).is_err());
466    }
467}