Skip to content

Commit

Permalink
Merge pull request #1 from lebrunel/tools
Browse files Browse the repository at this point in the history
Agents
  • Loading branch information
lebrunel authored Mar 13, 2024
2 parents d89f84c + 6e0e39e commit b272197
Show file tree
Hide file tree
Showing 13 changed files with 958 additions and 87 deletions.
107 changes: 83 additions & 24 deletions lib/anthropix.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ defmodule Anthropix do
- 🛜 Streaming API requests
- Stream to an Enumerable
- Or stream messages to any Elixir process
- 🔜 Advanced and flexible function calling workflow
- 🧩 Advanced and flexible function calling workflow
This library is currently a WIP. Check back in a week or two, by which point
it should be bangin!
Expand All @@ -33,6 +33,7 @@ defmodule Anthropix do
TODO...
"""
use Anthropix.Schemas
alias Anthropix.{APIError, Tool, XML}

defstruct [:req]

Expand All @@ -42,24 +43,24 @@ defmodule Anthropix do
}


schema :content, [
schema :message_content, [
type: [type: :string, required: true],
text: [type: :string],
content: [type: :map, keys: [
source: [type: :map, keys: [
type: [type: :string, required: :true],
media_type: [type: :string, required: :true],
data: [type: :string, required: :true],
]]
]

schema :message, [
schema :chat_message, [
role: [
type: :string,
required: true,
doc: "The role of the message, either `user` or `assistant`."
],
content: [
type: {:or, [:string, {:list, {:map, schema(:content).schema}}]},
type: {:or, [:string, {:list, {:map, schema(:message_content).schema}}]},
required: true,
doc: "Message content, either a single string or an array of content blocks."
]
Expand All @@ -70,13 +71,27 @@ defmodule Anthropix do
A chat message is a `t:map/0` with the following fields:
#{doc(:message)}
#{doc(:chat_message)}
"""
@type message() :: map()
@type message() :: %{
role: String.t(),
content: String.t() | list(content_block())
}

@typedoc "Message content block."
@type content_block() :: %{
:type => String.t(),
optional(:text) => String.t(),
optional(:source) => %{
type: String.t(),
media_type: String.t(),
data: String.t(),
}
}

@typedoc "Client response"
@type response() ::
{:ok, map() | boolean() | Enumerable.t() | Task.t()} |
{:ok, map() | Enumerable.t() | Task.t()} |
{:error, term()}

@typep req_response() ::
Expand Down Expand Up @@ -143,14 +158,14 @@ defmodule Anthropix do
end


schema :messages, [
schema :chat, [
model: [
type: :string,
required: true,
doc: "The model that will complete your prompt.",
],
messages: [
type: {:list, {:map, schema(:message).schema}},
type: {:list, {:map, schema(:chat_message).schema}},
required: true,
doc: "Input messages.",
],
Expand All @@ -176,6 +191,10 @@ defmodule Anthropix do
default: false,
doc: "Whether to incrementally stream the response using server-sent events.",
],
tools: [
type: {:list, {:struct, Tool}},
doc: "A list of tools the model may call.",
],
temperature: [
type: :float,
doc: "Amount of randomness injected into the response."
Expand All @@ -191,18 +210,18 @@ defmodule Anthropix do
]

@doc """
Send a structured list of input messages with text and/or image content, and
the model will generate the next message in the conversation.
Chat with Claude. Send a list of structured input messages with text and/or
image content, and Claude will generate the next message in the conversation.
## Options
#{doc(:messages)}
#{doc(:chat)}
## Message structure
Each message is a map with the following fields:
#{doc(:message)}
#{doc(:chat_message)}
## Examples
Expand All @@ -214,7 +233,7 @@ defmodule Anthropix do
...> %{role: "user", content: "How is that different than mie scattering?"},
...> ]
iex> Anthropix.messages(client, [
iex> Anthropix.chat(client, [
...> model: "claude-3-opus-20240229",
...> messages: messages,
...> ])
Expand All @@ -224,24 +243,64 @@ defmodule Anthropix do
}], ...}}
# Passing true to the :stream option initiates an async streaming request.
iex> Anthropix.messages(client, [
iex> Anthropix.chat(client, [
...> model: "claude-3-opus-20240229",
...> messages: messages,
...> stream: true,
...> ])
{:ok, #Function<52.53678557/2 in Stream.resource/3>}
```
"""
@spec messages(client(), keyword()) :: any()
def messages(%__MODULE__{} = client, params \\ []) do
with {:ok, params} <- NimbleOptions.validate(params, schema(:messages)) do
@spec chat(client(), keyword()) :: response()
def chat(%__MODULE__{} = client, params \\ []) do
with {:ok, params} <- NimbleOptions.validate(params, schema(:chat)) do
params =
params
|> use_tools()
|> Enum.into(%{})

client
|> req(:post, "/messages", json: Enum.into(params, %{}))
|> req(:post, "/messages", json: params)
|> res()
end
end


# If the params contains tools, setup the system prompt and stop sequnces
@spec use_tools(keyword()) :: keyword()
defp use_tools(params) do
case Keyword.get(params, :tools) do
tools when is_list(tools) and length(tools) > 0 ->
prompt = """
In this environment you have access to a set of tools you can use to answer the user's question.
You may call them like this:
<function_calls>
<invoke>
<tool_name>$TOOL_NAME</tool_name>
<parameters>
<$PARAMETER_NAME>$PARAMETER_VALUE</$PARAMETER_NAME>
...
</parameters>
</invoke>
</function_calls>
Here are the tools available:
#{XML.encode(:tools, tools)}
"""
stop = "</function_calls>"
params
|> Keyword.delete(:tools)
|> Keyword.update(:stop_sequences, [stop], & [stop | &1])
|> Keyword.update(:system, prompt, & prompt <> "\n" <> &1)

_ ->
params
end
end

# Builds the request from the given params
@spec req(client(), atom(), Req.url(), keyword()) :: req_response()
defp req(%__MODULE__{req: req}, method, url, opts) do
Expand Down Expand Up @@ -274,8 +333,8 @@ defmodule Anthropix do
{:ok, body}
end

defp res({:ok, %{status: status}}) do
{:error, Anthropix.HTTPError.exception(status)}
defp res({:ok, %{body: body}}) do
{:error, APIError.exception(body)}
end

defp res({:error, error}), do: {:error, error}
Expand Down Expand Up @@ -317,8 +376,8 @@ defmodule Anthropix do
{^ref, {:ok, %Req.Response{status: status}}} when status in 200..299 ->
{:halt, task}

{^ref, {:ok, %Req.Response{status: status}}} ->
raise Anthropix.HTTPError.exception(status)
{^ref, {:ok, %Req.Response{body: body}}} ->
raise APIError.exception(body)

{^ref, {:error, error}} ->
raise error
Expand Down
156 changes: 156 additions & 0 deletions lib/anthropix/agent.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
defmodule Anthropix.Agent do
@moduledoc """
The `Agent` module makes function calling with Claude a breeze!
Whilst it's possible to manually implement function calling using
`Anthropix.chat/2`, this module provides an interface on top that automates
the entire flow. Function calling is reduced to the following steps.
1. **Defining functions** - define one or more functions through
`Anthropix.Tool.new/1`.
2. **Initialise the agent** - initialise the agent by passing a
`t:Anthropix.client/0` client and list of tools to `init/2`
3. **Chat with Claude** - chat with Clause just how you normally would, using
`chat/2`. Where Claude attempts to call functions, Anthropix will handle that
automatically, send the result back to Claude, iterating as many times as is
necessary before ultimately a final result is returned.
`chat/2` returns a `t:t()` struct, which contains a list of all
messages, a sum of all usage statistics, as well as the final response.
## Example
Define your functions as tools. Remember, we're working with language models,
so provide clear descriptions for the functions and their parameters.
```elixir
iex> ticker_tool = %Anthropix.Tool.new([
...> name: "get_ticker_symbol",
...> description: "Gets the stock ticker symbol for a company searched by name. Returns str: The ticker symbol for the company stock. Raises TickerNotFound: if no matching ticker symbol is found.",
...> params: [
...> %{name: "company_name", description: "The name of the company.", type: "string"}
...> ],
...> function: {MyApp.Repo, :get_ticker, []}
...> ])
iex> price_tool = %Anthropix.Tool.new([
...> name: "get_current_stock_price",
...> description: "Gets the current stock price for a company. Returns float: The current stock price. Raises ValueError: if the input symbol is invalid/unknown.",
...> params: [
...> %{name: "symbol", description: "The stock symbol of the company to get the price for.", type: "string"}
...> ],
...> function: {MyApp.Repo, :get_price, []}
...> ])
```
Initialise the agent and chat with it. `chat/2` accepts the same parameters as
`Anthropix.chat/2` so can be combined with custom system prompts, chat
history, and any other parameters.
```elixir
iex> agent = Anthropix.Agent.init(
...> Anthropix.init(api_key),
...> [ticker_tool, price_tool]
...> )
iex> Anthropix.Agent.chat(agent, [
...> model: "claude-3-sonnet-20240229",
...> system: "Answer like Snoop Dogg.",
...> messages: [
...> %{role: "user", content: "What is the current stock price of General Motors?"}
...> ]
...> ])
%{
result: %{
"content" => [%{
"type" => "text",
"text" => "*snaps fingers* Damn shawty, General Motors' stock is sittin' pretty at $39.21 per share right now. Dat's a fly price for them big ballers investin' in one of Detroit's finest auto makers, ya heard? *puts hands up like car doors* If ya askin' Snoop, dat stock could be rollin' on some dubs fo' sho'. Just don't get caught slippin' when them prices dippin', ya dig?"
}]
}
}
```
## Streaming
The `:stream` option is currently ignored on `chat/2` in this modlule, so all
function calling requests are lengthy blocking calls whilst multiple
sequential requests are occuring behind the scenes.
This will hopefully change in a future version, but figuring out what and how
to stream from the multiple requests is less trivial than I'd like it to be.
"""
alias Anthropix.{FunctionCall, Tool, XML}

defstruct client: nil,
tools: [],
messages: [],
result: nil,
usage: nil

@typedoc "Agent struct"
@type t() :: %__MODULE__{
client: Anthropix.client(),
tools: list(Tool.t()),
messages: list(map()),
result: map(),
usage: map(),
}

@doc """
Creates a new Agent struct from the given `t:Anthropix.client/0` and list of
tools.
"""
@spec init(Anthropix.client(), list(Tool.t())) :: t()
def init(%Anthropix{} = client, tools) do
struct(__MODULE__, client: client, tools: tools)
end

@doc """
Chat with Claude, using the given agent. Accepts the same parameters as
`Anthropix.chat/2`.
Note, the `:stream` option is currently ignored for this function.
See the example as the [top of this page](#module-example).
"""
@spec chat(t(), keyword()) :: {:ok, t()} | {:error, term()}
def chat(%__MODULE__{client: client, tools: tools} = agent, params \\ []) do
params = Keyword.merge(params, tools: tools, stream: false)

with {:ok, res} <- Anthropix.chat(client, params) do
%{"text" => content} = hd(res["content"])

agent =
agent
|> Map.put(:messages, Keyword.get(params, :messages))
|> Map.update!(:usage, & update_usage(&1, res["usage"]))

case FunctionCall.extract!(content) do
[] ->
{:ok, Map.put(agent, :result, res)}

functions ->
functions = FunctionCall.invoke_all(functions, tools)

params = Keyword.update!(params, :messages, fn messages ->
messages ++ [
%{role: "assistant", content: content},
%{role: "user", content: XML.encode(:function_results, functions)}
]
end)

chat(agent, params)
end
end
end


# Merges the new usage stats into the previous
defp update_usage(nil, new), do: new
defp update_usage(prev, new) do
prev
|> Enum.map(fn {key, val} -> {key, val + Map.get(new, key)} end)
|> Enum.into(%{})
end

end
13 changes: 13 additions & 0 deletions lib/anthropix/api_error.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Anthropix.APIError do
@moduledoc false
defexception [:type, :message]

@impl true
def exception(%{"error" => %{"type" => type, "message" => message}}) do
struct(__MODULE__, [
type: type,
message: message,
])
end

end
Loading

0 comments on commit b272197

Please sign in to comment.