Current section
Files
Jump to
Current section
Files
lib/cmdc/session_server.ex
defmodule CMDC.SessionServer do
@moduledoc """
每个会话的进程树监督者。
每个会话拥有独立的监督子树:
```
CMDC.SessionServer (Supervisor, :one_for_one)
├── CMDC.SubAgent.Supervisor — 子代理动态监督者
└── CMDC.Agent — 主 Agent 状态机
```
终止 SessionServer 会清理所有资源:主 Agent 和所有子代理进程。
## 必要选项
* `:session_id` — 唯一会话标识符(字符串)
## 可选选项
* `:blueprint` — Blueprint 模块(实现 `CMDC.Blueprint` behaviour)或 `CMDC.Blueprint.t()` struct,
自动调用 `build([])` 展开为启动选项。Blueprint 展开的选项优先级**低于**显式传入的同名选项。
* `:working_dir` — 工具执行的基础目录,默认当前目录
* `:model` — LLM 模型 ID,格式 `"provider:model_id"`
* `:system_prompt` — 系统提示词
* `:tools` — Tool 模块列表
* `:plugins` — Plugin 列表
* `:config` — `CMDC.Config.t()` struct
* `:provider_opts` — Provider 初始化选项
## 示例
直接传选项:
{:ok, server} = CMDC.SessionServer.start_link(
session_id: "s-001",
model: "anthropic:claude-sonnet-4-5",
working_dir: "/project"
)
agent_pid = CMDC.SessionServer.agent(server)
通过 Blueprint 模块启动:
{:ok, server} = CMDC.SessionServer.start_link(
session_id: "s-002",
blueprint: MyApp.Agents.Translator
)
agent_pid = CMDC.SessionServer.agent(server)
CMDC.Agent.prompt(agent_pid, "Hello")
"""
use Supervisor
# ==========================================================================
# Public API
# ==========================================================================
@doc """
启动会话监督者。
必须在 `opts` 中提供 `:session_id`(字符串)。SessionServer 进程会以
`{:via, Registry, {CMDC.SessionRegistry, session_id}}` 注册,用于通过
字符串 session_id 反查 pid(`Registry.lookup/2`)。
同一 session_id 重复启动会返回 `{:error, {:already_started, pid}}`。
"""
@spec start_link(keyword()) :: Supervisor.on_start()
def start_link(opts) do
session_id = Keyword.fetch!(opts, :session_id)
Supervisor.start_link(__MODULE__, opts,
name: {:via, Registry, {CMDC.SessionRegistry, session_id}}
)
end
@doc """
通过 session_id 字符串反查 SessionServer pid。
返回 pid 或 `nil`(session 未运行 / 已被 GC)。
"""
@spec whereis(String.t()) :: pid() | nil
def whereis(session_id) when is_binary(session_id) do
case Registry.lookup(CMDC.SessionRegistry, session_id) do
[{pid, _}] -> pid
[] -> nil
end
end
@doc "从 SessionServer 监督者获取主 Agent pid。"
@spec agent(pid()) :: pid() | nil
def agent(session_server) do
session_server
|> Supervisor.which_children()
|> Enum.find_value(fn
{CMDC.Agent, pid, :worker, _} when is_pid(pid) -> pid
_ -> nil
end)
end
@doc "从 SessionServer 获取 SubAgent.Supervisor pid。"
@spec sub_agent_supervisor(pid()) :: pid() | nil
def sub_agent_supervisor(session_server) do
session_server
|> Supervisor.which_children()
|> Enum.find_value(fn
{CMDC.SubAgent.Supervisor, pid, :supervisor, _} when is_pid(pid) -> pid
_ -> nil
end)
end
@doc "向主 Agent 发送 prompt,返回结果。"
@spec prompt(pid(), String.t()) :: map()
def prompt(session_server, text) when is_binary(text) do
session_server
|> agent()
|> CMDC.Agent.prompt(text)
end
# ==========================================================================
# Supervisor Callback
# ==========================================================================
@impl true
def init(opts) do
opts = expand_blueprint(opts)
config = Keyword.get(opts, :config, CMDC.Config.new())
config = normalize_config(config)
working_dir = Keyword.get(opts, :working_dir, File.cwd!())
session_id = Keyword.fetch!(opts, :session_id)
model = Keyword.get(opts, :model, config.default_model)
tools = Keyword.get(opts, :tools, config.default_tools)
provider_opts = Keyword.get(opts, :provider_opts, config.provider_opts || [])
# skills_dirs → Skill.discover → config.skills 自动集成
skills_dirs = Keyword.get(opts, :skills_dirs, [])
config = maybe_discover_skills(config, skills_dirs, working_dir)
# skill_selector → config.skill_selector 透传给 SystemPrompt
skill_selector = Keyword.get(opts, :skill_selector)
config = if skill_selector, do: Map.put(config, :skill_selector, skill_selector), else: config
# Steering 相关:把 Options 顶层字段下沉到 config map,State helper 从 state.config 读
config =
config
|> maybe_put_config(opts, :max_steering_queue)
|> maybe_put_config(opts, :interrupt_immune_tools)
agent_opts =
opts
|> Keyword.take([
:disabled_tools,
:plugins,
:messages,
:blueprint,
:subagents,
:user_data,
:event_buffer_size,
:prompt_mode
])
|> then(fn kw ->
case Keyword.get(opts, :system_prompt) do
nil -> kw
prompt -> Keyword.put(kw, :blueprint_system_prompt, prompt)
end
end)
|> Keyword.put(:provider_opts, provider_opts)
|> Keyword.merge(
session_id: session_id,
working_dir: working_dir,
model: model,
tools: tools,
config: config
)
children = [
%{
id: CMDC.SubAgent.Supervisor,
start: {CMDC.SubAgent.Supervisor, :start_link, [[]]},
type: :supervisor
},
{CMDC.Agent, agent_opts}
]
Supervisor.init(children, strategy: :one_for_one)
end
# ==========================================================================
# 私有辅助 — Config 规范化
# ==========================================================================
# 兼容测试中传入裸 map(如 %{provider_fn: ...})和正式 Config struct 两种形式。
defp normalize_config(%CMDC.Config{} = config), do: config
# Config.new() 返回 {:ok, config},兼容此形式
defp normalize_config({:ok, %CMDC.Config{} = config}), do: config
defp normalize_config(raw_map) when is_map(raw_map) do
{:ok, base} = CMDC.Config.new()
extra_fields =
Map.take(raw_map, [
:provider_fn,
:compact_trigger,
:compact_keep,
:data_dir,
:tool_max_retries,
:tool_retry_delay_ms,
:provider_opts
])
Map.merge(base, extra_fields)
end
defp normalize_config(nil), do: CMDC.Config.new!()
# Options 顶层字段(如 :max_steering_queue / :interrupt_immune_tools)下沉到 config map。
# 仅在 opts 显式提供时才覆盖,否则保留 config 现有值或缺省。
defp maybe_put_config(config, opts, key) do
case Keyword.fetch(opts, key) do
{:ok, value} -> Map.put(config, key, value)
:error -> config
end
end
# skills_dirs 非空时自动 discover,并将结果合并进 config.skills(去重)
defp maybe_discover_skills(config, [], _working_dir), do: config
defp maybe_discover_skills(config, dirs, working_dir) when is_list(dirs) do
# 相对路径相对于 working_dir 解析
resolved_dirs =
Enum.map(dirs, fn dir ->
if Path.type(dir) == :absolute, do: dir, else: Path.join(working_dir, dir)
end)
discovered = CMDC.Skill.discover(resolved_dirs)
existing = Map.get(config, :skills, [])
merged = Enum.uniq_by(existing ++ discovered, & &1.name)
Map.put(config, :skills, merged)
end
# ==========================================================================
# 私有辅助 — Blueprint 展开
# ==========================================================================
defp expand_blueprint(opts) do
case Keyword.pop(opts, :blueprint) do
{nil, opts} ->
opts
{blueprint_mod, opts} when is_atom(blueprint_mod) ->
bp_options = blueprint_mod.build([])
bp_kw = options_to_keyword(bp_options)
merge_blueprint_opts(bp_kw, opts)
{%CMDC.Blueprint{} = bp, opts} ->
bp_options = CMDC.Blueprint.to_options(bp)
bp_kw = options_to_keyword(bp_options)
merge_blueprint_opts(bp_kw, opts)
{%CMDC.Options{} = bp_options, opts} ->
bp_kw = options_to_keyword(bp_options)
merge_blueprint_opts(bp_kw, opts)
end
end
defp options_to_keyword(%CMDC.Options{} = options) do
options
|> Map.from_struct()
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Keyword.new()
end
defp merge_blueprint_opts(bp_opts, user_opts) do
Enum.reduce(bp_opts, user_opts, fn {key, val}, acc ->
if Keyword.has_key?(acc, key), do: acc, else: Keyword.put(acc, key, val)
end)
end
end