defmodule Mix.Tasks.SkillKit.Chat do @moduledoc """ Interactive chat session with a SkillKit agent. mix skill_kit.chat # select agent interactively mix skill_kit.chat neve # start specific agent mix skill_kit.chat researcher ## Webhook dev server Each chat session starts `SkillKit.Webhook` and an HTTP listener on `SKILL_KIT_WEBHOOK_PORT` (default 4001). The agent is loaded with `SkillKit.Tools.Webhook, allow_unsigned: true` so you can register ad-hoc endpoints without configuring HMAC secrets: you> register a webhook that echoes the body back agent> Webhook URL: http://localhost:4001/ $ curl -d "hi" http://localhost:4001/ # agent receives the delivery as a scoped sub-loop and responds # in the chat. Signed vendor skills (`webhook:github`, `webhook:stripe`, `webhook:slack`) are also loaded but will need a host-configured credential to actually verify inbound requests — see the kit's moduledoc for wiring. """ use Mix.Task alias Mix.SkillKit.Dotenv alias SkillKit.AgentRef alias SkillKit.Event.Delta alias SkillKit.Event.Error alias SkillKit.Event.ToolCallComplete alias SkillKit.Kit.Local, as: LocalKit alias SkillKit.Types.AssistantMessage alias SkillKit.Types.ToolResult @shortdoc "Start an interactive agent chat session" @default_webhook_port 4001 @impl true def run(args) do Dotenv.load() Mix.Task.run("app.start") agents_dir = System.get_env("SKILL_KIT_AGENTS", "examples/agents") webhook_port = webhook_port() agents = list_kits(agents_dir) agent_name = case args do [name | _] -> name [] -> select_agent(agents) end case Enum.find(agents, &(&1.name == agent_name)) do nil -> Mix.shell().error("Agent not found: #{agent_name}") Mix.shell().error("Available: #{Enum.map_join(agents, ", ", & &1.name)}") exit({:shutdown, 1}) kit -> run_agent(kit, agents_dir, webhook_port) end end defp run_agent(kit, agents_dir, webhook_port) do definition = kit.agent agent_dir = Path.join(agents_dir, kit.name) {:ok, webhook_sup} = SkillKit.Webhook.Supervisor.start_link([]) {:ok, http_sup} = start_webhook_server(webhook_port) configure_webhook_base_url(webhook_port) {:ok, printer} = Task.start_link(fn -> printer_loop(definition.name) end) {:ok, agent} = SkillKit.start_agent(agent_dir, tools: [{SkillKit.Tools.Shell, []}], skills: [{SkillKit.Tools.Webhook, [allow_unsigned: true]}], caller: printer ) print_banner(definition, webhook_port) chat_loop(agent, definition.name) SkillKit.stop_agent(agent) Process.exit(printer, :shutdown) Process.exit(http_sup, :shutdown) Process.exit(webhook_sup, :shutdown) end defp webhook_port do case System.get_env("SKILL_KIT_WEBHOOK_PORT") do nil -> @default_webhook_port raw -> {port, ""} = Integer.parse(raw) port end end defp start_webhook_server(port) do Bandit.start_link(plug: Mix.Tasks.SkillKit.Chat.WebhookHost, port: port, scheme: :http) end defp configure_webhook_base_url(port) do Application.put_env(:skill_kit, :webhook_base_url, "http://localhost:#{port}") end defp print_banner(definition, webhook_port) do IO.puts( IO.ANSI.format([ :bright, "\n#{definition.name}", :reset, :faint, " — #{definition.description}" ]) ) IO.puts( IO.ANSI.format([ :faint, "webhooks listening on http://localhost:#{webhook_port}", :reset ]) ) IO.puts(IO.ANSI.format([:faint, "type 'exit' to quit\n"])) end defp select_agent([]) do Mix.shell().error("No agents found.") exit({:shutdown, 1}) end defp select_agent([single]), do: single.name defp select_agent(kits) do print_agent_menu(kits) prompt_agent_choice(kits) end defp print_agent_menu(kits) do IO.puts(IO.ANSI.format([:bright, "\nAvailable agents:\n"])) kits |> Enum.with_index(1) |> Enum.each(fn {kit, i} -> IO.puts( IO.ANSI.format([ " ", :bright, "#{i}", :reset, ") #{kit.name}", :faint, " — #{kit.agent.description}" ]) ) end) IO.puts("") end defp prompt_agent_choice(kits) do input = String.trim(IO.gets("Select agent: ")) names = Enum.map(kits, & &1.name) case Integer.parse(input) do {n, ""} when n >= 1 and n <= length(kits) -> Enum.at(names, n - 1) _ -> if input in names, do: input, else: List.first(names) end end defp list_kits(agents_dir) do case LocalKit.list_kits(dir: agents_dir <> "/*") do {:ok, kits} -> kits |> Enum.filter(& &1.agent) |> Enum.sort_by(& &1.name) {:error, _} -> [] end end defp chat_loop(agent, agent_name) do case IO.gets("you> ") do :eof -> :ok input -> prompt = String.trim(input) handle_prompt(prompt, agent, agent_name) end end defp handle_prompt("exit", _agent, _agent_name), do: IO.puts("Goodbye.") defp handle_prompt("", agent, agent_name), do: chat_loop(agent, agent_name) defp handle_prompt(prompt, agent, agent_name) do :ok = SkillKit.send_message(agent, prompt) chat_loop(agent, agent_name) end # Printer Task — receives all agent events and prints as they arrive, # independent of the chat loop's stdin blocking. Sub-loop handling: # # * "/skill:" — activate_skill chatter. Deltas hidden # (the final text comes back as the parent's tool_result), tool # calls shown in cyan for visibility. # * "/delivery:" — webhook event processing. # Deltas SHOWN (this is the only visible surface for the sub-loop's # response; there's no parent tool_result to collapse into), tool # calls shown in cyan too. defp printer_loop(root_name) do receive do %Delta{agent: agent, text: text} -> print_delta(AgentRef.origin(agent, root_name), text) %ToolCallComplete{agent: agent, name: name, input: input} -> print_tool_call(AgentRef.origin(agent, root_name), name, input) %ToolResult{agent: agent} = result -> print_tool_result(AgentRef.origin(agent, root_name), result) %AssistantMessage{agent: agent} -> if agent == root_name, do: IO.puts("\n") %Error{agent: agent, reason: reason} -> if belongs_to?(agent, root_name) do IO.puts("\n[error] #{inspect(reason)}\n") end _other -> :ok end printer_loop(root_name) end defp print_delta(:root, text), do: IO.write(text) defp print_delta(:delivery, text), do: IO.write(IO.ANSI.format([:cyan, text])) defp print_delta(_origin, _text), do: :ok defp belongs_to?(agent_name, root_name) do agent_name == root_name or String.starts_with?(agent_name, root_name <> "/") end defp print_tool_call(:root, name, input) do IO.puts(IO.ANSI.format([:faint, tool_trace(name, input)])) end defp print_tool_call(origin, name, input) when origin in [:skill, :delivery, :subloop] do IO.puts(IO.ANSI.format([:cyan, tool_trace(name, input)])) end defp print_tool_call(:other, _name, _input), do: :ok defp tool_trace(name, input) do " ↳ #{name}(#{format_input(input)})" end defp format_input(%{"command" => command}), do: command defp format_input(%{"name" => name}), do: name defp format_input(input) when map_size(input) == 0, do: "" defp format_input(input), do: inspect(input, limit: 3) # Skip printing the final `activate_skill` tool result — the sub-loop # already streamed that text via Delta events, so printing it again # (truncated) just looks like a duplicate ghost message. defp print_tool_result(_origin, %ToolResult{name: "activate_skill", is_error: false}), do: :ok defp print_tool_result(:root, %ToolResult{is_error: true, content: content}) do IO.puts(IO.ANSI.format([:red, " ← error: ", :reset, truncate(content)])) end defp print_tool_result(:root, %ToolResult{content: content}) do IO.puts(IO.ANSI.format([:faint, " ← ", truncate(content)])) end defp print_tool_result(origin, %ToolResult{is_error: true, content: content}) when origin in [:skill, :delivery, :subloop] do IO.puts(IO.ANSI.format([:red, " ← error: ", :reset, truncate(content)])) end defp print_tool_result(origin, %ToolResult{content: content}) when origin in [:skill, :delivery, :subloop] do IO.puts(IO.ANSI.format([:cyan, " ← ", truncate(content)])) end defp print_tool_result(:other, _result), do: :ok defp truncate(content) when is_binary(content) do if String.length(content) > 240, do: String.slice(content, 0, 240) <> "…", else: content end defp truncate(content), do: inspect(content, limit: 4) end defmodule Mix.Tasks.SkillKit.Chat.WebhookHost do @moduledoc false # Top-level Plug for the chat dev server. Reads the raw body into # `conn.assigns.raw_body` (required by HMAC verifiers) and hands the # conn to `SkillKit.Webhook.Plug`. @behaviour Plug alias SkillKit.Webhook.Plug, as: WebhookPlug @impl true def init(_opts), do: [] @impl true def call(conn, _opts) do case Plug.Conn.read_body(conn, []) do {:ok, body, conn} -> conn |> Plug.Conn.assign(:raw_body, body) |> WebhookPlug.call(WebhookPlug.init([])) {:more, _partial, conn} -> Plug.Conn.send_resp(conn, 413, "request body too large") {:error, _reason} -> Plug.Conn.send_resp(conn, 400, "") end end end