langcontinuation 0.1.0

Continuation-passing workflow engine for durable Rust programs and AI agent systems.
Documentation
//! Minimal durable tool-call loop.
//!
//! This example shows the [`langcontinuation`] tool primitive end to end with
//! the least possible code. A single registered [`Tool`] evaluates arithmetic;
//! the workflow asks a model, dispatches any tool calls the model makes, threads
//! the results back into the conversation, and loops until the model answers in
//! plain text.
//!
//! The point is contrast: a tool is a trait implementation, not a hand-rolled
//! dispatch loop. The crate owns the suspension boundary and the runtime runs
//! the tool; the workflow owns only the conversation history and the choice of
//! when to stop.
//!
//! Run it live (requires `ANTHROPIC_API_KEY`):
//!
//! ```sh
//! cargo run --example durable_tool_call
//! ```

use std::{future::Future, pin::Pin};

use claudius::{
    Anthropic, ContentBlock, KnownModel, Message, MessageCreateParams, MessageParam, MessageRole,
    ToolResultBlock, ToolUnionParam, ToolUseBlock,
};
use langcontinuation::{
    Tool, ToolCallId, ToolDispatch, Trampoline, Workflow, dispatch_tool_uses, from_env,
    generate_goto, live::Executor, push_env,
};
use serde::{Deserialize, Serialize};

/// A tiny calculator tool. It has nothing to do with a filesystem: the tool
/// contract is purely `ToolUseBlock` in, `ToolResultBlock` out.
struct CalculatorTool;

impl Tool for CalculatorTool {
    fn name(&self) -> String {
        "add".to_string()
    }

    fn to_param(&self) -> ToolUnionParam {
        ToolUnionParam::new_custom_tool(
            "add".to_string(),
            serde_json::json!({
                "type": "object",
                "properties": {
                    "a": { "type": "number" },
                    "b": { "type": "number" }
                },
                "required": ["a", "b"]
            }),
        )
    }

    fn call<'a>(
        &'a self,
        id: ToolCallId,
        tool_use: &'a ToolUseBlock,
    ) -> Pin<Box<dyn Future<Output = ToolResultBlock> + Send + 'a>> {
        let tool_use_id = tool_use.id.clone();
        let input = tool_use.input.clone();
        Box::pin(async move {
            // `id` is the durable, replay-deterministic dedupe key. A
            // side-effecting tool would record a marker under it; pure
            // arithmetic just ignores it.
            let _ = id;
            #[derive(Deserialize)]
            struct Args {
                a: f64,
                b: f64,
            }
            match serde_json::from_value::<Args>(input) {
                Ok(args) => ToolResultBlock::new(tool_use_id)
                    .with_string_content((args.a + args.b).to_string()),
                Err(err) => ToolResultBlock::new(tool_use_id)
                    .with_string_content(err.to_string())
                    .with_error(true),
            }
        })
    }
}

/// Durable conversation state lives in the workflow environment.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
struct Conversation {
    messages: Vec<MessageParam>,
}

fn request(conversation: &Conversation) -> MessageCreateParams {
    MessageCreateParams::new(
        256,
        conversation.messages.clone(),
        KnownModel::ClaudeHaiku45.into(),
    )
    .with_system(
        "You can call the `add` tool to add two numbers. Use it for any arithmetic, \
             then answer the user in plain text."
            .to_string(),
    )
    .with_tools(vec![CalculatorTool.to_param()])
}

generate_goto! {
    fn entry(workflow: &mut Workflow, question: String, continuation: Continuation) -> Result<ContinuationChoice, handled::SError> {
        let mut conversation = Conversation::default();
        conversation
            .messages
            .push(MessageParam::new_with_string(question, MessageRole::User));
        let req = request(&conversation);
        push_env!(workflow.conversation: Conversation = conversation);
        Ok(continuation.anthropic("anthropic", req, "response: Message", "receive"))
    }
}

generate_goto! {
    async fn receive(
        workflow: &mut Workflow,
        conversation: Conversation,
        response: Message,
        continuation: Continuation
    ) -> Result<ContinuationChoice, handled::SError> {
        // Append the assistant turn to the durable transcript.
        let mut conversation = conversation;
        conversation.messages.push(MessageParam::from(response.clone()));
        push_env!(workflow.conversation: Conversation = conversation);

        // Dispatch tools if the model called any; otherwise finish.
        match dispatch_tool_uses(continuation, &response, "tool_results: ToolResults", "after_tools") {
            ToolDispatch::Tools(choice) => Ok(choice),
            ToolDispatch::Done(continuation) => {
                let text = response
                    .content
                    .iter()
                    .filter_map(|block| match block {
                        ContentBlock::Text(text) => Some(text.text.clone()),
                        _ => None,
                    })
                    .collect::<Vec<_>>()
                    .join("");
                push_env!(workflow.answer: String = text);
                Ok(continuation.halt())
            }
        }
    }
}

/// The output key carries a `Vec<ToolResultBlock>`; this newtype just names that
/// type for the macro key convention.
#[derive(Clone, Debug, Deserialize, Serialize)]
struct ToolResults(Vec<ToolResultBlock>);

generate_goto! {
    fn after_tools(
        workflow: &mut Workflow,
        conversation: Conversation,
        tool_results: ToolResults,
        continuation: Continuation
    ) -> Result<ContinuationChoice, handled::SError> {
        // Thread tool results back as the next user message and ask again.
        let mut conversation = conversation;
        let blocks: Vec<ContentBlock> = tool_results
            .0
            .into_iter()
            .map(ContentBlock::ToolResult)
            .collect();
        conversation
            .messages
            .push(MessageParam::new_with_blocks(blocks, MessageRole::User));
        let req = request(&conversation);
        push_env!(workflow.conversation: Conversation = conversation);
        Ok(continuation.anthropic("anthropic", req, "response: Message", "receive"))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut workflow = Workflow::new("durable-tool-call", "entry");
    push_env!(workflow.question: String = "What is 21 plus 21?".to_string());

    let mut trampoline = Trampoline::default();
    trampoline.register("entry", entry);
    trampoline.register("receive", receive);
    trampoline.register("after_tools", after_tools);
    trampoline.register_tool(CalculatorTool);

    let anthropic = Anthropic::new(None)?;
    let executor = Executor::new(trampoline).with_anthropic("anthropic", anthropic);
    let workflow = executor.run_workflow(workflow).await?;

    from_env!(let answer: String = workflow.lookup());
    println!("{answer}");
    Ok(())
}