Skip to main content

langchain_rust/agent/open_ai_tools/
agent.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use futures::StreamExt;
6use serde_json::json;
7
8use crate::{
9    agent::{
10        agent::{box_plan_events, PlanEvent, PlanEventStream},
11        Agent, AgentError,
12    },
13    chain::Chain,
14    fmt_message, fmt_placeholder, fmt_template,
15    language_models::{GenerateResult, LLMEvent, TokenUsage},
16    message_formatter,
17    prompt::{HumanMessagePromptTemplate, MessageFormatterStruct, PromptArgs, DEFAULT_IMAGES_KEY},
18    schemas::{
19        agent::{AgentAction, AgentEvent, AgentFinish, LogTools},
20        messages::Message,
21        ToolCall,
22    },
23    template_jinja2,
24    tools::Tool,
25};
26
27pub struct OpenAiToolAgent {
28    pub(crate) chain: Box<dyn Chain>,
29    pub(crate) tools: Vec<Arc<dyn Tool>>,
30}
31
32impl OpenAiToolAgent {
33    pub fn create_prompt(prefix: &str) -> Result<MessageFormatterStruct, AgentError> {
34        let prompt = message_formatter![
35            fmt_message!(Message::new_system_message(prefix)),
36            fmt_placeholder!("chat_history"),
37            fmt_template!(
38                HumanMessagePromptTemplate::new(template_jinja2!("{{input}}", "input"))
39                    .with_images(DEFAULT_IMAGES_KEY)
40            ),
41            fmt_placeholder!("agent_scratchpad")
42        ];
43
44        Ok(prompt)
45    }
46
47    fn construct_scratchpad(
48        &self,
49        intermediate_steps: &[(AgentAction, String)],
50    ) -> Result<Vec<Message>, AgentError> {
51        let mut thoughts: Vec<Message> = Vec::new();
52
53        let mut tools_ai_message_seen: HashMap<String, ()> = HashMap::default();
54        for (action, observation) in intermediate_steps {
55            // Deserialize directly and embed in method calls to streamline code.
56            // Extract the tool ID and tool calls from the log.
57            let LogTools { tool_id, tools } = serde_json::from_str(&action.log)?;
58            let tools_vec: Vec<ToolCall> = serde_json::from_str(&tools)?;
59
60            // one action can trigger multiple observations.  But make sure we add it to
61            // the scratchpad before the related observations.  There can also be multiple
62            // different actions in the same thought chain.
63            if tools_ai_message_seen.insert(tools, ()).is_none() {
64                thoughts.push(Message::new_ai_message_with_tool_calls("", &tools_vec));
65            }
66
67            // Add a tool message for each observation. Observation is the ouput of the tool call.
68            // tool_id is the id of the tool.
69            thoughts.push(Message::new_tool_message(observation, tool_id));
70        }
71
72        Ok(thoughts)
73    }
74}
75
76#[async_trait]
77impl Agent for OpenAiToolAgent {
78    async fn plan(
79        &self,
80        intermediate_steps: &[(AgentAction, String)],
81        inputs: PromptArgs,
82    ) -> Result<AgentEvent, AgentError> {
83        Ok(self.plan_with_usage(intermediate_steps, inputs).await?.0)
84    }
85
86    async fn plan_with_usage(
87        &self,
88        intermediate_steps: &[(AgentAction, String)],
89        inputs: PromptArgs,
90    ) -> Result<(AgentEvent, Option<TokenUsage>), AgentError> {
91        let inputs = self.plan_inputs(intermediate_steps, inputs)?;
92        let mut result = self.chain.call(inputs).await?;
93        let usage = result.tokens.take();
94        Ok((Self::event_from_result(result)?, usage))
95    }
96
97    /// Streams the model's text as it arrives (with native tool calling that is the
98    /// answer, or a short preamble like "Let me check" before tool calls).
99    fn plan_stream<'a>(
100        &'a self,
101        intermediate_steps: &'a [(AgentAction, String)],
102        inputs: PromptArgs,
103    ) -> PlanEventStream<'a> {
104        box_plan_events(async_stream::try_stream! {
105            let inputs = self.plan_inputs(intermediate_steps, inputs)?;
106            let mut events = self.chain.call_stream(inputs);
107            // LLMs without native tool calling write the calls as a JSON array in the text.
108            // Output starting with `[` is held back until the plan is known, so that JSON
109            // never reaches the caller as answer text.
110            let mut held = String::new();
111            let mut hold: Option<bool> = None;
112            while let Some(event) = events.next().await {
113                match event? {
114                    LLMEvent::Text(text) => {
115                        if hold == Some(false) {
116                            yield PlanEvent::Text(text);
117                            continue;
118                        }
119                        held.push_str(&text);
120                        if let Some(first) = held.trim_start().chars().next() {
121                            hold = Some(first == '[');
122                            if first != '[' {
123                                yield PlanEvent::Text(std::mem::take(&mut held));
124                            }
125                        }
126                    }
127                    LLMEvent::Done(mut result) => {
128                        let usage = result.tokens.take();
129                        let event = Self::event_from_result(result)?;
130                        if matches!(event, AgentEvent::Finish(_)) && !held.is_empty() {
131                            yield PlanEvent::Text(held);
132                        }
133                        if let Some(usage) = usage {
134                            yield PlanEvent::Usage(usage);
135                        }
136                        yield PlanEvent::Done(event);
137                        return;
138                    }
139                }
140            }
141            Err(AgentError::OtherError("LLM stream ended without a result".into()))?;
142        })
143    }
144
145    fn get_tools(&self) -> Vec<Arc<dyn Tool>> {
146        self.tools.clone()
147    }
148}
149
150impl OpenAiToolAgent {
151    fn plan_inputs(
152        &self,
153        intermediate_steps: &[(AgentAction, String)],
154        mut inputs: PromptArgs,
155    ) -> Result<PromptArgs, AgentError> {
156        let scratchpad = self.construct_scratchpad(intermediate_steps)?;
157        inputs.insert("agent_scratchpad".to_string(), json!(scratchpad));
158        Ok(inputs)
159    }
160
161    fn event_from_result(result: GenerateResult) -> Result<AgentEvent, AgentError> {
162        // Providers fill `tool_calls`; LLMs that only encode calls as JSON text in
163        // `generation` (custom impls, older code) still work through the fallback.
164        let calls = if result.has_tool_calls() {
165            Some(result.tool_calls)
166        } else {
167            ToolCall::parse_list(&result.generation).filter(|c| !c.is_empty())
168        };
169        let Some(calls) = calls else {
170            return Ok(AgentEvent::Finish(AgentFinish {
171                output: result.generation,
172            }));
173        };
174
175        // Every action carries the full list: the scratchpad replays it as one AI message.
176        let tools = ToolCall::list_to_string(&calls);
177        let mut actions: Vec<AgentAction> = Vec::new();
178        for call in calls {
179            let log = LogTools {
180                tool_id: call.id,
181                tools: tools.clone(),
182            };
183            actions.push(AgentAction {
184                tool: call.name,
185                tool_input: call.arguments,
186                log: serde_json::to_string(&log)?, //We send this as string to minimise changes
187            });
188        }
189        Ok(AgentEvent::Action(actions))
190    }
191}