Skip to main content

durable_tool_call/
durable_tool_call.rs

1//! Minimal durable tool-call loop.
2//!
3//! This example shows the [`langcontinuation`] tool primitive end to end with
4//! the least possible code. A single registered [`Tool`] evaluates arithmetic;
5//! the workflow asks a model, dispatches any tool calls the model makes, threads
6//! the results back into the conversation, and loops until the model answers in
7//! plain text.
8//!
9//! The point is contrast: a tool is a trait implementation, not a hand-rolled
10//! dispatch loop. The crate owns the suspension boundary and the runtime runs
11//! the tool; the workflow owns only the conversation history and the choice of
12//! when to stop.
13//!
14//! Run it live (requires `ANTHROPIC_API_KEY`):
15//!
16//! ```sh
17//! cargo run --example durable_tool_call
18//! ```
19
20use std::{future::Future, pin::Pin};
21
22use claudius::{
23    Anthropic, ContentBlock, KnownModel, Message, MessageCreateParams, MessageParam, MessageRole,
24    ToolResultBlock, ToolUnionParam, ToolUseBlock,
25};
26use langcontinuation::{
27    Tool, ToolCallId, ToolDispatch, Trampoline, Workflow, dispatch_tool_uses, from_env,
28    generate_goto, live::Executor, push_env,
29};
30use serde::{Deserialize, Serialize};
31
32/// A tiny calculator tool. It has nothing to do with a filesystem: the tool
33/// contract is purely `ToolUseBlock` in, `ToolResultBlock` out.
34struct CalculatorTool;
35
36impl Tool for CalculatorTool {
37    fn name(&self) -> String {
38        "add".to_string()
39    }
40
41    fn to_param(&self) -> ToolUnionParam {
42        ToolUnionParam::new_custom_tool(
43            "add".to_string(),
44            serde_json::json!({
45                "type": "object",
46                "properties": {
47                    "a": { "type": "number" },
48                    "b": { "type": "number" }
49                },
50                "required": ["a", "b"]
51            }),
52        )
53    }
54
55    fn call<'a>(
56        &'a self,
57        id: ToolCallId,
58        tool_use: &'a ToolUseBlock,
59    ) -> Pin<Box<dyn Future<Output = ToolResultBlock> + Send + 'a>> {
60        let tool_use_id = tool_use.id.clone();
61        let input = tool_use.input.clone();
62        Box::pin(async move {
63            // `id` is the durable, replay-deterministic dedupe key. A
64            // side-effecting tool would record a marker under it; pure
65            // arithmetic just ignores it.
66            let _ = id;
67            #[derive(Deserialize)]
68            struct Args {
69                a: f64,
70                b: f64,
71            }
72            match serde_json::from_value::<Args>(input) {
73                Ok(args) => ToolResultBlock::new(tool_use_id)
74                    .with_string_content((args.a + args.b).to_string()),
75                Err(err) => ToolResultBlock::new(tool_use_id)
76                    .with_string_content(err.to_string())
77                    .with_error(true),
78            }
79        })
80    }
81}
82
83/// Durable conversation state lives in the workflow environment.
84#[derive(Clone, Debug, Default, Deserialize, Serialize)]
85struct Conversation {
86    messages: Vec<MessageParam>,
87}
88
89fn request(conversation: &Conversation) -> MessageCreateParams {
90    MessageCreateParams::new(
91        256,
92        conversation.messages.clone(),
93        KnownModel::ClaudeHaiku45.into(),
94    )
95    .with_system(
96        "You can call the `add` tool to add two numbers. Use it for any arithmetic, \
97             then answer the user in plain text."
98            .to_string(),
99    )
100    .with_tools(vec![CalculatorTool.to_param()])
101}
102
103generate_goto! {
104    fn entry(workflow: &mut Workflow, question: String, continuation: Continuation) -> Result<ContinuationChoice, handled::SError> {
105        let mut conversation = Conversation::default();
106        conversation
107            .messages
108            .push(MessageParam::new_with_string(question, MessageRole::User));
109        let req = request(&conversation);
110        push_env!(workflow.conversation: Conversation = conversation);
111        Ok(continuation.anthropic("anthropic", req, "response: Message", "receive"))
112    }
113}
114
115generate_goto! {
116    async fn receive(
117        workflow: &mut Workflow,
118        conversation: Conversation,
119        response: Message,
120        continuation: Continuation
121    ) -> Result<ContinuationChoice, handled::SError> {
122        // Append the assistant turn to the durable transcript.
123        let mut conversation = conversation;
124        conversation.messages.push(MessageParam::from(response.clone()));
125        push_env!(workflow.conversation: Conversation = conversation);
126
127        // Dispatch tools if the model called any; otherwise finish.
128        match dispatch_tool_uses(continuation, &response, "tool_results: ToolResults", "after_tools") {
129            ToolDispatch::Tools(choice) => Ok(choice),
130            ToolDispatch::Done(continuation) => {
131                let text = response
132                    .content
133                    .iter()
134                    .filter_map(|block| match block {
135                        ContentBlock::Text(text) => Some(text.text.clone()),
136                        _ => None,
137                    })
138                    .collect::<Vec<_>>()
139                    .join("");
140                push_env!(workflow.answer: String = text);
141                Ok(continuation.halt())
142            }
143        }
144    }
145}
146
147/// The output key carries a `Vec<ToolResultBlock>`; this newtype just names that
148/// type for the macro key convention.
149#[derive(Clone, Debug, Deserialize, Serialize)]
150struct ToolResults(Vec<ToolResultBlock>);
151
152generate_goto! {
153    fn after_tools(
154        workflow: &mut Workflow,
155        conversation: Conversation,
156        tool_results: ToolResults,
157        continuation: Continuation
158    ) -> Result<ContinuationChoice, handled::SError> {
159        // Thread tool results back as the next user message and ask again.
160        let mut conversation = conversation;
161        let blocks: Vec<ContentBlock> = tool_results
162            .0
163            .into_iter()
164            .map(ContentBlock::ToolResult)
165            .collect();
166        conversation
167            .messages
168            .push(MessageParam::new_with_blocks(blocks, MessageRole::User));
169        let req = request(&conversation);
170        push_env!(workflow.conversation: Conversation = conversation);
171        Ok(continuation.anthropic("anthropic", req, "response: Message", "receive"))
172    }
173}
174
175#[tokio::main]
176async fn main() -> Result<(), Box<dyn std::error::Error>> {
177    let mut workflow = Workflow::new("durable-tool-call", "entry");
178    push_env!(workflow.question: String = "What is 21 plus 21?".to_string());
179
180    let mut trampoline = Trampoline::default();
181    trampoline.register("entry", entry);
182    trampoline.register("receive", receive);
183    trampoline.register("after_tools", after_tools);
184    trampoline.register_tool(CalculatorTool);
185
186    let anthropic = Anthropic::new(None)?;
187    let executor = Executor::new(trampoline).with_anthropic("anthropic", anthropic);
188    let workflow = executor.run_workflow(workflow).await?;
189
190    from_env!(let answer: String = workflow.lookup());
191    println!("{answer}");
192    Ok(())
193}