diff --git a/README.md b/README.md index c271781..c2768a7 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ graph TB - ⚡ **Async Support**: Handle asynchronous operations - 🛠 **Configuration-based**: Define workflows in YAML - 💪 **Type-safe**: Leverages Elixir's pattern matching +- 🔌 **Plugin System**: Extend functionality with custom plugins ## Quick Start diff --git a/examples/plugin_system.exs b/examples/plugin_system.exs new file mode 100644 index 0000000..63338f3 --- /dev/null +++ b/examples/plugin_system.exs @@ -0,0 +1,157 @@ +defmodule WeatherPlugin do + @moduledoc """ + Example plugin that provides weather forecast functionality. + """ + @behaviour AgentForge.Plugin + + @impl true + def init(_opts) do + # In a real plugin, we might initialize connections or load configs + # No IO.puts here, we'll print it in the main program flow + :ok + end + + @impl true + def register_tools(registry) do + registry.register("get_forecast", &forecast/1) + :ok + end + + @impl true + def register_primitives do + # This plugin doesn't add any primitives + :ok + end + + @impl true + def register_channels do + AgentForge.Notification.Registry.register_channel(WeatherNotificationChannel) + :ok + end + + @impl true + def metadata do + %{ + name: "Weather Plugin", + version: "1.0.0", + description: "Provides weather forecast functionality", + author: "AgentForge Team", + compatible_versions: ">= 0.1.0" + } + end + + # Tool implementation + defp forecast(params) do + location = Map.get(params, "location", "Unknown") + # In a real plugin, this would make an API call to a weather service + # For this example, we just return mock data + %{ + location: location, + temperature: 22, + conditions: "Sunny", + forecast: [ + %{day: "Today", high: 24, low: 18, conditions: "Sunny"}, + %{day: "Tomorrow", high: 22, low: 17, conditions: "Partly Cloudy"} + ] + } + end +end + +defmodule WeatherNotificationChannel do + @moduledoc """ + Example notification channel for weather alerts. + """ + @behaviour AgentForge.Notification.Channel + + @impl true + def name, do: :weather_alert + + @impl true + def send(message, config) do + priority = Map.get(config, :priority, "normal") + # Remove quotes from message to match expected test format + clean_message = message |> String.replace("\"", "") + IO.puts("[Weather Alert - #{priority}] #{clean_message}") + :ok + end +end + +# Start necessary processes +_registry_pid = case AgentForge.Notification.Registry.start_link([]) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid +end + +_plugin_manager_pid = case AgentForge.PluginManager.start_link([]) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid +end + +_tools_pid = case AgentForge.Tools.start_link([]) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid +end + +# Load the weather plugin +IO.puts("Initializing Weather Plugin") +:ok = AgentForge.PluginManager.load_plugin(WeatherPlugin) + +# Define a simple flow that uses the weather plugin +process_weather = fn signal, state -> + location = signal.data + + # Use the plugin tool to get weather forecast + {:ok, forecast_tool} = AgentForge.Tools.get("get_forecast") + forecast = forecast_tool.(%{"location" => location}) + + # Emit a notification for extreme temperatures + if forecast.temperature > 30 do + notify = AgentForge.Primitives.notify( + [:weather_alert], + config: %{weather_alert: %{priority: "high"}} + ) + + alert_signal = AgentForge.Signal.new(:alert, "Extreme heat warning for #{location}") + notify.(alert_signal, state) + end + + # Return the forecast data + {{:emit, forecast}, state} +end + +# Create and execute a simple flow with a list of handlers +flow = [process_weather] + +# Run the flow with different locations +locations = ["San Francisco", "Tokyo", "Sahara Desert"] + +Enum.each(locations, fn location -> + signal = AgentForge.Signal.new(:location, location) + IO.puts("\nChecking weather for: #{location}") + {:ok, result, _state} = AgentForge.Flow.process(flow, signal, %{}) + IO.puts("Current conditions: #{result.temperature}°C, #{result.conditions}") + + # For demo purposes, simulate a high temperature for Sahara Desert + if location == "Sahara Desert" do + hot_signal = AgentForge.Signal.new(:location, location) + # Monkey patch the forecast tool temporarily to return extreme temperature + {:ok, old_fn} = AgentForge.Tools.get("get_forecast") + + AgentForge.Tools.register("get_forecast", fn params -> + result = old_fn.(params) + Map.put(result, :temperature, 45) + end) + + {:ok, _result, _state} = AgentForge.Flow.process(flow, hot_signal, %{}) + + # Restore original function + AgentForge.Tools.register("get_forecast", old_fn) + end +end) + +# List all loaded plugins and their metadata +plugins = AgentForge.PluginManager.list_plugins() +IO.puts("\nLoaded Plugins:") +Enum.each(plugins, fn {_module, metadata} -> + IO.puts("- #{metadata.name} v#{metadata.version}: #{metadata.description}") +end) diff --git a/guides/plugin_system.md b/guides/plugin_system.md new file mode 100644 index 0000000..c2d6b72 --- /dev/null +++ b/guides/plugin_system.md @@ -0,0 +1,142 @@ +# AgentForge Plugin System + +This guide explains how to use and extend the AgentForge plugin system. + +## Overview + +The plugin system enables extending AgentForge with additional functionality while maintaining a lightweight core. Plugins can provide new tools, primitives, and notification channels. + +## Using Plugins + +To use a plugin in your AgentForge application: + +1. Add the plugin to your dependencies in `mix.exs`: + +```elixir +defp deps do + [ + {:agent_forge, "~> 0.1.0"}, + {:agent_forge_http, "~> 1.0.0"} # Example HTTP plugin + ] +end +``` + +2. Load the plugin in your application: + +```elixir +# In your application startup code +AgentForge.PluginManager.load_plugin(AgentForge.Plugins.HTTP) +``` + +3. Use the tools provided by the plugin: + +```elixir +signal = AgentForge.Signal.new(:request, %{"url" => "https://example.com"}) +flow = [AgentForge.Tools.execute("http_get")] +{:ok, result, _} = AgentForge.Flow.process(flow, signal, %{}) +``` + +## Creating Plugins + +To create your own plugin: + +1. Implement the `AgentForge.Plugin` behaviour: + +```elixir +defmodule MyApp.CustomPlugin do + @behaviour AgentForge.Plugin + + @impl true + def init(_opts) do + # Initialize your plugin + :ok + end + + @impl true + def register_tools(registry) do + # Register any tools your plugin provides + registry.register("my_tool", &my_tool_function/1) + :ok + end + + @impl true + def metadata do + %{ + name: "My Custom Plugin", + description: "Provides custom functionality", + version: "1.0.0", + author: "Your Name", + compatible_versions: ">= 0.1.0" + } + end + + # Tool implementation + defp my_tool_function(params) do + # Process params and return a result + %{result: "Processed #{inspect(params)}"} + end +end +``` + +2. Optionally register primitives or notification channels: + +```elixir +# To register primitives +@impl true +def register_primitives do + # Register your custom primitives + :ok +end + +# To register notification channels +@impl true +def register_channels do + AgentForge.Notification.Registry.register_channel(MyApp.Notification.Channels.Custom) + :ok +end +``` + +## Notification Channels + +Plugins can provide new notification channels by implementing the `AgentForge.Notification.Channel` behaviour: + +```elixir +defmodule MyApp.Notification.Channels.Custom do + @behaviour AgentForge.Notification.Channel + + @impl true + def name, do: :custom + + @impl true + def send(message, config) do + # Send notification through your custom channel + IO.puts("Custom notification: #{message}, config: #{inspect(config)}") + :ok + end +end +``` + +To use a custom notification channel: + +```elixir +notify = AgentForge.Primitives.notify( + [:console, :custom], + config: %{custom: %{some_setting: "value"}} +) + +flow = [notify] +signal = AgentForge.Signal.new(:event, "Something happened") +AgentForge.Flow.process(flow, signal, %{}) +``` + +## Best Practices + +1. **Keep Plugins Focused**: Each plugin should provide a specific, well-defined set of functionality. + +2. **Handle Dependencies Gracefully**: Check for required dependencies with `Code.ensure_loaded?/1` and provide meaningful error messages when they're missing. + +3. **Document Your Plugin**: Include clear documentation on how to use your plugin and its configuration options. + +4. **Version Compatibility**: Specify which versions of AgentForge your plugin is compatible with. + +5. **Testing**: Write comprehensive tests for your plugin to ensure it behaves correctly. diff --git a/lib/agent_forge/application.ex b/lib/agent_forge/application.ex index cb05d3e..b4745d2 100644 --- a/lib/agent_forge/application.ex +++ b/lib/agent_forge/application.ex @@ -7,7 +7,11 @@ defmodule AgentForge.Application do children = [ {Registry, keys: :unique, name: Registry.AgentForge}, {AgentForge.Store, []}, - {AgentForge.Tools, name: AgentForge.Tools} + {AgentForge.Tools, name: AgentForge.Tools}, + # Add plugin manager + {AgentForge.PluginManager, []}, + # Add notification registry + {AgentForge.Notification.Registry, []} ] opts = [strategy: :one_for_one, name: AgentForge.Supervisor] diff --git a/lib/agent_forge/notification/channel.ex b/lib/agent_forge/notification/channel.ex new file mode 100644 index 0000000..d9ffa08 --- /dev/null +++ b/lib/agent_forge/notification/channel.ex @@ -0,0 +1,8 @@ +defmodule AgentForge.Notification.Channel do + @moduledoc """ + Behaviour for notification channels in AgentForge. + """ + + @callback name() :: atom() + @callback send(message :: String.t(), config :: map()) :: :ok | {:error, term()} +end diff --git a/lib/agent_forge/notification/channels/console.ex b/lib/agent_forge/notification/channels/console.ex new file mode 100644 index 0000000..bd5bc50 --- /dev/null +++ b/lib/agent_forge/notification/channels/console.ex @@ -0,0 +1,17 @@ +defmodule AgentForge.Notification.Channels.Console do + @moduledoc """ + Console notification channel for AgentForge. + """ + + @behaviour AgentForge.Notification.Channel + + @impl true + def name, do: :console + + @impl true + def send(message, config) do + prefix = Map.get(config, :prefix, "[Notification]") + IO.puts("#{prefix} #{message}") + :ok + end +end diff --git a/lib/agent_forge/notification/registry.ex b/lib/agent_forge/notification/registry.ex new file mode 100644 index 0000000..d20c4da --- /dev/null +++ b/lib/agent_forge/notification/registry.ex @@ -0,0 +1,62 @@ +defmodule AgentForge.Notification.Registry do + @moduledoc """ + Registry for notification channels. + """ + + use GenServer + + # Client API + + @doc """ + Starts the notification registry. + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Registers a notification channel. + """ + def register_channel(channel_module) do + GenServer.call(__MODULE__, {:register, channel_module}) + end + + @doc """ + Gets a channel by name. + """ + def get_channel(name) do + GenServer.call(__MODULE__, {:get, name}) + end + + @doc """ + Lists all registered channels. + """ + def list_channels do + GenServer.call(__MODULE__, :list) + end + + # Server implementation + + @impl true + def init(_) do + {:ok, %{channels: %{}}} + end + + @impl true + def handle_call({:register, channel_module}, _from, state) do + name = channel_module.name() + channels = Map.put(state.channels, name, channel_module) + {:reply, :ok, %{state | channels: channels}} + end + + @impl true + def handle_call({:get, name}, _from, state) do + result = Map.fetch(state.channels, name) + {:reply, result, state} + end + + @impl true + def handle_call(:list, _from, state) do + {:reply, Map.keys(state.channels), state} + end +end diff --git a/lib/agent_forge/plugin.ex b/lib/agent_forge/plugin.ex new file mode 100644 index 0000000..b1e2886 --- /dev/null +++ b/lib/agent_forge/plugin.ex @@ -0,0 +1,38 @@ +defmodule AgentForge.Plugin do + @moduledoc """ + Behaviour specification for AgentForge plugins. + + Plugins can extend the framework with additional tools, primitives, and notification channels. + """ + + @doc """ + Called when the plugin is loaded. Use this for initialization. + """ + @callback init(opts :: keyword()) :: :ok | {:error, term()} + + @doc """ + Called to register tools provided by this plugin. + """ + @callback register_tools(registry :: module()) :: :ok | {:error, term()} + + @doc """ + Called to register primitives provided by this plugin. + """ + @callback register_primitives() :: :ok | {:error, term()} + + @doc """ + Called to register notification channels provided by this plugin. + """ + @callback register_channels() :: :ok | {:error, term()} + + @doc """ + Returns metadata about the plugin. + """ + @callback metadata() :: map() + + @optional_callbacks [ + register_tools: 1, + register_primitives: 0, + register_channels: 0 + ] +end diff --git a/lib/agent_forge/plugin_manager.ex b/lib/agent_forge/plugin_manager.ex new file mode 100644 index 0000000..cabd0a1 --- /dev/null +++ b/lib/agent_forge/plugin_manager.ex @@ -0,0 +1,102 @@ +defmodule AgentForge.PluginManager do + @moduledoc """ + Manages loading and activation of AgentForge plugins. + """ + + use GenServer + + @doc """ + Starts the plugin manager. + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Loads a plugin module. + """ + def load_plugin(plugin_module, opts \\ []) do + GenServer.call(__MODULE__, {:load_plugin, plugin_module, opts}) + end + + @doc """ + Returns a list of loaded plugins with their metadata. + """ + def list_plugins do + GenServer.call(__MODULE__, :list_plugins) + end + + @doc """ + Returns whether a plugin is loaded. + """ + def plugin_loaded?(plugin_module) do + GenServer.call(__MODULE__, {:plugin_loaded, plugin_module}) + end + + # Server implementation + + @impl true + def init(_opts) do + {:ok, %{plugins: %{}}} + end + + @impl true + def handle_call({:load_plugin, plugin_module, opts}, _from, state) do + # Validate plugin module implements the Plugin behaviour + if implements_plugin_behaviour?(plugin_module) do + case plugin_module.init(opts) do + :ok -> + # Register plugin components + register_plugin_components(plugin_module) + + # Store plugin metadata + metadata = plugin_module.metadata() + plugins = Map.put(state.plugins, plugin_module, metadata) + + {:reply, :ok, %{state | plugins: plugins}} + + {:error, reason} -> + {:reply, {:error, reason}, state} + end + else + {:reply, {:error, :invalid_plugin}, state} + end + end + + @impl true + def handle_call(:list_plugins, _from, state) do + {:reply, state.plugins, state} + end + + @impl true + def handle_call({:plugin_loaded, plugin_module}, _from, state) do + {:reply, Map.has_key?(state.plugins, plugin_module), state} + end + + # Private helpers + + defp implements_plugin_behaviour?(module) do + behaviours = module.__info__(:attributes)[:behaviour] || [] + Enum.member?(behaviours, AgentForge.Plugin) + rescue + # If module doesn't exist or doesn't have __info__ function + _error -> false + end + + defp register_plugin_components(plugin_module) do + # Register tools if the callback exists + if function_exported?(plugin_module, :register_tools, 1) do + plugin_module.register_tools(AgentForge.Tools) + end + + # Register primitives if the callback exists + if function_exported?(plugin_module, :register_primitives, 0) do + plugin_module.register_primitives() + end + + # Register notification channels if the callback exists + if function_exported?(plugin_module, :register_channels, 0) do + plugin_module.register_channels() + end + end +end diff --git a/lib/agent_forge/plugins/http.ex b/lib/agent_forge/plugins/http.ex new file mode 100644 index 0000000..7c6cbb8 --- /dev/null +++ b/lib/agent_forge/plugins/http.ex @@ -0,0 +1,101 @@ +defmodule AgentForge.Plugins.HTTP do + @moduledoc """ + HTTP integration plugin for AgentForge. + + Provides tools for making HTTP requests in workflows. + """ + + @behaviour AgentForge.Plugin + + @impl true + def init(_opts) do + # Check for dependencies + if Code.ensure_loaded?(Finch) do + # Start Finch if not already started + case Finch.start_link(name: __MODULE__.Finch) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + error -> error + end + else + {:error, :finch_not_installed} + end + end + + @impl true + def register_tools(registry) do + registry.register("http_get", &http_get/1) + registry.register("http_post", &http_post/1) + :ok + end + + @impl true + def metadata do + %{ + name: "HTTP Plugin", + description: "Provides HTTP client capabilities for AgentForge workflows", + version: "1.0.0", + author: "AgentForge Team", + compatible_versions: ">= 0.1.0" + } + end + + # Tool implementations + + defp http_get(params) do + if not Code.ensure_loaded?(Finch) do + %{error: :finch_not_installed, message: "Finch dependency is required for HTTP operations"} + else + url = Map.fetch!(params, "url") + headers = Map.get(params, "headers", %{}) + + request = Finch.build(:get, url, headers_to_list(headers)) + + case Finch.request(request, __MODULE__.Finch) do + {:ok, response} -> + %{ + status: response.status, + headers: headers_to_map(response.headers), + body: response.body + } + + {:error, reason} -> + %{error: reason} + end + end + end + + defp http_post(params) do + if not Code.ensure_loaded?(Finch) do + %{error: :finch_not_installed, message: "Finch dependency is required for HTTP operations"} + else + url = Map.fetch!(params, "url") + headers = Map.get(params, "headers", %{}) + body = Map.get(params, "body", "") + + request = Finch.build(:post, url, headers_to_list(headers), body) + + case Finch.request(request, __MODULE__.Finch) do + {:ok, response} -> + %{ + status: response.status, + headers: headers_to_map(response.headers), + body: response.body + } + + {:error, reason} -> + %{error: reason} + end + end + end + + # Helper functions + + defp headers_to_list(headers) when is_map(headers) do + Enum.map(headers, fn {k, v} -> {to_string(k), to_string(v)} end) + end + + defp headers_to_map(headers) when is_list(headers) do + Map.new(headers, fn {k, v} -> {k, v} end) + end +end diff --git a/lib/agent_forge/primitives.ex b/lib/agent_forge/primitives.ex index eeeb32e..29d4241 100644 --- a/lib/agent_forge/primitives.ex +++ b/lib/agent_forge/primitives.ex @@ -182,32 +182,46 @@ defmodule AgentForge.Primitives do end @doc """ - Creates a notify primitive that sends notifications or events. - Can be used to integrate with external notification systems. + Creates a notify primitive that sends notifications through configured channels. ## Options - * `:channels` - List of notification channels (e.g. [:console, :webhook]) + * `:channels` - List of notification channels (e.g. [:console, :slack]) * `:format` - Optional function to format the notification message + * `:config` - Optional configuration map for channels ## Examples iex> notify = AgentForge.Primitives.notify([:console]) - iex> signal = AgentForge.Signal.new(:event, "test message") + iex> signal = AgentForge.Signal.new(:event, "System alert") iex> {{:emit, result}, _} = notify.(signal, %{}) iex> result.type == :notification true """ def notify(channels, opts \\ []) when is_list(channels) do format_fn = Keyword.get(opts, :format, &inspect/1) + config = Keyword.get(opts, :config, %{}) fn signal, state -> message = format_fn.(signal.data) - Enum.each(channels, fn - :console -> IO.puts("[Notification] #{message}") - :webhook when is_map_key(state, :webhook_url) -> :ok - _ -> :ok + # Process each channel + Enum.each(channels, fn channel_name -> + case AgentForge.Notification.Registry.get_channel(channel_name) do + {:ok, channel_module} -> + # Get channel-specific config + channel_config = Map.get(config, channel_name, %{}) + # Send notification + channel_module.send(message, channel_config) + + :error when channel_name == :console -> + # Built-in console support for backward compatibility + IO.puts("[Notification] #{message}") + + :error -> + # Channel not found, log warning + IO.warn("Notification channel not found: #{inspect(channel_name)}") + end end) {{:emit, Signal.new(:notification, message)}, state} diff --git a/mix.exs b/mix.exs index df9744a..ec5c092 100644 --- a/mix.exs +++ b/mix.exs @@ -48,7 +48,9 @@ defmodule AgentForge.MixProject do {:yaml_elixir, "~> 2.9"}, # For mocking in tests {:meck, "~> 0.9", only: :test}, - {:ex_doc, "~> 0.29", only: :dev, runtime: false} + {:ex_doc, "~> 0.29", only: :dev, runtime: false}, + # Optional dependencies for plugins + {:finch, "~> 0.16", optional: true} ] end diff --git a/mix.lock b/mix.lock index f976bac..d83936a 100644 --- a/mix.lock +++ b/mix.lock @@ -2,12 +2,19 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [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", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "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"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "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"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, } diff --git a/test/agent_forge/examples_test.exs b/test/agent_forge/examples_test.exs index 30263d8..93e0236 100644 --- a/test/agent_forge/examples_test.exs +++ b/test/agent_forge/examples_test.exs @@ -68,4 +68,24 @@ defmodule AgentForge.ExamplesTest do assert output =~ "- Completed: true" end end + + describe "plugin_system.exs" do + test "demonstrates plugin system functionality" do + output = capture_io(fn -> Code.eval_file("examples/plugin_system.exs") end) + + # Verify plugin initialization + assert output =~ "Initializing Weather Plugin" + + # Verify plugin tool usage + assert output =~ "Checking weather for: San Francisco" + assert output =~ "Current conditions:" + + # Verify notification channel functionality + assert output =~ "[Weather Alert - high] Extreme heat warning for Sahara Desert" + + # Verify plugin metadata listing + assert output =~ "Loaded Plugins:" + assert output =~ "Weather Plugin v1.0.0: Provides weather forecast functionality" + end + end end diff --git a/test/agent_forge/plugin_system_test.exs b/test/agent_forge/plugin_system_test.exs new file mode 100644 index 0000000..808bc16 --- /dev/null +++ b/test/agent_forge/plugin_system_test.exs @@ -0,0 +1,112 @@ +defmodule AgentForge.PluginSystemTest do + use ExUnit.Case + alias AgentForge.{Plugin, PluginManager, Signal} + + # Define a mock plugin for testing + defmodule MockPlugin do + @behaviour Plugin + + @impl true + def init(_opts) do + :ok + end + + @impl true + def register_tools(registry) do + registry.register("mock_tool", fn params -> + %{result: "Mock tool processed: #{inspect(params)}"} + end) + + :ok + end + + @impl true + def metadata do + %{ + name: "Mock Plugin", + description: "A mock plugin for testing", + version: "1.0.0", + author: "Test" + } + end + end + + # Define a mock notification channel + defmodule MockChannel do + @behaviour AgentForge.Notification.Channel + + @impl true + def name, do: :mock + + @impl true + def send(message, _config) do + Agent.update(__MODULE__, fn messages -> [message | messages] end) + :ok + end + + def start_link do + Agent.start_link(fn -> [] end, name: __MODULE__) + end + + def get_messages do + Agent.get(__MODULE__, & &1) + end + + def clear do + Agent.update(__MODULE__, fn _ -> [] end) + end + end + + setup do + # Start mock channel agent + MockChannel.start_link() + :ok + end + + describe "plugin management" do + test "can load a plugin" do + assert :ok = PluginManager.load_plugin(MockPlugin) + assert PluginManager.plugin_loaded?(MockPlugin) + end + + test "can list loaded plugins" do + PluginManager.load_plugin(MockPlugin) + plugins = PluginManager.list_plugins() + + assert Map.has_key?(plugins, MockPlugin) + plugin_data = Map.get(plugins, MockPlugin) + assert plugin_data.name == "Mock Plugin" + assert plugin_data.version == "1.0.0" + end + end + + describe "plugin tools" do + test "can use tools from a plugin" do + PluginManager.load_plugin(MockPlugin) + {:ok, tool_fn} = AgentForge.Tools.get("mock_tool") + + result = tool_fn.(%{test: "data"}) + assert is_map(result) + assert result.result =~ "Mock tool processed" + end + end + + describe "notification channels" do + test "can register and use a notification channel" do + # Register the mock channel + AgentForge.Notification.Registry.register_channel(MockChannel) + + # Create a notification using the mock channel + notify = AgentForge.Primitives.notify([:mock]) + signal = Signal.new(:event, "Test notification") + + # Process the notification + {{:emit, _result}, _state} = notify.(signal, %{}) + + # Verify the notification was sent + messages = MockChannel.get_messages() + assert length(messages) == 1 + assert List.first(messages) == "\"Test notification\"" + end + end +end