├── test ├── test_helper.exs └── agent_chain_test.exs ├── .formatter.exs ├── lib ├── agent_executor │ └── step.ex ├── agent_executor_lite.ex ├── graph_agent.ex ├── agent_chain.ex └── agent_executor.ex ├── .gitignore ├── README.md ├── mix.exs ├── notebooks ├── agent-prototyping.livemd └── essay-writer.livemd ├── mix.lock └── LICENSE.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Mimic.copy(LangChain.ChatModels.ChatOpenAI) 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/agent_executor/step.ex: -------------------------------------------------------------------------------- 1 | defmodule Magus.AgentExecutor.Step do 2 | defstruct [ 3 | :node, 4 | status: :notstarted, 5 | input_state: nil, 6 | output_state: nil, 7 | pid: nil 8 | ] 9 | end 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # The directory Mix will write compiled artifacts to. 4 | /_build/ 5 | 6 | # If you run "mix test --cover", coverage assets end up here. 7 | /cover/ 8 | 9 | # The directory Mix downloads your dependencies sources to. 10 | /deps/ 11 | 12 | # Where third-party dependencies like ExDoc output generated docs. 13 | /doc/ 14 | 15 | # Ignore .fetch files in case you like to edit your project deps locally. 16 | /.fetch 17 | 18 | # If the VM crashes, it generates a dump, let's ignore it too. 19 | erl_crash.dump 20 | 21 | # Also ignore archive artifacts (built via "mix archive.build"). 22 | *.ez 23 | 24 | # Ignore package tarball (built via "mix hex.build"). 25 | magus-*.tar 26 | 27 | # Temporary files, for example, from tests. 28 | /tmp/ 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magus 2 | 3 | [![Module Version](https://img.shields.io/hexpm/v/magus.svg)](https://hex.pm/packages/magus) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/magus/) 5 | [![License](https://img.shields.io/hexpm/l/magus.svg)](https://github.com/poffdeluxe/magus/blob/main/LICENSE.md) 6 | 7 | Magus is a proof-of-concept libray for implementing and running graph-based agents in Elixir. 8 | 9 | Magus provides a simple interface in `Magus.GraphAgent` for defining agents and their flows. 10 | These agent definitions can then be run using either the `Magus.AgentExecutor` (which creates a 11 | GenServer for storing state as the agent runs asynchronously) and `Magus.AgentExecutorLite` (which 12 | steps through th agent graph synchronously in the same process) 13 | 14 | ## Examples 15 | Livebooks with a few examples can be found in the `notebooks/` directory. You will need to set a `OPENAI_KEY` secret in your Livebook. 16 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Magus.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :magus, 7 | name: "Magus", 8 | description: "A very simple library for implementing graph-based LLM agents in Elixir", 9 | version: "0.2.0", 10 | elixir: "~> 1.18", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | package: package() 14 | ] 15 | end 16 | 17 | def application do 18 | [ 19 | extra_applications: [:logger] 20 | ] 21 | end 22 | 23 | def package do 24 | [ 25 | licenses: ["Apache-2.0"], 26 | links: %{ 27 | "GitHub" => "https://github.com/poffdeluxe/magus" 28 | } 29 | ] 30 | end 31 | 32 | defp deps do 33 | [ 34 | {:ex_doc, "~> 0.31", only: :dev, runtime: false}, 35 | {:ex_json_schema, "~> 0.10.2"}, 36 | {:langchain, "~> 0.3.1"}, 37 | {:libgraph, "~> 0.16.0"}, 38 | {:mimic, "~> 1.10", only: :test}, 39 | {:phoenix_pubsub, "~> 2.0"}, 40 | {:retry, "~> 0.18"} 41 | ] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/agent_executor_lite.ex: -------------------------------------------------------------------------------- 1 | defmodule Magus.AgentExecutorLite do 2 | alias Magus.GraphAgent 3 | 4 | @spec run(%GraphAgent{}) :: any() 5 | def run(%GraphAgent{} = agent) do 6 | do_step(agent, agent.initial_state, agent.entry_point_node) 7 | end 8 | 9 | defp do_step(agent, cur_state, :end) do 10 | # Return our final output state value or the whole state if an output state is not specified 11 | final_output_property = agent |> Map.get(:final_output_property) 12 | 13 | case final_output_property do 14 | nil -> cur_state 15 | _ -> Map.get(cur_state, final_output_property) 16 | end 17 | end 18 | 19 | defp do_step(%GraphAgent{} = agent, cur_state, cur_node) do 20 | cur_node_fn = agent.node_to_fn[cur_node] 21 | 22 | chain = Magus.AgentChain.new!() 23 | next_state = cur_node_fn.(chain, cur_state) 24 | 25 | # Find next edge to go to 26 | neighbors = agent.graph |> Graph.out_neighbors(cur_node) 27 | 28 | next_node = 29 | if length(neighbors) > 1 do 30 | # We need to call a conditional function to figure out what node to move to 31 | conditional_fn = agent.node_to_conditional_fn[cur_node] 32 | conditional_fn.(next_state) 33 | else 34 | neighbors |> Enum.at(0) 35 | end 36 | 37 | do_step(agent, next_state, next_node) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/graph_agent.ex: -------------------------------------------------------------------------------- 1 | defmodule Magus.GraphAgent do 2 | alias Magus.GraphAgent 3 | 4 | defstruct [ 5 | :name, 6 | :entry_point_node, 7 | initial_state: %{}, 8 | graph: Graph.new(), 9 | final_output_property: nil, 10 | node_to_fn: %{}, 11 | node_to_conditional_fn: %{}, 12 | cleanup_fn: nil 13 | ] 14 | 15 | @type t() :: %GraphAgent{} 16 | @type node_name() :: atom() 17 | 18 | @spec add_node(t(), node_name(), fun()) :: t() 19 | def add_node(agent, node, node_fn) do 20 | g = agent.graph |> Graph.add_vertex(node) 21 | node_to_fn = agent.node_to_fn |> Map.put(node, node_fn) 22 | 23 | %{agent | graph: g, node_to_fn: node_to_fn} 24 | end 25 | 26 | @spec add_edge(t(), node_name(), node_name()) :: t() 27 | def add_edge(agent, node_a, node_b) do 28 | g = agent.graph |> Graph.add_edge(node_a, node_b) 29 | 30 | %{agent | graph: g} 31 | end 32 | 33 | @spec add_conditional_edges(t(), node_name(), list(node_name()), fun()) :: t() 34 | def add_conditional_edges(agent, node_a, possible_nodes, conditional_fn) do 35 | edges = Enum.map(possible_nodes, fn end_node -> {node_a, end_node} end) 36 | g = agent.graph |> Graph.add_edges(edges) 37 | 38 | %{ 39 | agent 40 | | graph: g, 41 | node_to_conditional_fn: Map.put(agent.node_to_conditional_fn, node_a, conditional_fn) 42 | } 43 | end 44 | 45 | @spec set_entry_point(t(), node_name()) :: t() 46 | def set_entry_point(agent, node) do 47 | %{agent | entry_point_node: node} 48 | end 49 | 50 | @spec get_final_output(t(), any()) :: any() 51 | def get_final_output(%GraphAgent{final_output_property: nil}, _cur_state) do 52 | # No final output property specified 53 | "" 54 | end 55 | 56 | @spec get_final_output(t(), any()) :: any() 57 | def get_final_output(agent, cur_state) do 58 | Map.fetch!(cur_state, agent.final_output_property) 59 | end 60 | 61 | @spec cleanup(t(), any()) :: t() 62 | def cleanup(agent, cur_state) 63 | 64 | def cleanup(%GraphAgent{cleanup_fn: nil} = agent, _cur_state) do 65 | agent 66 | end 67 | 68 | def cleanup(%GraphAgent{cleanup_fn: cleanup_fn} = agent, cur_state) do 69 | cleanup_fn.(cur_state) 70 | agent 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /notebooks/agent-prototyping.livemd: -------------------------------------------------------------------------------- 1 | # Agent Prototyping 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:magus, path: "#{__DIR__}/.."}, 6 | {:kino, "~> 0.12.0"} 7 | # {:ex_dot, "~> 0.1.0"} 8 | ]) 9 | 10 | Application.put_env(:magus, :model_provider, "openai") 11 | Application.put_env(:magus, :openai_key, System.fetch_env!("LB_OPENAI_KEY")) 12 | ``` 13 | 14 | ## New Agent 15 | 16 | ```elixir 17 | alias LangChain.Message 18 | alias Magus.GraphAgent 19 | alias Magus.AgentChain 20 | 21 | write_story_node = fn chain, state -> 22 | prompt = "Write a brief story about \"#{state.topic}\" that features three different characters" 23 | 24 | {:ok, content, _last_message} = 25 | chain 26 | |> AgentChain.add_message(Message.new_user!(prompt)) 27 | |> AgentChain.run() 28 | 29 | %{state | story: content} 30 | end 31 | 32 | story_schema = %{ 33 | "type" => "object", 34 | "properties" => %{ 35 | "summary" => %{ 36 | "type" => "string", 37 | "description" => "Brief summary of the story." 38 | }, 39 | "characters" => %{ 40 | "type" => "array", 41 | "description" => "List of characters in the story", 42 | "items" => %{ 43 | "type" => "string" 44 | } 45 | } 46 | } 47 | } 48 | 49 | structure_story_node = fn chain, state -> 50 | prompt = """ 51 | You are a helpful assistant responsible for summarizing stories and identifying story characters. 52 | The following is a short story about "#{state.topic}": 53 | 54 | #{state.story} 55 | """ 56 | 57 | {:ok, content, _last_message} = 58 | chain 59 | |> AgentChain.add_message(Message.new_user!(prompt)) 60 | |> AgentChain.ask_for_json_response(story_schema) 61 | |> AgentChain.run() 62 | 63 | %{state | structured_story: content} 64 | end 65 | 66 | agent = 67 | %GraphAgent{ 68 | name: "Poff's Great New Agent", 69 | initial_state: %{} 70 | } 71 | |> GraphAgent.add_node(:write_story, write_story_node) 72 | |> GraphAgent.add_node(:structure_story, structure_story_node) 73 | |> GraphAgent.set_entry_point(:write_story) 74 | |> GraphAgent.add_edge(:write_story, :structure_story) 75 | |> GraphAgent.add_edge(:structure_story, :end) 76 | ``` 77 | 78 | 79 | 80 | ```elixir 81 | # This only works if the ex_dot dependency is available (see the Notebook dependencies section) 82 | 83 | agent.graph 84 | |> Graph.Serializers.DOT.serialize() 85 | |> case do 86 | {:ok, dot_graph} -> dot_graph 87 | end 88 | |> Dot.to_svg() 89 | |> Kino.Image.new(:svg) 90 | ``` 91 | 92 | ```elixir 93 | import Kino.Shorts 94 | topic = read_text("New story topic: ") 95 | 96 | if topic == "" do 97 | Kino.interrupt!(:error, "You must enter a topic") 98 | end 99 | 100 | initial_state = %{topic: topic, story: nil, structured_story: nil} 101 | Magus.AgentExecutorLite.run(%{agent | initial_state: initial_state}) |> Kino.Tree.new() 102 | ``` 103 | 104 | 105 | -------------------------------------------------------------------------------- /notebooks/essay-writer.livemd: -------------------------------------------------------------------------------- 1 | # Essay Writer 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:magus, path: "#{__DIR__}/.."}, 6 | {:kino, "~> 0.12.0"} 7 | ]) 8 | 9 | Application.put_env(:magus, :model_provider, "openai") 10 | Application.put_env(:magus, :openai_key, System.fetch_env!("LB_OPENAI_KEY")) 11 | ``` 12 | 13 | ## Building a graph agent to write essays 14 | 15 | ```elixir 16 | defmodule EssayWriterState do 17 | defstruct [ 18 | :topic, 19 | :latest_revision, 20 | :latest_feedback, 21 | num_of_revisions: 0 22 | ] 23 | end 24 | ``` 25 | 26 | ```elixir 27 | alias Magus.GraphAgent 28 | alias Magus.AgentChain 29 | alias LangChain.PromptTemplate 30 | 31 | first_draft_template = ~S| 32 | You are a writer who is working on a three-paragraph essay on the following topic: <%= @topic %>. 33 | | |> PromptTemplate.from_template!() 34 | 35 | writer_with_revision_template = ~S| 36 | You are a writer who is working on a three-paragraph essay on the following topic: <%= @topic %>. 37 | This is a previous revision of the essay: 38 | 39 | <%= @latest_revision %> 40 | 41 | On the latest revision, you received the following feedback: 42 | 43 | <%= @latest_feedback %> 44 | 45 | Write a new revision of the essay, incorporating the feedback where applicable. Begin immediately below: 46 | | |> PromptTemplate.from_template!() 47 | 48 | feedback_template = ~S| 49 | You are a professor grading and providing feedback on an essay on the following topic: <%= @topic %>. 50 | 51 | This is the essay: 52 | 53 | <%= @latest_revision %> 54 | 55 | Provide feedback on this essay below: 56 | | |> PromptTemplate.from_template!() 57 | 58 | write_first_draft_node = fn chain, state -> 59 | {:ok, content, _response} = 60 | chain 61 | |> AgentChain.add_message(PromptTemplate.to_message!(first_draft_template, state)) 62 | |> AgentChain.run() 63 | 64 | %EssayWriterState{state | latest_revision: content, num_of_revisions: 1} 65 | end 66 | 67 | write_node = fn chain, state -> 68 | {:ok, content, _response} = 69 | chain 70 | |> AgentChain.add_message(PromptTemplate.to_message!(writer_with_revision_template, state)) 71 | |> AgentChain.run() 72 | 73 | %EssayWriterState{ 74 | state 75 | | latest_revision: content, 76 | num_of_revisions: state.num_of_revisions + 1 77 | } 78 | end 79 | 80 | feedback_node = fn chain, state -> 81 | {:ok, content, _response} = 82 | chain 83 | |> AgentChain.add_message(PromptTemplate.to_message!(feedback_template, state)) 84 | |> AgentChain.run() 85 | 86 | %EssayWriterState{state | latest_feedback: content} 87 | end 88 | 89 | should_continue = fn %EssayWriterState{num_of_revisions: num_of_revisions} = _state -> 90 | case num_of_revisions > 2 do 91 | true -> :end 92 | false -> :provide_feedback 93 | end 94 | end 95 | 96 | agent = 97 | %GraphAgent{ 98 | name: "Essay Writer", 99 | final_output_property: :latest_revision, 100 | initial_state: %EssayWriterState{} 101 | } 102 | |> GraphAgent.add_node(:first_draft, write_first_draft_node) 103 | |> GraphAgent.add_node(:write, write_node) 104 | |> GraphAgent.add_node(:provide_feedback, feedback_node) 105 | |> GraphAgent.set_entry_point(:first_draft) 106 | |> GraphAgent.add_edge(:first_draft, :provide_feedback) 107 | |> GraphAgent.add_edge(:provide_feedback, :write) 108 | |> GraphAgent.add_conditional_edges(:write, [:end, :provide_feedback], should_continue) 109 | ``` 110 | 111 | ```elixir 112 | import Kino.Shorts 113 | 114 | topic = read_text("New topic: ") 115 | 116 | if topic == "" do 117 | Kino.interrupt!(:error, "You must enter a topic") 118 | end 119 | 120 | agent = %{ 121 | agent 122 | | initial_state: %EssayWriterState{ 123 | topic: topic 124 | } 125 | } 126 | 127 | Magus.AgentExecutorLite.run(agent) |> Kino.Tree.new() 128 | ``` 129 | 130 | 131 | -------------------------------------------------------------------------------- /test/agent_chain_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Magus.AgentChainTest do 2 | alias LangChain.Chains.LLMChain 3 | alias LangChain.ChatModels.ChatOpenAI 4 | alias LangChain.Message 5 | alias Magus.AgentChain 6 | 7 | use ExUnit.Case 8 | use Mimic 9 | 10 | test "if json is requested, it should parse and validate proper llm responses", %{} do 11 | json_schema = %{ 12 | "type" => "object", 13 | "properties" => %{ 14 | "title" => %{ 15 | "type" => "string", 16 | "description" => "Title" 17 | }, 18 | "characters" => %{ 19 | "type" => "array", 20 | "description" => "List of characters in the story", 21 | "items" => %{ 22 | "type" => "string" 23 | } 24 | } 25 | }, 26 | "required" => [ 27 | "characters" 28 | ] 29 | } 30 | 31 | raw_json = "{\"title\": \"Formula 1\", \"characters\": [\"Lewis\", \"Max\"]}" 32 | 33 | fake_messages = [ 34 | Message.new!(%{role: :assistant, content: raw_json, status: :complete}) 35 | ] 36 | 37 | expect(ChatOpenAI, :call, fn _model, _messages, _tools -> 38 | {:ok, fake_messages} 39 | end) 40 | 41 | wrapped_chain = 42 | %{ 43 | llm: ChatOpenAI.new!(%{temperature: 1, stream: false}) 44 | } 45 | |> LLMChain.new!() 46 | 47 | chain = 48 | %AgentChain{ 49 | wrapped_chain: wrapped_chain 50 | } 51 | |> AgentChain.ask_for_json_response(json_schema) 52 | 53 | {:ok, content, _response} = chain |> AgentChain.run() 54 | 55 | assert content["title"] == "Formula 1" 56 | assert content["characters"] == ["Lewis", "Max"] 57 | end 58 | 59 | test "if json is requested, it should error on json llm repsonses that don't conform to spec", 60 | %{} do 61 | json_schema = %{ 62 | "type" => "object", 63 | "properties" => %{ 64 | "title" => %{ 65 | "type" => "string", 66 | "description" => "Title" 67 | }, 68 | "characters" => %{ 69 | "type" => "array", 70 | "description" => "List of characters in the story", 71 | "items" => %{ 72 | "type" => "string" 73 | } 74 | } 75 | }, 76 | "required" => [ 77 | "characters" 78 | ] 79 | } 80 | 81 | # `racers` should be `characters` to be valid 82 | raw_json = "{\"title\": \"Formula 1\", \"racers\": [\"Lewis\", \"Max\"]}" 83 | 84 | fake_messages = [ 85 | Message.new!(%{role: :assistant, content: raw_json, status: :complete}) 86 | ] 87 | 88 | expect(ChatOpenAI, :call, fn _model, _messages, _tools -> 89 | {:ok, fake_messages} 90 | end) 91 | 92 | wrapped_chain = 93 | %{ 94 | llm: ChatOpenAI.new!(%{}) 95 | } 96 | |> LLMChain.new!() 97 | 98 | chain = 99 | %AgentChain{ 100 | wrapped_chain: wrapped_chain 101 | } 102 | |> AgentChain.ask_for_json_response(json_schema) 103 | 104 | {:error, validation_errors} = chain |> AgentChain.run() 105 | assert validation_errors == [{"Required property characters was not present.", "#"}] 106 | end 107 | 108 | test "if json is requested, it should error on llm repsonses that aren't json", %{} do 109 | json_schema = %{ 110 | "type" => "object", 111 | "properties" => %{ 112 | "title" => %{ 113 | "type" => "string", 114 | "description" => "Title" 115 | }, 116 | "characters" => %{ 117 | "type" => "array", 118 | "description" => "List of characters in the story", 119 | "items" => %{ 120 | "type" => "string" 121 | } 122 | } 123 | }, 124 | "required" => [ 125 | "characters" 126 | ] 127 | } 128 | 129 | raw_content = "Lewis, Max" 130 | 131 | fake_messages = [ 132 | Message.new!(%{role: :assistant, content: raw_content, status: :complete}) 133 | ] 134 | 135 | expect(ChatOpenAI, :call, fn _model, _messages, _tools -> 136 | {:ok, fake_messages} 137 | end) 138 | 139 | wrapped_chain = 140 | %{ 141 | llm: ChatOpenAI.new!(%{}) 142 | } 143 | |> LLMChain.new!() 144 | 145 | chain = 146 | %AgentChain{ 147 | wrapped_chain: wrapped_chain 148 | } 149 | |> AgentChain.ask_for_json_response(json_schema) 150 | 151 | {:error, error} = chain |> AgentChain.run() 152 | assert error == %Jason.DecodeError{position: 0, token: nil, data: "Lewis, Max"} 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/agent_chain.ex: -------------------------------------------------------------------------------- 1 | defmodule Magus.AgentChain do 2 | @doc """ 3 | Define a chain for agents. 4 | 5 | This is an extention of the LangChain.Chains.LLMChain chain that provides 6 | a few helpers for the agent use case. 7 | 8 | A `stream_handler` can be passed in when the AgentChain is created and then 9 | used automatically when the chain is run. This is useful for the AgentExecutor 10 | to listen to tokens as they're returned by the LLM. 11 | 12 | The AgentChain can also be configured with a JSON schema that is used when 13 | requesting content from the LLM and then used to validate that the LLM response 14 | conforms to the schema. 15 | """ 16 | alias LangChain.PromptTemplate 17 | alias Magus.AgentChain 18 | 19 | alias LangChain.Message 20 | alias LangChain.MessageDelta 21 | alias LangChain.Chains.LLMChain 22 | alias LangChain.ChatModels.ChatOpenAI 23 | alias LangChain.ChatModels.ChatGoogleAI 24 | 25 | defstruct [ 26 | :wrapped_chain, 27 | :stream_handler, 28 | :json_response_schema 29 | ] 30 | 31 | @default_openai_model "gpt-3.5-turbo" 32 | @default_gemini_model "gemini-1.5-flash" 33 | 34 | @json_format_template PromptTemplate.from_template!( 35 | ~S|"The output should be formatted as a JSON instance that conforms to the JSON schema below. 36 | 37 | As an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]} 38 | the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted. 39 | 40 | Do not wrap the JSON with any Markdown. 41 | 42 | Here is the output schema: 43 | ``` 44 | <%= @schema %> 45 | ```| 46 | ) 47 | @type t() :: %AgentChain{} 48 | 49 | @doc """ 50 | Start a new AgentChain configuration. 51 | 52 | ## Options 53 | 54 | - `:verbose` - Runs the LLM in verbose mode if set to true. Defaults to `false`. 55 | - `:stream_handler` - Handler that is called as the LLM returns messages. 56 | 57 | """ 58 | @spec new!(opts :: keyword()) :: t() 59 | def new!(opts \\ []) do 60 | default = [verbose: false, stream_handler: nil] 61 | opts = Keyword.merge(default, opts) 62 | 63 | stream_handler = opts[:stream_handler] 64 | 65 | callback = 66 | if stream_handler != nil, do: stream_handler, else: get_default_stream_handler() 67 | 68 | # TODO: pull this default llm from a config 69 | wrapped_chain = 70 | %{ 71 | llm: get_default_llm(), 72 | verbose: opts[:verbose] 73 | } 74 | |> LLMChain.new!() 75 | |> LLMChain.add_callback(callback) 76 | 77 | %AgentChain{wrapped_chain: wrapped_chain, stream_handler: stream_handler} 78 | end 79 | 80 | @doc """ 81 | Adds a JSON schema to the chain as the response format from the LLM. 82 | 83 | A message is added to the chain that tells the LLM to return the response 84 | content in JSON format. In the OpenAI case, the request will be made 85 | in [JSON Mode](https://platform.openai.com/docs/guides/text-generation/json-mode) 86 | """ 87 | @spec ask_for_json_response(t(), map()) :: t() 88 | def ask_for_json_response(chain, schema) do 89 | # TODO: this is ugly and doesn't generalize well. Re-work this when we re-think LLM configuration 90 | inner_llm = 91 | case chain.wrapped_chain.llm do 92 | %ChatOpenAI{} -> %ChatOpenAI{chain.wrapped_chain.llm | json_response: true} 93 | _ -> chain.wrapped_chain.llm 94 | end 95 | 96 | wrapped_chain = %{chain.wrapped_chain | llm: inner_llm} 97 | 98 | # Add a message to the chain that requests the schema 99 | schema_json = Jason.encode!(schema) 100 | 101 | wrapped_chain = 102 | wrapped_chain 103 | |> LLMChain.add_message( 104 | PromptTemplate.to_message!(@json_format_template, %{ 105 | schema: schema_json 106 | }) 107 | ) 108 | 109 | %AgentChain{chain | wrapped_chain: wrapped_chain, json_response_schema: schema} 110 | end 111 | 112 | @spec add_message(t(), LangChain.Message.t()) :: t() 113 | def add_message(chain, message) do 114 | wrapped_chain = chain.wrapped_chain |> LLMChain.add_message(message) 115 | 116 | %AgentChain{chain | wrapped_chain: wrapped_chain} 117 | end 118 | 119 | @spec add_messages(t(), [LangChain.Message.t()]) :: t() 120 | def add_messages(chain, messages) do 121 | wrapped_chain = chain.wrapped_chain |> LLMChain.add_messages(messages) 122 | 123 | %AgentChain{chain | wrapped_chain: wrapped_chain} 124 | end 125 | 126 | @spec add_tool(t(), LangChain.Function.t()) :: t() 127 | def add_tool(chain, tool) do 128 | wrapped_chain = chain.wrapped_chain |> LLMChain.add_tools([tool]) 129 | 130 | %AgentChain{chain | wrapped_chain: wrapped_chain} 131 | end 132 | 133 | @doc """ 134 | Run the AgentChain. 135 | 136 | If a `stream_handler` was specified when the AgentChain was created, 137 | it will be called as the LLM returns tokens. 138 | 139 | If a JSON response was requested with `ask_for_json_response`, the response 140 | will be validated against the schema and decoded to a struct. 141 | """ 142 | @spec run(t()) :: {:error, binary() | list()} | {:ok, :any, LangChain.Message.t()} 143 | def run(%AgentChain{wrapped_chain: llm_chain} = chain) do 144 | with {:ok, updated_llm_chain} <- 145 | LLMChain.run(llm_chain), 146 | content <- process_raw_content(updated_llm_chain.last_message.content), 147 | {:ok, content} <- parse_content_to_schema(content, chain.json_response_schema) do 148 | {:ok, content, updated_llm_chain.last_message} 149 | end 150 | end 151 | 152 | defp process_raw_content(content) when is_list(content) do 153 | content 154 | |> Enum.map(fn part -> part.content end) 155 | |> Enum.join(" ") 156 | end 157 | 158 | defp process_raw_content(content) do 159 | content 160 | end 161 | 162 | defp parse_content_to_schema(content, nil) do 163 | {:ok, content} 164 | end 165 | 166 | defp parse_content_to_schema(content, schema) do 167 | # Strip Gemini's markdown 168 | content = 169 | content |> String.replace_leading("```json", "") |> String.replace_trailing("```", "") 170 | 171 | with {:ok, parsed_content} <- Jason.decode(content), 172 | :ok <- ExJsonSchema.Validator.validate(schema, parsed_content) do 173 | {:ok, parsed_content} 174 | end 175 | end 176 | 177 | defp get_default_llm() do 178 | model_provider = Application.fetch_env!(:magus, :model_provider) 179 | 180 | case model_provider do 181 | "gemini_ai" -> get_default_gemini_llm() 182 | "openai" -> get_default_openai_llm() 183 | end 184 | end 185 | 186 | def get_default_gemini_llm() do 187 | gemini_key = Application.fetch_env!(:magus, :gemini_key) 188 | gemini_model = Application.get_env(:magus, :gemini_model) || @default_gemini_model 189 | 190 | ChatGoogleAI.new!(%{ 191 | endpoint: "https://generativelanguage.googleapis.com", 192 | model: gemini_model, 193 | api_key: gemini_key, 194 | stream: true 195 | }) 196 | end 197 | 198 | defp get_default_openai_llm() do 199 | openai_key = Application.fetch_env!(:magus, :openai_key) 200 | openai_model = Application.get_env(:magus, :openai_model) || @default_openai_model 201 | 202 | ChatOpenAI.new!(%{model: openai_model, api_key: openai_key, stream: true}) 203 | end 204 | 205 | defp get_default_stream_handler() do 206 | %{ 207 | on_llm_new_delta: fn _model, %MessageDelta{} = _data -> 208 | :ok 209 | end, 210 | on_message_processed: fn _chain, %Message{} = _data -> 211 | :ok 212 | end 213 | } 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "abacus": {:hex, :abacus, "2.1.0", "b6db5c989ba3d9dd8c36d1cb269e2f0058f34768d47c67eb8ce06697ecb36dd4", [:mix], [], "hexpm", "255de08b02884e8383f1eed8aa31df884ce0fb5eb394db81ff888089f2a1bbff"}, 3 | "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, 4 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 6 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 7 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 8 | "ex_json_schema": {:hex, :ex_json_schema, "0.10.2", "7c4b8c1481fdeb1741e2ce66223976edfb9bccebc8014f6aec35d4efe964fb71", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "37f43be60f8407659d4d0155a7e45e7f406dab1f827051d3d35858a709baf6a6"}, 9 | "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, 10 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 11 | "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, 12 | "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, 13 | "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, 14 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 15 | "langchain": {:hex, :langchain, "0.3.1", "df63af8f928438bd98253e569099109041ce985092227cbe8e1216612b0d4f23", [:mix], [{:abacus, "~> 2.1.0", [hex: :abacus, repo: "hexpm", optional: true]}, {:ecto, "~> 3.10", [hex: :ecto, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: true]}, {:nx, ">= 0.7.0", [hex: :nx, repo: "hexpm", optional: true]}, {:req, ">= 0.5.2", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "0cc40659e9a76e4e6599727d71fc841122f0aefc036a09a145caeaca8e45558b"}, 16 | "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, 17 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 20 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 21 | "mimic": {:hex, :mimic, "1.11.0", "49b126687520b6e179acab305068ad7d72bfea8abe94908a6c0c8ca0a5b7bdc7", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "8b16b1809ca947cffbaede146cd42da8c1c326af67a84b59b01c204d54e4f1a2"}, 22 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 23 | "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, 24 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 25 | "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, 26 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 27 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 28 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 29 | "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"}, 30 | "retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"}, 31 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 32 | } 33 | -------------------------------------------------------------------------------- /lib/agent_executor.ex: -------------------------------------------------------------------------------- 1 | defmodule Magus.AgentExecutor do 2 | @doc """ 3 | The AgentExecutor is a GenServer for executing an agent and managing/storing its 4 | state. This includes agent state as well as state about the execution (current node, status, errors, etc) 5 | 6 | For each node in the agent graph, a new `Task` will be started to run the function for the current node. 7 | If the node function succeeds, the current agent state will be updated and the executor 8 | traverses the agent's graph to find the next node to execute. 9 | If the node function fails, it is retrieved 3 times with an expotential backoff. 10 | 11 | Once created, the AgentExecutor process can be subscribed to in order to get to state and log updates. 12 | """ 13 | 14 | use GenServer 15 | use Retry 16 | 17 | alias LangChain.MessageDelta 18 | alias Magus.AgentExecutor 19 | alias Magus.AgentExecutor.Step 20 | alias Magus.GraphAgent 21 | alias Magus.AgentChain 22 | 23 | defstruct [ 24 | :id, 25 | :agent, 26 | :cur_node, 27 | :cur_agent_state, 28 | :created_at, 29 | :pid, 30 | :error_message, 31 | steps: [], 32 | status: :notstarted 33 | ] 34 | 35 | @type t() :: %AgentExecutor{} 36 | 37 | @doc """ 38 | Create a new AgentExecutor process responsible for running an agent. 39 | 40 | ## Options 41 | 42 | - `:agent` - The `Magus.GraphAgent` definition of the agent for the executor. 43 | - `:id` - A string to use for the id of the execution. 44 | """ 45 | @spec new(agent: GraphAgent.t(), id: String.t()) :: {:ok, pid()} | {:error, any()} 46 | def new(opts) do 47 | GenServer.start(__MODULE__, opts) 48 | end 49 | 50 | @doc """ 51 | Returns the execution state of the given AgentExecutor process 52 | """ 53 | @spec get_state(atom() | pid() | {atom(), any()} | {:via, atom(), any()}) :: any() 54 | def get_state(pid) do 55 | GenServer.call(pid, :get_state) 56 | end 57 | 58 | @doc """ 59 | Starts the agent execution for the given AgentExecutor process. 60 | """ 61 | @spec run(atom() | pid() | {atom(), any()} | {:via, atom(), any()}) :: :ok 62 | def run(pid) do 63 | GenServer.cast(pid, :start) 64 | 65 | :ok 66 | end 67 | 68 | @doc """ 69 | Subscribe to new agents being started. 70 | """ 71 | @spec subscribe_to_new_agents() :: :ok | {:error, {:already_registered, pid()}} 72 | def subscribe_to_new_agents() do 73 | Phoenix.PubSub.subscribe(Magus.PubSub, "agent_starting") 74 | end 75 | 76 | @doc """ 77 | Subscribe to agent execution state changes. 78 | """ 79 | @spec subscribe_to_state(atom() | pid() | {atom(), any()} | {:via, atom(), any()}) :: 80 | :ok | {:error, {:already_registered, pid()}} 81 | def subscribe_to_state(pid) do 82 | %{id: id} = AgentExecutor.get_state(pid) 83 | Phoenix.PubSub.subscribe(Magus.PubSub, get_agent_state_topic(id)) 84 | end 85 | 86 | @doc """ 87 | Subscribe to logs coming from the agent execution. This includes info about steps and 88 | the streamed content from the LLM. 89 | """ 90 | @spec subscribe_to_logs(atom() | pid() | {atom(), any()} | {:via, atom(), any()}) :: 91 | :ok | {:error, {:already_registered, pid()}} 92 | def subscribe_to_logs(pid) do 93 | %{id: id} = AgentExecutor.get_state(pid) 94 | Phoenix.PubSub.subscribe(Magus.PubSub, get_agent_log_topic(id)) 95 | end 96 | 97 | @impl true 98 | @spec init(keyword()) :: {:ok, t()} 99 | def init(opts) do 100 | # TODO: autogenerate id if one is not provided 101 | {:ok, 102 | %AgentExecutor{ 103 | id: Keyword.fetch!(opts, :id), 104 | agent: Keyword.fetch!(opts, :agent), 105 | status: :notstarted, 106 | cur_node: nil, 107 | cur_agent_state: nil, 108 | steps: [], 109 | created_at: DateTime.now!("Etc/UTC"), 110 | pid: self(), 111 | error_message: nil 112 | }} 113 | end 114 | 115 | @impl true 116 | def handle_call(:get_state, _reply, state) do 117 | {:reply, state, state} 118 | end 119 | 120 | @impl true 121 | def handle_cast(:start, %{agent: agent} = state) do 122 | cur_node = agent.entry_point_node 123 | 124 | state = %AgentExecutor{ 125 | state 126 | | cur_node: cur_node, 127 | status: :running, 128 | cur_agent_state: agent.initial_state 129 | } 130 | 131 | Phoenix.PubSub.broadcast!(Magus.PubSub, "agent_starting", {:agent_starting, state.id}) 132 | 133 | # TODO: Check if the end of the graph is reachable 134 | # Throw error if not 135 | 136 | GenServer.cast(self(), :step) 137 | 138 | {:noreply, state} 139 | end 140 | 141 | @impl true 142 | def handle_cast( 143 | :step, 144 | %{agent: agent, cur_node: cur_node, cur_agent_state: cur_agent_state, steps: steps} = 145 | state 146 | ) do 147 | step = %Step{ 148 | node: cur_node, 149 | status: :notstarted, 150 | input_state: cur_agent_state 151 | } 152 | 153 | cur_node_fn = agent.node_to_fn[cur_node] 154 | 155 | # Execute function for current node 156 | pid = self() 157 | notify_with_log(state.id, "\n--- BEGINNING STEP #{cur_node} ---\n") 158 | 159 | Task.start_link(fn -> 160 | log_handler = %{ 161 | on_llm_new_delta: fn _model, %MessageDelta{} = data -> 162 | # We receive a piece of data 163 | AgentExecutor.notify_with_log(state.id, data.content) 164 | end 165 | } 166 | 167 | chain = AgentChain.new!(stream_handler: log_handler) 168 | 169 | # Retry up to 3 times if we get an error 170 | # TODO: Make this more configurable 171 | # and be more thoughtful about how we want to handle errors 172 | # thrown from nodes 173 | retry with: exponential_backoff() |> cap(1_000) |> Stream.take(3), 174 | rescue_only: [MatchError] do 175 | cur_node_fn.(chain, cur_agent_state) 176 | after 177 | new_state -> GenServer.cast(pid, {:step_done, new_state}) 178 | else 179 | error -> GenServer.cast(pid, {:error, error}) 180 | end 181 | end) 182 | 183 | # Update the state with the step and notify subscribers 184 | step = %Step{step | pid: pid, status: :running} 185 | steps = steps ++ [step] 186 | state = %AgentExecutor{state | steps: steps} 187 | notify_with_state(state.id, state) 188 | 189 | {:noreply, state} 190 | end 191 | 192 | def handle_cast( 193 | {:step_done, new_agent_state}, 194 | %{agent: agent, cur_node: cur_node, steps: steps} = state 195 | ) do 196 | # Set the new agent state 197 | state = %AgentExecutor{state | cur_agent_state: new_agent_state} 198 | 199 | # Find next nodes to go to 200 | neighbors = agent.graph |> Graph.out_neighbors(cur_node) 201 | 202 | next_node = 203 | if length(neighbors) > 1 do 204 | # We need to call a conditional function to figure out what node to move to 205 | conditional_fn = agent.node_to_conditional_fn[cur_node] 206 | conditional_fn.(new_agent_state) 207 | else 208 | neighbors |> Enum.at(0) 209 | end 210 | 211 | if !Enum.member?(neighbors, next_node) do 212 | raise "Next node is not a possible edge -- check the agent definition" 213 | end 214 | 215 | # Gets the latest state (assuming that's what we're workin on) 216 | # and sets the output state 217 | last_step = 218 | steps 219 | |> List.last() 220 | |> Map.put(:status, :done) 221 | |> Map.put(:output_state, new_agent_state) 222 | 223 | steps = steps |> List.replace_at(-1, last_step) 224 | state = %AgentExecutor{state | steps: steps} 225 | notify_with_state(state.id, state) 226 | notify_with_log(state.id, "\n--- FINISHED STEP #{cur_node} ---\n") 227 | 228 | # If we're not done, continue to step through the graph 229 | status = 230 | if next_node != :end do 231 | GenServer.cast(self(), :step) 232 | :running 233 | else 234 | # Clean up the agent 235 | GraphAgent.cleanup(state.agent, state.cur_agent_state) 236 | 237 | # We're done 238 | :done 239 | end 240 | 241 | # TODO: have an update state func that automatically notifies subscribers 242 | state = %AgentExecutor{ 243 | state 244 | | agent: agent, 245 | cur_node: next_node, 246 | steps: steps, 247 | status: status 248 | } 249 | 250 | notify_with_state(state.id, state) 251 | 252 | {:noreply, state} 253 | end 254 | 255 | def handle_cast({:error, msg}, state) do 256 | # Gets the latest state (assuming that's what we're workin on) 257 | last_step = 258 | state.steps 259 | |> List.last() 260 | |> Map.put(:status, :failed) 261 | 262 | msg_string = inspect(msg) 263 | 264 | steps = state.steps |> List.replace_at(-1, last_step) 265 | state = %{state | steps: steps, error_message: msg_string, status: :failed} 266 | 267 | # Clean up the agent 268 | GraphAgent.cleanup(state.agent, state.cur_agent_state) 269 | 270 | notify_with_state(state.id, state) 271 | notify_with_log(state.id, "#{msg_string}\n") 272 | notify_with_log(state.id, "\n--- STEP FAILED #{state.cur_node} ---\n") 273 | 274 | {:noreply, state} 275 | end 276 | 277 | defp notify_with_state(id, state) do 278 | Phoenix.PubSub.broadcast!( 279 | Magus.PubSub, 280 | get_agent_state_topic(id), 281 | {:agent_state, id, state} 282 | ) 283 | end 284 | 285 | def notify_with_log(id, msg) do 286 | Phoenix.PubSub.broadcast!(Magus.PubSub, get_agent_log_topic(id), {:agent_log, id, msg}) 287 | end 288 | 289 | def get_agent_state_topic(id) do 290 | "agent:#{id}" 291 | end 292 | 293 | def get_agent_log_topic(id) do 294 | "agent_log:#{id}" 295 | end 296 | end 297 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------