defmodule CMDC.Tool.AskUser do @moduledoc """ 向用户提问,等待用户回答后继续,对标 DeepAgents `ask_user` 工具。 ## 执行流程 1. 生成唯一 `ref` 2. 广播 `{:ask_user, session_id, question, options, ref}` 事件 3. 阻塞等待 `{:cmdc_event, session_id, {:user_responded, session_id, ref, response}}` 消息 4. 超时(默认 5 分钟)则返回错误 5. 将用户回答作为工具结果返回 LLM ## 选项参数(可选) `options` 字段支持预设选项列表,UI 可展示为单选/多选。 为空列表时视为自由文本输入。 ## 注意 此工具会**阻塞当前工具执行 Task**,直到用户响应或超时。 外部需通过 `CMDC.approve/2` 或发送对应事件来解除阻塞。 """ @behaviour CMDC.Tool @default_timeout 300_000 @impl true def name, do: "ask_user" @impl true def description do "Ask the user a question and wait for their response before continuing. " <> "Use this when you need clarification, a decision, or information " <> "that cannot be inferred from context. " <> "Do NOT use this for simple yes/no decisions you can make yourself." end @impl true def meta(%{"question" => q}), do: "Ask: #{String.slice(q, 0, 54)}" def meta(_), do: "AskUser" @impl true def parameters do %{ "type" => "object", "properties" => %{ "question" => %{ "type" => "string", "description" => "The question to ask the user. Be specific and concise." }, "options" => %{ "type" => "array", "items" => %{ "type" => "object", "properties" => %{ "id" => %{"type" => "string", "description" => "Option identifier"}, "label" => %{"type" => "string", "description" => "Display text for the option"} }, "required" => ["id", "label"] }, "description" => "Optional list of pre-defined answer options. " <> "If provided, user selects from these choices. " <> "If empty, free-form text input is expected." } }, "required" => ["question"] } end @impl true def execute(%{"question" => question} = args, context) do session_id = context.session_id options = Map.get(args, "options", []) ref = generate_ref() timeout = @default_timeout CMDC.EventBus.subscribe(session_id) CMDC.EventBus.broadcast(session_id, {:ask_user, session_id, question, options, ref}) result = wait_for_response(session_id, ref, timeout) CMDC.EventBus.unsubscribe(session_id) result end def execute(_args, _ctx), do: {:error, "Missing required parameter: question"} # ========================================================================== # 私有辅助 # ========================================================================== defp wait_for_response(session_id, ref, timeout) do receive do {:cmdc_event, ^session_id, {:user_responded, ^session_id, ^ref, response}} -> {:ok, format_response(response)} {:cmdc_event, ^session_id, {:user_responded, ^session_id, ^ref, response, _meta}} -> {:ok, format_response(response)} after timeout -> {:error, "User did not respond within #{div(timeout, 1000)}s timeout"} end end defp format_response(response) when is_binary(response), do: response defp format_response(response) when is_list(response), do: Enum.join(response, ", ") defp format_response(response), do: inspect(response) defp generate_ref do :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower) end end