diff --git a/AGENT_DESIGN.md b/AGENT_DESIGN.md new file mode 100644 index 0000000..abc45a7 --- /dev/null +++ b/AGENT_DESIGN.md @@ -0,0 +1,99 @@ +# Agent & Tool Loop + +High-level API for agentic LLM workflows with automatic tool execution. + +## Overview + +``` +┌─────────────────────────────────────┐ +│ Agent │ +│ - provider │ +│ - system_prompt │ +│ - tools (with executors) │ +│ - max_iterations │ +├─────────────────────────────────────┤ +│ Loop │ +│ - Sends messages to LLM │ +│ - Executes tool calls │ +│ - Repeats until done │ +├─────────────────────────────────────┤ +│ Runtime │ +│ - HTTP transport abstraction │ +│ - Erlang: gleam_httpc │ +│ - JS: gleam_fetch (TBD) │ +└─────────────────────────────────────┘ +``` + +## Tool Design + +Tools carry their own executor. Type safety is preserved at definition via closures: + +```gleam +let weather_tool = tool.tool( + name: "get_weather", + description: "Get weather for a location", + schema: weather_schema(), + execute: fn(ctx, args) { + // args is WeatherArgs - fully typed! + Ok("Sunny in " <> args.location) + }, +) +``` + +## Agent + +Minimal config for agentic workflows: + +```gleam +let my_agent = agent.new(provider) + |> agent.with_system_prompt("You are helpful.") + |> agent.with_tool(weather_tool) + |> agent.with_max_iterations(5) +``` + +Request-level config (max_tokens, temperature) is passed via `run_with_config`: + +```gleam +loop.run_with_config(agent, ctx, messages, runtime, Some(fn(req) { + req + |> request.with_max_tokens(1000) + |> request.with_temperature(0.7) +})) +``` + +## Runtime + +Abstracts HTTP transport: + +```gleam +pub type Runtime { + Runtime(send: fn(Request(String)) -> Result(Response(String), Error)) +} +``` + +## Usage + +```gleam +// 1. Create provider +let provider = openai.new("sk-...") |> openai.provider + +// 2. Create tools +let search_tool = tool.tool( + name: "search", + description: "Search the web", + schema: search_schema(), + execute: fn(ctx, args) { do_search(ctx, args.query) }, +) + +// 3. Create agent +let my_agent = agent.new(provider) + |> agent.with_system_prompt("You are a helpful assistant.") + |> agent.with_tool(search_tool) + +// 4. Run +let messages = [gai.user_text("Search for Gleam")] +case loop.run(my_agent, ctx, messages, runtime) { + Ok(result) -> response.text_content(result.response) + Error(err) -> gai.error_to_string(err) +} +``` diff --git a/src/gai/agent.gleam b/src/gai/agent.gleam new file mode 100644 index 0000000..1134e88 --- /dev/null +++ b/src/gai/agent.gleam @@ -0,0 +1,94 @@ +/// Agent configuration for tool-enabled LLM interactions. +/// +/// An Agent bundles a provider, system prompt, and executable tools +/// together for use with the tool loop. It focuses on agentic-specific +/// concerns, leaving request-level config (max_tokens, temperature, etc.) +/// to be passed separately. +/// +/// ## Example +/// +/// ```gleam +/// let my_agent = agent.new(provider) +/// |> agent.with_system_prompt("You are a helpful assistant.") +/// |> agent.with_tool(weather_tool) +/// |> agent.with_tool(search_tool) +/// |> agent.with_max_iterations(5) +/// ``` +import gai/provider.{type Provider} +import gai/tool.{type Tool} +import gleam/list +import gleam/option.{type Option, None, Some} + +/// Agent configuration with context type parameter. +/// +/// The `ctx` type represents the context passed to tool executors. +pub opaque type Agent(ctx) { + Agent( + provider: Provider, + system_prompt: Option(String), + tools: List(Tool(ctx)), + max_iterations: Int, + ) +} + +/// Create a new agent with a provider +pub fn new(provider: Provider) -> Agent(ctx) { + Agent(provider:, system_prompt: None, tools: [], max_iterations: 10) +} + +/// Set the system prompt +pub fn with_system_prompt(agent: Agent(ctx), prompt: String) -> Agent(ctx) { + Agent(..agent, system_prompt: Some(prompt)) +} + +/// Add a tool to the agent +pub fn with_tool(agent: Agent(ctx), tool: Tool(ctx)) -> Agent(ctx) { + Agent(..agent, tools: [tool, ..agent.tools]) +} + +/// Add multiple tools to the agent +pub fn with_tools(agent: Agent(ctx), tools: List(Tool(ctx))) -> Agent(ctx) { + Agent(..agent, tools: list.append(tools, agent.tools)) +} + +/// Set maximum tool loop iterations (safety limit) +pub fn with_max_iterations(agent: Agent(ctx), n: Int) -> Agent(ctx) { + Agent(..agent, max_iterations: n) +} + +/// Get the provider +pub fn provider(agent: Agent(ctx)) -> Provider { + agent.provider +} + +/// Get the system prompt +pub fn system_prompt(agent: Agent(ctx)) -> Option(String) { + agent.system_prompt +} + +/// Get tools as a list +pub fn tools(agent: Agent(ctx)) -> List(Tool(ctx)) { + agent.tools +} + +/// Get max iterations setting +pub fn max_iterations(agent: Agent(ctx)) -> Int { + agent.max_iterations +} + +/// Find a tool by name +pub fn find_tool(agent: Agent(ctx), name: String) -> Option(Tool(ctx)) { + agent.tools + |> list.find(fn(t) { tool.name(t) == name }) + |> option.from_result +} + +/// Check if the agent has any tools +pub fn has_tools(agent: Agent(ctx)) -> Bool { + !list.is_empty(agent.tools) +} + +/// Get the number of tools +pub fn tool_count(agent: Agent(ctx)) -> Int { + list.length(agent.tools) +} diff --git a/src/gai/agent/loop.gleam b/src/gai/agent/loop.gleam new file mode 100644 index 0000000..0ddb09e --- /dev/null +++ b/src/gai/agent/loop.gleam @@ -0,0 +1,215 @@ +/// Tool loop for agent execution. +/// +/// The tool loop orchestrates LLM calls with automatic tool execution: +/// 1. Send messages to the LLM +/// 2. If the response contains tool calls, execute them +/// 3. Append tool results and repeat +/// 4. Return when no more tool calls or max iterations reached +/// +/// ## Example +/// +/// ```gleam +/// let result = loop.run(agent, ctx, messages, runtime) +/// case result { +/// Ok(run_result) -> { +/// io.println(response.text_content(run_result.response)) +/// } +/// Error(err) -> { +/// io.println("Error: " <> gai.error_to_string(err)) +/// } +/// } +/// ``` +import gai.{type Error, type Message} +import gai/agent.{type Agent} +import gai/provider +import gai/request.{type CompletionRequest} +import gai/response.{type CompletionResponse} +import gai/runtime.{type Runtime} +import gai/tool.{type Call, type CallResult} +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result + +/// Result of running the agent +pub type RunResult { + RunResult( + /// The final completion response + response: CompletionResponse, + /// Full message history including tool calls and results + messages: List(Message), + /// Number of iterations (LLM calls) made + iterations: Int, + ) +} + +/// Run the agent with automatic tool loop. +/// +/// This function: +/// 1. Prepends the system prompt (if set) +/// 2. Sends messages to the LLM +/// 3. If tool calls are returned, executes them and loops +/// 4. Returns when complete or max iterations reached +/// +/// For request-level config (max_tokens, temperature, etc.), use `run_with_config`. +pub fn run( + agent: Agent(ctx), + ctx: ctx, + messages: List(Message), + http_runtime: Runtime, +) -> Result(RunResult, Error) { + run_with_config(agent, ctx, messages, http_runtime, None) +} + +/// Run the agent with a custom request configuration. +/// +/// The config function receives a base CompletionRequest (with model and messages) +/// and can modify it to add max_tokens, temperature, tool_choice, etc. +/// +/// ## Example +/// +/// ```gleam +/// loop.run_with_config(agent, ctx, messages, runtime, Some(fn(req) { +/// req +/// |> request.with_max_tokens(1000) +/// |> request.with_temperature(0.7) +/// })) +/// ``` +pub fn run_with_config( + agent: Agent(ctx), + ctx: ctx, + messages: List(Message), + http_runtime: Runtime, + config: Option(fn(CompletionRequest) -> CompletionRequest), +) -> Result(RunResult, Error) { + // Prepend system prompt if set + let messages = case agent.system_prompt(agent) { + None -> messages + Some(prompt) -> [gai.system(prompt), ..messages] + } + + run_loop(agent, ctx, messages, http_runtime, config, 0) +} + +fn run_loop( + agent: Agent(ctx), + ctx: ctx, + messages: List(Message), + http_runtime: Runtime, + config: Option(fn(CompletionRequest) -> CompletionRequest), + iteration: Int, +) -> Result(RunResult, Error) { + // Check iteration limit + case iteration >= agent.max_iterations(agent) { + True -> + Error(gai.ApiError( + "max_iterations", + "Tool loop exceeded maximum iterations", + )) + False -> { + // Build request + let req = build_request(agent, messages, config) + + // Build HTTP request using provider + let http_req = provider.build_request(agent.provider(agent), req) + + // Send via runtime + use http_resp <- result.try(runtime.send(http_runtime, http_req)) + + // Parse response + use completion <- result.try(provider.parse_response( + agent.provider(agent), + http_resp, + )) + + // Check for tool calls + case response.has_tool_calls(completion) { + False -> { + // No tools, we're done + let final_messages = response.append_response(messages, completion) + Ok(RunResult( + response: completion, + messages: final_messages, + iterations: iteration + 1, + )) + } + True -> { + // Execute tools + let tool_calls = extract_tool_calls(completion) + let results = execute_tools(agent, ctx, tool_calls) + + // Append assistant response and tool results to history + let messages = response.append_response(messages, completion) + let messages = append_tool_results(messages, results) + + // Continue loop + run_loop(agent, ctx, messages, http_runtime, config, iteration + 1) + } + } + } + } +} + +fn build_request( + agent: Agent(ctx), + messages: List(Message), + config: Option(fn(CompletionRequest) -> CompletionRequest), +) -> CompletionRequest { + let base_req = request.new(provider.name(agent.provider(agent)), messages) + + // Add tools if any + let req = case agent.has_tools(agent) { + False -> base_req + True -> { + let tool_schemas = + agent.tools(agent) + |> list.map(tool.to_schema) + request.with_tools(base_req, tool_schemas) + } + } + + // Apply user config if provided + case config { + None -> req + Some(configure) -> configure(req) + } +} + +fn extract_tool_calls(resp: CompletionResponse) -> List(Call) { + response.tool_calls(resp) + |> list.filter_map(fn(content) { + case content { + gai.ToolUse(id, name, args_json) -> + Ok(tool.Call(id:, name:, arguments_json: args_json)) + _ -> Error(Nil) + } + }) +} + +fn execute_tools( + agent: Agent(ctx), + ctx: ctx, + calls: List(Call), +) -> List(CallResult) { + list.map(calls, fn(call) { execute_single_tool(agent, ctx, call) }) +} + +fn execute_single_tool(agent: Agent(ctx), ctx: ctx, call: Call) -> CallResult { + case agent.find_tool(agent, call.name) { + None -> tool.call_error(call, "Unknown tool: " <> call.name) + Some(t) -> tool.execute_call(t, ctx, call) + } +} + +fn append_tool_results( + messages: List(Message), + results: List(CallResult), +) -> List(Message) { + let content = + list.map(results, fn(r) { + case r.content { + Ok(text) -> gai.tool_result(r.tool_use_id, text) + Error(err) -> gai.tool_result_error(r.tool_use_id, err) + } + }) + list.append(messages, [gai.Message(gai.User, content, None)]) +} diff --git a/src/gai/anthropic.gleam b/src/gai/anthropic.gleam index 7c13c87..5a1c5f4 100644 --- a/src/gai/anthropic.gleam +++ b/src/gai/anthropic.gleam @@ -421,11 +421,11 @@ fn encode_content(content: gai.Content) -> json.Json { } } -fn encode_tool(t: tool.UntypedTool) -> json.Json { +fn encode_tool(t: tool.Schema) -> json.Json { json.object([ - #("name", json.string(tool.untyped_name(t))), - #("description", json.string(tool.untyped_description(t))), - #("input_schema", tool.untyped_schema(t)), + #("name", json.string(t.name)), + #("description", json.string(t.description)), + #("input_schema", t.schema), ]) } diff --git a/src/gai/cache.gleam b/src/gai/cache.gleam index b551f58..a3cc99a 100644 --- a/src/gai/cache.gleam +++ b/src/gai/cache.gleam @@ -197,11 +197,11 @@ fn serialise_content(content: Content) -> json.Json { } } -fn serialise_tool(t: tool.UntypedTool) -> json.Json { +fn serialise_tool(t: tool.Schema) -> json.Json { json.object([ - #("name", json.string(tool.untyped_name(t))), - #("description", json.string(tool.untyped_description(t))), - #("schema", tool.untyped_schema(t)), + #("name", json.string(t.name)), + #("description", json.string(t.description)), + #("schema", t.schema), ]) } diff --git a/src/gai/google.gleam b/src/gai/google.gleam index b4a302a..da038b2 100644 --- a/src/gai/google.gleam +++ b/src/gai/google.gleam @@ -300,15 +300,12 @@ fn encode_part(content: gai.Content) -> json.Json { } } -fn encode_tool(t: tool.UntypedTool) -> json.Json { +fn encode_tool(t: tool.Schema) -> json.Json { json.object([ - #("name", json.string(tool.untyped_name(t))), - #("description", json.string(tool.untyped_description(t))), + #("name", json.string(t.name)), + #("description", json.string(t.description)), // Strip $schema and additionalProperties which Google doesn't support - #( - "parameters", - json_decode.strip_unsupported_schema_fields(tool.untyped_schema(t)), - ), + #("parameters", json_decode.strip_unsupported_schema_fields(t.schema)), ]) } diff --git a/src/gai/openai.gleam b/src/gai/openai.gleam index 629e472..44f61e7 100644 --- a/src/gai/openai.gleam +++ b/src/gai/openai.gleam @@ -256,15 +256,15 @@ fn encode_content(content: gai.Content) -> json.Json { } } -fn encode_tool(t: tool.UntypedTool) -> json.Json { +fn encode_tool(t: tool.Schema) -> json.Json { json.object([ #("type", json.string("function")), #( "function", json.object([ - #("name", json.string(tool.untyped_name(t))), - #("description", json.string(tool.untyped_description(t))), - #("parameters", tool.untyped_schema(t)), + #("name", json.string(t.name)), + #("description", json.string(t.description)), + #("parameters", t.schema), ]), ), ]) diff --git a/src/gai/request.gleam b/src/gai/request.gleam index c3dfc81..d7a814b 100644 --- a/src/gai/request.gleam +++ b/src/gai/request.gleam @@ -1,7 +1,7 @@ /// Completion request types and builders. import gai.{type Message} import gai/schema.{type Schema} -import gai/tool.{type UntypedTool} +import gai/tool import gleam/json.{type Json} import gleam/option.{type Option, None} @@ -29,7 +29,7 @@ pub type CompletionRequest { temperature: Option(Float), top_p: Option(Float), stop: Option(List(String)), - tools: Option(List(UntypedTool)), + tools: Option(List(tool.Schema)), tool_choice: Option(ToolChoice), response_format: Option(ResponseFormat), provider_options: Option(List(#(String, Json))), @@ -78,7 +78,7 @@ pub fn with_stop( /// Set tools pub fn with_tools( req: CompletionRequest, - tools: List(UntypedTool), + tools: List(tool.Schema), ) -> CompletionRequest { CompletionRequest(..req, tools: option.Some(tools)) } diff --git a/src/gai/runtime.gleam b/src/gai/runtime.gleam new file mode 100644 index 0000000..f9a004a --- /dev/null +++ b/src/gai/runtime.gleam @@ -0,0 +1,57 @@ +/// Runtime abstraction for HTTP transport. +/// +/// The Runtime type provides an abstraction over HTTP clients, allowing +/// the agent/loop to work with any HTTP implementation on any target. +/// +/// ## Erlang Example +/// +/// ```gleam +/// import gleam/httpc +/// +/// let runtime = runtime.new(fn(req) { +/// httpc.send(req) +/// |> result.map_error(fn(_) { gai.HttpError(0, "Request failed") }) +/// }) +/// ``` +/// +/// ## JavaScript Example +/// +/// ```gleam +/// // Note: JS requires async handling - see runtime/fetch.gleam +/// ``` +import gai.{type Error} +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} + +/// Runtime provides HTTP transport abstraction. +/// +/// The send function takes an HTTP request and returns a response +/// or an error. This allows the agent to be agnostic about the +/// underlying HTTP client. +pub type Runtime { + Runtime(send: fn(Request(String)) -> Result(Response(String), Error)) +} + +/// Create a new runtime from a send function. +/// +/// ## Example +/// +/// ```gleam +/// let runtime = runtime.new(fn(req) { +/// // Your HTTP client here +/// my_http_client.send(req) +/// }) +/// ``` +pub fn new( + send send: fn(Request(String)) -> Result(Response(String), Error), +) -> Runtime { + Runtime(send:) +} + +/// Send an HTTP request using the runtime. +pub fn send( + runtime: Runtime, + request: Request(String), +) -> Result(Response(String), Error) { + runtime.send(request) +} diff --git a/src/gai/tool.gleam b/src/gai/tool.gleam index c3743c3..a25c448 100644 --- a/src/gai/tool.gleam +++ b/src/gai/tool.gleam @@ -1,84 +1,222 @@ -/// Tool definitions with Sextant schema integration. +/// Tool definitions with embedded executors. /// -/// This module provides typed tool definitions that can generate JSON Schema -/// for the LLM API and parse tool call arguments back into typed Gleam values. +/// Tools carry their own execution function and type safety is preserved +/// at definition time through closures. +/// +/// ## Example +/// +/// ```gleam +/// let weather_tool = tool.new( +/// name: "get_weather", +/// description: "Get weather for a location", +/// schema: weather_schema(), +/// execute: fn(ctx, args) { +/// // args is WeatherArgs here - fully typed! +/// Ok("Weather in " <> args.location <> ": sunny") +/// }, +/// ) +/// ``` import gleam/dynamic/decode import gleam/json.{type Json} +import gleam/list +import gleam/result +import gleam/string import sextant.{type JsonSchema} -/// Tool definition with Sextant schema. -/// The phantom type `a` represents the decoded arguments type. -pub opaque type Tool(a) { - Tool(name: String, description: String, schema: JsonSchema(a)) +/// An executable tool with embedded executor. +/// +/// The `ctx` type parameter is the context passed to the executor. +/// The `args` type parameter represents the parsed arguments type, +/// but is erased to `ToolArgs` for storage in lists. +pub opaque type Tool(ctx) { + Tool( + name: String, + description: String, + schema_json: Json, + /// Parses JSON args internally and executes with context + run: fn(ctx, String) -> Result(String, ExecutionError), + ) } -/// Create a tool definition -pub fn new( - name: String, - description: String, - parameters: JsonSchema(a), -) -> Tool(a) { - Tool(name:, description:, schema: parameters) +/// Tool schema information for sending to LLM APIs. +/// This is a context-free representation of a tool's metadata. +pub type Schema { + Schema(name: String, description: String, schema: Json) } -/// Get the tool name -pub fn name(tool: Tool(a)) -> String { - tool.name +/// Errors that can occur during tool execution +pub type ExecutionError { + /// Failed to parse the JSON arguments + ParseError(message: String) + /// The tool execution itself failed + ToolError(message: String) } -/// Get the tool description -pub fn description(tool: Tool(a)) -> String { - tool.description +/// Convert an execution error to a human-readable string +pub fn describe_error(error: ExecutionError) -> String { + case error { + ParseError(msg) -> "Parse error: " <> msg + ToolError(msg) -> "Execution error: " <> msg + } } -/// Get the JSON Schema for a tool (for sending to API) -pub fn to_json_schema(tool: Tool(a)) -> Json { - sextant.to_json(tool.schema) +/// A tool call extracted from an LLM response +pub type Call { + Call(id: String, name: String, arguments_json: String) } -/// Parse tool call arguments from a JSON string using the tool's schema -pub fn parse_arguments( - tool: Tool(a), - arguments_json: String, -) -> Result(a, List(sextant.ValidationError)) { - case json.parse(arguments_json, decode.dynamic) { - Ok(dynamic) -> sextant.run(dynamic, tool.schema) - Error(_) -> - Error([ - sextant.TypeError( - path: [], - expected: "valid JSON", - found: "invalid JSON", - ), - ]) - } +/// Result of executing a tool +pub type CallResult { + CallResult(tool_use_id: String, content: Result(String, String)) } -/// An untyped tool for storage in lists (type erased) -pub opaque type UntypedTool { - UntypedTool(name: String, description: String, schema_json: Json) +/// Create a successful call result +pub fn call_ok(call: Call, content: String) -> CallResult { + CallResult(tool_use_id: call.id, content: Ok(content)) } -/// Erase the type parameter for storage in lists -pub fn to_untyped(tool: Tool(a)) -> UntypedTool { - UntypedTool( - name: tool.name, - description: tool.description, - schema_json: sextant.to_json(tool.schema), +/// Create a failed call result +pub fn call_error(call: Call, message: String) -> CallResult { + CallResult(tool_use_id: call.id, content: Error(message)) +} + +/// Create a new executable tool with typed schema and executor. +/// +/// The type parameter `args` is captured in the executor closure, +/// then erased to `ToolArgs` for storage. This allows storing +/// heterogeneous tools in a list while maintaining type safety +/// at the definition site. +/// +/// ## Example +/// +/// ```gleam +/// let weather_tool = tool.executable( +/// name: "get_weather", +/// description: "Get current weather", +/// schema: weather_schema(), +/// execute: fn(ctx, args) { +/// // args is fully typed as WeatherArgs +/// Ok("Sunny in " <> args.location) +/// }, +/// ) +/// ``` +pub fn tool( + name name: String, + description description: String, + schema schema: JsonSchema(args), + execute execute: fn(ctx, args) -> Result(String, ExecutionError), +) -> Tool(ctx) { + Tool( + name:, + description:, + schema_json: sextant.to_json(schema), + run: fn(ctx, args_json) { + // Parse JSON to dynamic + use dynamic <- result.try( + json.parse(args_json, decode.dynamic) + |> result.map_error(describe_json_decoding_error) + |> result.map_error(ParseError), + ) + use args <- result.try( + sextant.run(dynamic, schema) + |> result.map_error(describe_validation_errors) + |> result.map_error(ParseError), + ) + execute(ctx, args) + }, ) } -/// Get the name of an untyped tool -pub fn untyped_name(tool: UntypedTool) -> String { +fn describe_json_decoding_error(error: json.DecodeError) -> String { + case error { + json.UnexpectedEndOfInput -> "Unexpected end of input" + json.UnexpectedByte(_) -> "Unexpected byte" + json.UnexpectedSequence(_) -> "Unexpected sequence" + json.UnableToDecode(errors) -> + "Unable to decode JSON: " + <> list.map(errors, describe_decode_error) |> string.join("; ") + } + |> string.append("JSON Decoding Failed: ", _) +} + +fn describe_decode_error(error: decode.DecodeError) { + case error { + decode.DecodeError(expected:, found:, path:) -> + string.join(path, ".") <> " expected " <> expected <> ", got " <> found + } +} + +fn describe_validation_errors(errors: List(sextant.ValidationError)) -> String { + errors + |> list.map(fn(e) { + case e { + sextant.TypeError(path:, expected:, found:) -> + string.join(path, ".") <> " expected " <> expected <> ", got " <> found + sextant.MissingField(path:, field:) -> + string.join(path, ".") <> " missing field '" <> field <> "'" + sextant.ConstraintError(path:, violation: _) -> + string.join(path, ".") <> " constraint violation" + sextant.UnknownVariant(path:, value:, expected:) -> + string.join(path, ".") + <> " unknown variant '" + <> value + <> "', expected one of " + <> string.join(expected, ", ") + sextant.ConstMismatch(path:, expected:, actual:) -> + string.join(path, ".") + <> " expected const '" + <> expected + <> "', got '" + <> actual + <> "'" + } + }) + |> string.join("; ") + |> string.append("Validation failed:", _) +} + +/// Get the name of an executable tool +pub fn name(tool: Tool(ctx)) -> String { tool.name } -/// Get the description of an untyped tool -pub fn untyped_description(tool: UntypedTool) -> String { +/// Get the description of an executable tool +pub fn description(tool: Tool(ctx)) -> String { tool.description } -/// Get the JSON schema of an untyped tool -pub fn untyped_schema(tool: UntypedTool) -> Json { +/// Get the JSON Schema for sending to the LLM API +pub fn schema(tool: Tool(ctx)) -> Json { tool.schema_json } + +/// Execute the tool with context and JSON arguments +pub fn execute( + tool: Tool(ctx), + ctx: ctx, + arguments_json: String, +) -> Result(String, ExecutionError) { + tool.run(ctx, arguments_json) +} + +/// Execute a tool call, returning a CallResult +pub fn execute_call(tool: Tool(ctx), ctx: ctx, call: Call) -> CallResult { + case tool.run(ctx, call.arguments_json) { + Ok(content) -> CallResult(tool_use_id: call.id, content: Ok(content)) + Error(e) -> + CallResult(tool_use_id: call.id, content: Error(describe_error(e))) + } +} + +// ============================================================================ +// Tool Schema (for requests, without executor) +// ============================================================================ + +/// Extract the schema information from a tool for use in requests +pub fn to_schema(tool: Tool(ctx)) -> Schema { + Schema( + name: tool.name, + description: tool.description, + schema: tool.schema_json, + ) +} diff --git a/test/gai/agent_test.gleam b/test/gai/agent_test.gleam new file mode 100644 index 0000000..5c34001 --- /dev/null +++ b/test/gai/agent_test.gleam @@ -0,0 +1,91 @@ +import gai/agent +import gai/anthropic +import gai/tool +import gleam/option +import sextant + +// Test argument types +type WeatherArgs { + WeatherArgs(location: String, unit: option.Option(Unit)) +} + +type Unit { + Celsius + Fahrenheit +} + +type SearchArgs { + SearchArgs(query: String) +} + +// Test context type +type TestContext + +fn weather_schema() { + use location <- sextant.field("location", sextant.string()) + use unit <- sextant.optional_field( + "unit", + sextant.enum(#("celsius", Celsius), [#("fahrenheit", Fahrenheit)]), + ) + sextant.success(WeatherArgs(location:, unit:)) +} + +fn search_schema() { + use query <- sextant.field("query", sextant.string()) + sextant.success(SearchArgs(query:)) +} + +pub fn agent_creation_test() { + let config = anthropic.new("test-key") + let provider = anthropic.provider(config) + + let my_agent = + agent.new(provider) + |> agent.with_system_prompt("You are helpful") + |> agent.with_max_iterations(5) + + let assert option.Some("You are helpful") = agent.system_prompt(my_agent) + let assert 5 = agent.max_iterations(my_agent) + let assert False = agent.has_tools(my_agent) + Nil +} + +pub fn agent_with_tools_test() { + let config = anthropic.new("test-key") + let provider = anthropic.provider(config) + + let weather_tool = + tool.tool( + name: "get_weather", + description: "Get weather", + schema: weather_schema(), + execute: fn(_ctx: TestContext, args: WeatherArgs) { + Ok("Weather in " <> args.location) + }, + ) + + let search_tool = + tool.tool( + name: "search", + description: "Search web", + schema: search_schema(), + execute: fn(_ctx: TestContext, args: SearchArgs) { + Ok("Results for: " <> args.query) + }, + ) + + let my_agent = + agent.new(provider) + |> agent.with_tool(weather_tool) + |> agent.with_tool(search_tool) + + let assert True = agent.has_tools(my_agent) + let assert 2 = agent.tool_count(my_agent) + + // Find tool by name + let assert option.Some(tool) = agent.find_tool(my_agent, "get_weather") + assert "get_weather" == tool.name(tool) + assert "Get weather" == tool.description(tool) + + assert option.None == agent.find_tool(my_agent, "nonexistent") +} diff --git a/test/gai/request_test.gleam b/test/gai/request_test.gleam index 053715f..8b0a4e7 100644 --- a/test/gai/request_test.gleam +++ b/test/gai/request_test.gleam @@ -113,14 +113,21 @@ type SearchParams { SearchParams(query: String) } +type Ctx + pub fn with_tools_test() { let search_schema = { use query <- sextant.field("query", sextant.string()) sextant.success(SearchParams(query:)) } let search_tool = - tool.new("search", "Search the web", search_schema) - |> tool.to_untyped + tool.tool( + name: "search", + description: "Search the web", + schema: search_schema, + execute: fn(_ctx: Ctx, _args: SearchParams) { Ok("results") }, + ) + |> tool.to_schema let req = request.new("gpt-4o", [gai.user_text("Search for cats")]) diff --git a/test/gai/tool_test.gleam b/test/gai/tool_test.gleam index 84269d1..8c3cb12 100644 --- a/test/gai/tool_test.gleam +++ b/test/gai/tool_test.gleam @@ -14,6 +14,11 @@ type Unit { Fahrenheit } +// Test context +type TestCtx { + TestCtx +} + fn weather_schema() -> sextant.JsonSchema(WeatherParams) { use location <- sextant.field( "location", @@ -30,10 +35,11 @@ fn weather_schema() -> sextant.JsonSchema(WeatherParams) { pub fn new_tool_test() -> Nil { let t = - tool.new( - "get_weather", - "Get current weather for a location", - weather_schema(), + tool.tool( + name: "get_weather", + description: "Get current weather for a location", + schema: weather_schema(), + execute: fn(_ctx: TestCtx, _args) { Ok("sunny") }, ) let assert "get_weather" = tool.name(t) @@ -41,10 +47,16 @@ pub fn new_tool_test() -> Nil { Nil } -pub fn to_json_schema_test() -> Nil { - let weather_tool = tool.new("get_weather", "Get weather", weather_schema()) +pub fn tool_schema_test() -> Nil { + let weather_tool = + tool.tool( + name: "get_weather", + description: "Get weather", + schema: weather_schema(), + execute: fn(_ctx: TestCtx, _args) { Ok("sunny") }, + ) - let json_schema = tool.to_json_schema(weather_tool) + let json_schema = tool.schema(weather_tool) let json_str = json.to_string(json_schema) // Should contain the field definitions @@ -56,49 +68,109 @@ pub fn to_json_schema_test() -> Nil { Nil } -pub fn parse_arguments_success_test() -> Nil { - let weather_tool = tool.new("get_weather", "Get weather", weather_schema()) +pub fn execute_success_test() -> Nil { + let weather_tool = + tool.tool( + name: "get_weather", + description: "Get weather", + schema: weather_schema(), + execute: fn(_ctx: TestCtx, args: WeatherParams) { + let unit_str = case args.unit { + Some(Celsius) -> "celsius" + Some(Fahrenheit) -> "fahrenheit" + None -> "celsius" + } + Ok("Weather in " <> args.location <> ": 20 " <> unit_str) + }, + ) let args_json = "{\"location\":\"London\",\"unit\":\"celsius\"}" - let assert Ok(WeatherParams(location: "London", unit: Some(Celsius))) = - tool.parse_arguments(weather_tool, args_json) - Nil + assert Ok("Weather in London: 20 celsius") + == tool.execute(weather_tool, TestCtx, args_json) } -pub fn parse_arguments_optional_missing_test() -> Nil { - let weather_tool = tool.new("get_weather", "Get weather", weather_schema()) +pub fn execute_optional_missing_test() -> Nil { + let weather_tool = + tool.tool( + name: "get_weather", + description: "Get weather", + schema: weather_schema(), + execute: fn(_ctx: TestCtx, args: WeatherParams) { + let unit_str = case args.unit { + Some(Celsius) -> "celsius" + Some(Fahrenheit) -> "fahrenheit" + None -> "default" + } + Ok("Weather in " <> args.location <> ": " <> unit_str) + }, + ) let args_json = "{\"location\":\"Paris\"}" - let assert Ok(WeatherParams(location: "Paris", unit: None)) = - tool.parse_arguments(weather_tool, args_json) - Nil + assert Ok("Weather in Paris: default") + == tool.execute(weather_tool, TestCtx, args_json) } -pub fn parse_arguments_invalid_test() -> Nil { - let weather_tool = tool.new("get_weather", "Get weather", weather_schema()) +pub fn execute_invalid_test() -> Nil { + let weather_tool = + tool.tool( + name: "get_weather", + description: "Get weather", + schema: weather_schema(), + execute: fn(_ctx: TestCtx, _args: WeatherParams) { Ok("ok") }, + ) // Missing required field let args_json = "{}" - let assert Error(_errors) = tool.parse_arguments(weather_tool, args_json) - Nil + assert Error(tool.ParseError("Validation failed: missing field 'location'")) + == tool.execute(weather_tool, TestCtx, args_json) } -// UntypedTool tests +// ToolSchema tests + +pub fn execute_with_complex_args_test() -> Nil { + // This test proves args can be any type, not just String + let weather_tool = + tool.tool( + name: "get_weather", + description: "Get weather", + schema: weather_schema(), + execute: fn(_ctx: TestCtx, args: WeatherParams) { + // args.unit is Option(Unit), not String! + let unit_name = case args.unit { + Some(Celsius) -> "°C" + Some(Fahrenheit) -> "°F" + None -> "°C" + } + // args.location is String + Ok(args.location <> ": 20" <> unit_name) + }, + ) -pub fn to_untyped_test() -> Nil { - let weather_tool = tool.new("get_weather", "Get weather", weather_schema()) + // Test with enum value - this would fail if args was String + assert Ok("Tokyo: 20°F") + == tool.execute( + weather_tool, + TestCtx, + "{\"location\":\"Tokyo\",\"unit\":\"fahrenheit\"}", + ) +} - let untyped = tool.to_untyped(weather_tool) +pub fn to_schema_test() -> Nil { + let weather_tool = + tool.tool( + name: "get_weather", + description: "Get weather", + schema: weather_schema(), + execute: fn(_ctx: TestCtx, _args: WeatherParams) { Ok("ok") }, + ) - let assert "get_weather" = tool.untyped_name(untyped) - let assert "Get weather" = tool.untyped_description(untyped) + let assert tool.Schema("get_weather", "Get weather", schema) = + tool.to_schema(weather_tool) // JSON schema should still be valid - let schema_json = tool.untyped_schema(untyped) - let json_str = json.to_string(schema_json) - assert string.contains(json_str, "location") - Nil + assert "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"required\":[\"location\"],\"type\":\"object\",\"properties\":{\"location\":{\"description\":\"City name\",\"type\":\"string\"},\"unit\":{\"type\":\"string\",\"enum\":[\"celsius\",\"fahrenheit\"]}},\"additionalProperties\":false}" + == json.to_string(schema) } diff --git a/test/integration_test.gleam b/test/integration_test.gleam index bb7c05c..bf78177 100644 --- a/test/integration_test.gleam +++ b/test/integration_test.gleam @@ -1,127 +1,329 @@ /// Integration tests demonstrating full request/response flows. +/// +/// This file contains two types of tests: +/// 1. Agent-based tests - High-level API with automatic tool execution +/// 2. Request-based tests - Low-level API for manual control import gai +import gai/agent +import gai/agent/loop import gai/anthropic import gai/google import gai/openai import gai/provider import gai/request import gai/response +import gai/runtime import gai/streaming import gai/tool import gleam/http/response as http_response -import gleam/option +import gleam/list +import gleam/option.{Some} import sextant // ============================================================================ -// OpenAI Integration Test +// Shared Types // ============================================================================ pub type SearchParams { SearchParams(query: String) } +type TestCtx { + TestCtx(context: String) +} + fn search_schema() -> sextant.JsonSchema(SearchParams) { use query <- sextant.field("query", sextant.string()) sextant.success(SearchParams(query:)) } -pub fn openai_full_flow_test() { - // 1. Create config +// ============================================================================ +// Agent Integration Tests (High-level API) +// ============================================================================ + +pub fn agent_openai_integration_test() { + // 1. Create provider let config = openai.new("sk-test-key") + let provider = openai.provider(config) - // 2. Create tool + // 2. Create tool with executor let search_tool = - tool.new("search", "Search the web", search_schema()) - |> tool.to_untyped + tool.tool( + name: "search", + description: "Search the web", + schema: search_schema(), + execute: fn(ctx: TestCtx, args: SearchParams) { + Ok("Context: " <> ctx.context <> ". Query: " <> args.query) + }, + ) + + // 3. Create agent + let my_agent = + agent.new(provider) + |> agent.with_system_prompt("You are a helpful assistant.") + |> agent.with_tool(search_tool) + |> agent.with_max_iterations(3) + + // 4. Create mock runtime that returns a tool call, then a final response + let mock_runtime = + runtime.new(fn(req) { + case + req.body + == "{\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"search\",\"description\":\"Search the web\",\"parameters\":{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"required\":[\"query\"],\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"additionalProperties\":false}}}],\"model\":\"openai\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a helpful assistant.\"},{\"role\":\"user\",\"content\":\"Search for Gleam programming language\"}]}" + { + False -> + // Second call: LLM returns final response after tool execution + Ok( + http_response.new(200) + |> http_response.set_body( + "{ + \"id\": \"chatcmpl-2\", + \"model\": \"gpt-4o\", + \"choices\": [{ + \"message\": { + \"role\": \"assistant\", + \"content\": \"The search results are: Context: I am the context. Query: Gleam language\" + }, + \"finish_reason\": \"stop\" + }], + \"usage\": {\"prompt_tokens\": 70, \"completion_tokens\": 30} + }", + ), + ) + True -> + Ok( + http_response.new(200) + |> http_response.set_body( + "{ + \"id\": \"chatcmpl-1\", + \"model\": \"gpt-4o\", + \"choices\": [{ + \"message\": { + \"role\": \"assistant\", + \"content\": null, + \"tool_calls\": [{ + \"id\": \"call_123\", + \"type\": \"function\", + \"function\": { + \"name\": \"search\", + \"arguments\": \"{\\\"query\\\": \\\"Gleam language\\\"}\" + } + }] + }, + \"finish_reason\": \"tool_calls\" + }], + \"usage\": {\"prompt_tokens\": 50, \"completion_tokens\": 20} + }", + ), + ) + } + }) + + // 5. Run the agent + let ctx = TestCtx("I am the context") + let messages = [gai.user_text("Search for Gleam programming language")] + + let assert Ok(loop.RunResult( + response: response.CompletionResponse( + "chatcmpl-2", + "gpt-4o", + [ + gai.Text( + "The search results are: Context: I am the context. Query: Gleam language", + ), + ], + gai.EndTurn, + gai.Usage(70, 30, option.None, option.None), + ), + messages: [ + gai.Message( + gai.System, + [gai.Text("You are a helpful assistant.")], + option.None, + ), + gai.Message( + gai.User, + [gai.Text("Search for Gleam programming language")], + option.None, + ), + gai.Message( + gai.Assistant, + [gai.ToolUse("call_123", "search", "{\"query\": \"Gleam language\"}")], + option.None, + ), + gai.Message( + gai.User, + [ + gai.ToolResult( + tool_use_id: "call_123", + content: [ + gai.Text("Context: I am the context. Query: Gleam language"), + ], + ), + ], + option.None, + ), + gai.Message( + gai.Assistant, + [ + gai.Text( + "The search results are: Context: I am the context. Query: Gleam language", + ), + ], + option.None, + ), + ], + iterations: 2, + )) = loop.run(my_agent, ctx, messages, mock_runtime) +} + +pub fn agent_with_config_test() { + // Test that we can pass request config to the agent loop + let config = anthropic.new("sk-ant-test") + let provider = anthropic.provider(config) + + let my_agent = + agent.new(provider) + |> agent.with_system_prompt("You are Claude.") + + // Create a mock runtime + let mock_runtime = + runtime.new(fn(_req) { + let json_body = + "{ + \"id\": \"msg_1\", + \"model\": \"claude-3-opus\", + \"content\": [{\"type\": \"text\", \"text\": \"Hello!\"}], + \"stop_reason\": \"end_turn\", + \"usage\": {\"input_tokens\": 10, \"output_tokens\": 5} + }" + Ok( + http_response.new(200) + |> http_response.set_body(json_body), + ) + }) + + let messages = [gai.user_text("Hi")] + + // Run with custom config (max_tokens, temperature) + let result = + loop.run_with_config( + my_agent, + Nil, + messages, + mock_runtime, + Some(fn(req) { + req + |> request.with_max_tokens(100) + |> request.with_temperature(0.7) + }), + ) + + let assert Ok(run_result) = result + let assert "Hello!" = response.text_content(run_result.response) + let assert 1 = run_result.iterations + Nil +} + +// ============================================================================ +// Request Integration Tests (Low-level API) +// ============================================================================ + +pub fn openai_request_test() { + // Low-level test: build request manually + let config = openai.new("sk-test-key") + + let search_tool = + tool.tool( + name: "search", + description: "Search the web", + schema: search_schema(), + execute: fn(_ctx: TestCtx, _args: SearchParams) { Ok("results") }, + ) + |> tool.to_schema - // 3. Build request let req = request.new("gpt-4o", [ gai.system("You are a helpful assistant."), - gai.user_text("Search for Gleam programming language"), + gai.user_text("Search for Gleam"), ]) |> request.with_max_tokens(100) |> request.with_temperature(0.7) |> request.with_tools([search_tool]) |> request.with_tool_choice(request.Auto) - // 4. Build HTTP request let http_req = openai.build_request(config, req) - // Verify request was built let assert "api.openai.com" = http_req.host assert http_req.body != "" + Nil +} + +pub fn anthropic_request_test() { + let config = anthropic.new("sk-ant-test") - // 5. Simulate response + let req = + request.new("claude-3-opus-20240229", [ + gai.system("You are Claude."), + gai.user_text("What is 2+2?"), + ]) + |> request.with_max_tokens(100) + + let http_req = anthropic.build_request(config, req) + + let assert "api.anthropic.com" = http_req.host + Nil +} + +pub fn google_request_test() { + let config = google.new("test-api-key") + + let req = + request.new("gemini-1.5-pro", [ + gai.system("You are Gemini."), + gai.user_text("Hello!"), + ]) + |> request.with_max_tokens(50) + + let http_req = google.build_request(config, req) + + let assert "generativelanguage.googleapis.com" = http_req.host + Nil +} + +// ============================================================================ +// Response Parsing Tests +// ============================================================================ + +pub fn openai_response_parsing_test() { let json_body = "{ - \"id\": \"chatcmpl-integration\", - \"model\": \"gpt-4o-2024-05-13\", + \"id\": \"chatcmpl-test\", + \"model\": \"gpt-4o\", \"choices\": [{ \"message\": { \"role\": \"assistant\", - \"content\": null, - \"tool_calls\": [{ - \"id\": \"call_abc\", - \"type\": \"function\", - \"function\": { - \"name\": \"search\", - \"arguments\": \"{\\\"query\\\": \\\"Gleam programming language\\\"}\" - } - }] + \"content\": \"Hello world!\" }, - \"finish_reason\": \"tool_calls\" + \"finish_reason\": \"stop\" }], - \"usage\": {\"prompt_tokens\": 50, \"completion_tokens\": 20} + \"usage\": {\"prompt_tokens\": 10, \"completion_tokens\": 5} }" let http_resp = http_response.new(200) |> http_response.set_body(json_body) - // 6. Parse response let assert Ok(completion) = openai.parse_response(http_resp) - - // 7. Verify response - assert response.has_tool_calls(completion) - let assert response.CompletionResponse(stop_reason: gai.ToolUsed, ..) = - completion - let assert [gai.ToolUse(id: "call_abc", name: "search", arguments_json:)] = - response.tool_calls(completion) - - // 8. Parse tool arguments using the typed tool - let typed_tool = tool.new("search", "Search the web", search_schema()) - let assert Ok(SearchParams(query: "Gleam programming language")) = - tool.parse_arguments(typed_tool, arguments_json) + let assert "Hello world!" = response.text_content(completion) + Nil } -// ============================================================================ -// Anthropic Integration Test -// ============================================================================ - -pub fn anthropic_full_flow_test() { - // 1. Create config - let config = anthropic.new("sk-ant-test") - - // 2. Build request - let req = - request.new("claude-3-opus-20240229", [ - gai.system("You are Claude."), - gai.user_text("What is 2+2?"), - ]) - |> request.with_max_tokens(100) - - // 3. Build HTTP request - let http_req = anthropic.build_request(config, req) - - // Verify request was built - let assert "api.anthropic.com" = http_req.host - - // 4. Simulate response +pub fn anthropic_response_parsing_test() { let json_body = "{ - \"id\": \"msg_integration\", - \"model\": \"claude-3-opus-20240229\", + \"id\": \"msg_test\", + \"model\": \"claude-3-opus\", \"content\": [{\"type\": \"text\", \"text\": \"2 + 2 = 4\"}], \"stop_reason\": \"end_turn\", \"usage\": {\"input_tokens\": 20, \"output_tokens\": 10} @@ -131,43 +333,17 @@ pub fn anthropic_full_flow_test() { http_response.new(200) |> http_response.set_body(json_body) - // 5. Parse response let assert Ok(completion) = anthropic.parse_response(http_resp) - - // 6. Verify response let assert "2 + 2 = 4" = response.text_content(completion) - let assert response.CompletionResponse(stop_reason: gai.EndTurn, ..) = - completion + Nil } -// ============================================================================ -// Google Gemini Integration Test -// ============================================================================ - -pub fn google_full_flow_test() { - // 1. Create config - let config = google.new("test-api-key") - - // 2. Build request - let req = - request.new("gemini-1.5-pro", [ - gai.system("You are Gemini."), - gai.user_text("Hello!"), - ]) - |> request.with_max_tokens(50) - - // 3. Build HTTP request - let http_req = google.build_request(config, req) - - // Verify request was built - let assert "generativelanguage.googleapis.com" = http_req.host - - // 4. Simulate response +pub fn google_response_parsing_test() { let json_body = "{ \"candidates\": [{ \"content\": { - \"parts\": [{\"text\": \"Hello! How can I help you?\"}], + \"parts\": [{\"text\": \"Hello from Gemini!\"}], \"role\": \"model\" }, \"finishReason\": \"STOP\" @@ -179,11 +355,9 @@ pub fn google_full_flow_test() { http_response.new(200) |> http_response.set_body(json_body) - // 5. Parse response let assert Ok(completion) = google.parse_response(http_resp) - - // 6. Verify response - let assert "Hello! How can I help you?" = response.text_content(completion) + let assert "Hello from Gemini!" = response.text_content(completion) + Nil } // ============================================================================ @@ -191,7 +365,6 @@ pub fn google_full_flow_test() { // ============================================================================ pub fn provider_abstraction_test() { - // Test that the Provider type enables provider-agnostic code let openai_provider = openai.new("sk-test") |> openai.provider @@ -213,67 +386,32 @@ pub fn provider_abstraction_test() { let _anthropic_http = provider.build_request(anthropic_provider, req) let _google_http = provider.build_request(google_provider, req) - // Provider names are correct let assert "openai" = provider.name(openai_provider) let assert "anthropic" = provider.name(anthropic_provider) let assert "google" = provider.name(google_provider) + Nil } // ============================================================================ -// Streaming Integration Test +// Streaming Test // ============================================================================ -pub fn streaming_integration_test() { - // Simulate a complete streaming session +pub fn streaming_test() { let raw_sse = - "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" \"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"world!\"}}]}\n\ndata: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2}}\n\ndata: [DONE]\n\n" + "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" world!\"}}]}\n\ndata: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2}}\n\ndata: [DONE]\n\n" - // 1. Parse SSE events let events = streaming.parse_sse(raw_sse) - let assert 5 = length(events) + let assert 4 = list.length(events) - // 2. Process each event through OpenAI parser let deltas = events - |> filter_map(fn(e) { - case openai.parse_stream_chunk(e) { - Ok(d) -> option.Some(d) - Error(_) -> option.None - } - }) + |> list.filter_map(openai.parse_stream_chunk) - // 3. Accumulate into final response let acc = deltas - |> fold(streaming.new_accumulator(), streaming.accumulate) + |> list.fold(streaming.new_accumulator(), streaming.accumulate) - // 4. Finish and verify let assert Ok(completion) = streaming.finish(acc) let assert "Hello world!" = response.text_content(completion) -} - -// Helper functions for tests -fn length(list: List(a)) -> Int { - case list { - [] -> 0 - [_, ..rest] -> 1 + length(rest) - } -} - -fn filter_map(list: List(a), f: fn(a) -> option.Option(b)) -> List(b) { - case list { - [] -> [] - [first, ..rest] -> - case f(first) { - option.Some(b) -> [b, ..filter_map(rest, f)] - option.None -> filter_map(rest, f) - } - } -} - -fn fold(list: List(a), acc: b, f: fn(b, a) -> b) -> b { - case list { - [] -> acc - [first, ..rest] -> fold(rest, f(acc, first), f) - } + Nil }