defmodule CMDC.Agent.State do # 内部模块 — `CMDC.Agent` gen_statem 运行时状态。API 不保证稳定。 # 外部读 Agent 状态请用 `CMDC.status/1`。 @moduledoc false @type status :: :idle | :running | :streaming | :executing_tools @type t :: %__MODULE__{ # ── Identity ────────────────────────────────────────────────── session_id: String.t(), model: String.t(), working_dir: String.t(), config: CMDC.Config.t() | map(), status: status(), # ── Conversation ────────────────────────────────────────────── blueprint_system_prompt: String.t(), messages: [CMDC.Message.t()], # ── Plugin Pipeline ─────────────────────────────────────────── plugins: [{module(), term()}], plugin_states: %{module() => term()}, # ── Tool registry ───────────────────────────────────────────── tools: [module()], disabled_tools: [String.t()], # ── Tool execution ──────────────────────────────────────────── # 第三元素 started_at_ms 用于 status/1 暴露 + duration_ms 计算 pending_tool_tasks: %{reference() => {Task.t(), map(), integer()}}, tool_results: [{map(), term()}], # ── Streaming accumulator(每 turn 重置)───────────────────── current_text: String.t(), current_tool_calls: [map()], current_thinking: String.t() | nil, message_started: boolean(), tag_buffers: %{atom() => String.t()}, tool_call_arg_buffers: %{non_neg_integer() => iodata()}, # ── Stream transport ────────────────────────────────────────── streaming_resp: term() | nil, stream_task_pid: pid() | nil, # ── Stream health ───────────────────────────────────────────── last_chunk_at: integer() | nil, stream_errored: false | term(), stall_count: non_neg_integer(), # ── Token & cost tracking ───────────────────────────────────── # 内部 token_usage 仍使用 map 兼容存量代码(含上下文窗口元数据); # 公开 API(事件/status)通过 `CMDC.TokenUsage.from_state_token_map/3` 转 struct。 token_usage: %{ prompt_tokens: non_neg_integer(), completion_tokens: non_neg_integer(), total_tokens: non_neg_integer(), context_window: non_neg_integer(), current_context_tokens: non_neg_integer() }, turn_count: non_neg_integer(), tool_call_count: non_neg_integer(), cost_usd: float(), cached_tokens: non_neg_integer() | nil, # ── Loop detection ──────────────────────────────────────────── # 最近 N 次工具调用的 hash 序列,供 ToolRunner 检测重复模式 tool_call_hashes: [binary()], # ── Context relay ───────────────────────────────────────────── sandbox: module() | nil, subagents: [map()], todos: [map()], memory_contents: %{String.t() => String.t()}, user_data: map(), # ── Resilience ──────────────────────────────────────────────── retry_count: non_neg_integer(), max_retries: pos_integer(), retry_base_delay_ms: pos_integer(), retry_max_delay_ms: pos_integer(), overflow_detected: boolean(), # ── Interaction ─────────────────────────────────────────────── pending_messages: [String.t()], # ── Steering(中段软中断)──────────────────────────────────── steering_queue: [ %{ref: reference(), text: String.t(), queued_at: integer()} ], # ── Monitors ───────────────────────────────── # 通过 CMDC.monitor/1 登记的观察者,Agent terminate 时广播 # {:cmdc_down, ref, session_id, structured_reason} monitors: %{reference() => pid()}, # 记录"预期结构化退出原因",terminate 时优先使用此值覆盖 raw reason。 # 典型来源:plugin abort / max_turns / provider_timeout 等内部已知分支。 exit_reason: term() | nil, # ── PromptMode ───────────────────────────── # 系统提示词模式 :full | :task | :minimal | :none # 由 Options.prompt_mode 透传,SubAgent 默认 :task prompt_mode: CMDC.Options.prompt_mode(), # ── Dynamic System Context(Planning 插件持续注入)─ # Plugin 可通过 emit {:update_system_context, key, text_or_nil} 动态 # 向 system prompt 末尾追加段落;text=nil 表示删除该 key。 # 典型用途:Planning plugin 把 Plan 的 progress 实时注入,让 LLM 每 # 轮都能感知计划推进状态。 dynamic_context_sections: %{atom() => String.t()}, # ── After-turn marker ───────────────────── # idle → running 时记录起始快照(messages 长度 + token_usage 快照 + # 起始时间),用于在 finish / finalize_abort 触发 `:after_turn` # Plugin hook 时计算 diff payload。 # `nil` 表示当前不在 prompt cycle 中(idle 起始或已被 emit 清空)。 turn_start_marker: %{ messages_offset: non_neg_integer(), token_usage_snapshot: map(), cost_usd_snapshot: float(), started_at_ms: integer() } | nil, # ── Lifecycle ───────────────────────────────────────────────── started_at: integer() } @enforce_keys [:session_id, :model, :working_dir] # credo:disable-for-next-line Credo.Check.Warning.StructFieldAmount defstruct [ # Identity :session_id, :model, :working_dir, config: %{}, status: :idle, # Conversation blueprint_system_prompt: "", messages: [], # Plugin Pipeline plugins: [], plugin_states: %{}, # Tool registry tools: [], disabled_tools: [], # Tool execution pending_tool_tasks: %{}, tool_results: [], # Streaming accumulator current_text: "", current_tool_calls: [], current_thinking: nil, message_started: false, tag_buffers: %{}, tool_call_arg_buffers: %{}, # Stream transport streaming_resp: nil, stream_task_pid: nil, # Stream health last_chunk_at: nil, stream_errored: false, stall_count: 0, # Token & cost tracking token_usage: %{ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, context_window: 0, current_context_tokens: 0 }, turn_count: 0, tool_call_count: 0, cost_usd: 0.0, cached_tokens: nil, # Loop detection tool_call_hashes: [], # Context relay sandbox: nil, subagents: [], todos: [], memory_contents: %{}, user_data: %{}, # Resilience retry_count: 0, max_retries: 3, retry_base_delay_ms: 2_000, retry_max_delay_ms: 60_000, overflow_detected: false, # Interaction pending_messages: [], # Steering steering_queue: [], # Monitors monitors: %{}, exit_reason: nil, # PromptMode prompt_mode: :full, # Dynamic System Context dynamic_context_sections: %{}, # After-turn marker turn_start_marker: nil, # Lifecycle started_at: 0 ] @max_hash_history 20 @default_max_steering_queue 3 # ========================================================================== # Public API — 构造 # ========================================================================== @doc """ 从 keyword list 构建 State struct。 供 Agent.init/1 使用,自动设置 started_at 时间戳。 ## 示例 state = State.new(session_id: "abc", model: "anthropic:claude-sonnet-4-5", working_dir: ".") """ @spec new(keyword()) :: t() def new(opts) when is_list(opts) do struct!(__MODULE__, Keyword.put_new(opts, :started_at, System.system_time(:millisecond))) end # ========================================================================== # Public API — 状态映射 # ========================================================================== @doc "映射 status 字段为 gen_statem 状态名称。" @spec state_name(t()) :: status() def state_name(%__MODULE__{status: status}) when status in [:idle, :running, :streaming, :executing_tools], do: status # ========================================================================== # Public API — 消息操作 # ========================================================================== @doc "向消息历史头部追加一条消息(反序存储:最新在前)。" @spec append_message(t(), CMDC.Message.t()) :: t() def append_message(%__MODULE__{} = state, %CMDC.Message{} = msg) do %{state | messages: [msg | state.messages]} end @doc "向消息历史批量追加多条消息。" @spec append_messages(t(), [CMDC.Message.t()]) :: t() def append_messages(%__MODULE__{} = state, msgs) when is_list(msgs) do %{state | messages: Enum.reverse(msgs) ++ state.messages} end # ========================================================================== # Public API — 流式累积重置 # ========================================================================== @doc "重置每个 turn 的流式累积字段(在新 turn 开始时调用)。" @spec reset_stream_fields(t()) :: t() def reset_stream_fields(%__MODULE__{} = state) do %{ state | current_text: "", current_tool_calls: [], current_thinking: nil, message_started: false, tag_buffers: %{}, tool_call_arg_buffers: %{}, last_chunk_at: nil, stream_errored: false, stream_task_pid: nil, stall_count: 0 } end # ========================================================================== # Public API — Plugin 状态同步 # ========================================================================== @doc "从 Plugin.Pipeline 的执行结果中同步 plugin_states 回 State。" @spec sync_plugin_states(t(), map()) :: t() def sync_plugin_states(%__MODULE__{} = state, pipeline_result) do new_plugins = Enum.map(state.plugins, fn {mod, _old_state} -> {mod, Map.get(pipeline_result.plugin_states, mod, %{})} end) %{state | plugins: new_plugins, plugin_states: pipeline_result.plugin_states} end # ========================================================================== # Public API — Context 构建 # ========================================================================== @doc """ 构建当前 State 对应的 Context 快照(只读,传给 Plugin/Tool)。 将 State 内部字段映射到 Context 的公共接口: - `turn_count` → `turn` - `token_usage.total_tokens` → `total_tokens` - `cost_usd` → `cost_usd` - `sandbox`、`subagents`、`todos`、`memory_contents`、`user_data` 直接透传 """ @spec to_context(t()) :: CMDC.Context.t() def to_context(%__MODULE__{} = state) do %CMDC.Context{ session_id: state.session_id, working_dir: state.working_dir, model: state.model, sandbox: state.sandbox, tools: state.tools, subagents: state.subagents, config: state.config, todos: state.todos, memory_contents: state.memory_contents, user_data: state.user_data, turn: state.turn_count, total_tokens: state.token_usage.total_tokens, cost_usd: state.cost_usd, last_assistant_reply: last_assistant_reply(state) } end # 从 state.messages(头部最新)中取最后一条 assistant 文本回复 defp last_assistant_reply(%__MODULE__{messages: messages}) do Enum.find_value(messages, nil, fn %{role: :assistant, content: c} when is_binary(c) and c != "" -> c _ -> nil end) end # ========================================================================== # Public API — 计数器 # ========================================================================== @doc "增加 turn_count 计数。" @spec increment_turn(t()) :: t() def increment_turn(%__MODULE__{turn_count: n} = state) do %{state | turn_count: n + 1} end @doc "增加 tool_call_count 计数。" @spec increment_tool_calls(t(), non_neg_integer()) :: t() def increment_tool_calls(%__MODULE__{tool_call_count: n} = state, delta \\ 1) do %{state | tool_call_count: n + delta} end # ========================================================================== # Public API — Turn marker # ========================================================================== @doc """ 在 prompt cycle 起始处(idle → running)记录快照。 如果当前已存在 marker(理论上不应发生,例如未消费的 cycle),保留旧 marker 不覆盖,避免漏算上一次 prompt cycle 的 diff。 """ @spec mark_turn_start(t()) :: t() def mark_turn_start(%__MODULE__{turn_start_marker: m} = state) when is_map(m), do: state def mark_turn_start(%__MODULE__{} = state) do marker = %{ messages_offset: length(state.messages), token_usage_snapshot: state.token_usage, cost_usd_snapshot: state.cost_usd, started_at_ms: System.system_time(:millisecond) } %{state | turn_start_marker: marker} end @doc """ 消费 marker,返回 `{after_turn_payload | nil, state_with_marker_cleared}`。 - `outcome :: :finished | :aborted` - `abort_reason` 仅在 `:aborted` 时非 nil payload 字段: - `:outcome` — `:finished | :aborted` - `:abort_reason` — `term() | nil` - `:messages_diff` — 本轮 prompt cycle 内新增的消息(按时间顺序) - `:token_usage_diff` — `%CMDC.TokenUsage{}` 本轮增量 - `:duration_ms` — 本轮总耗时 - `:started_at_ms` / `:ended_at_ms` — 起止时间戳 marker 为 `nil` 时返回 `{nil, state}`(Plugin 不会触发 `:after_turn`)。 """ @spec consume_turn_marker(t(), :finished | :aborted, term() | nil) :: {map() | nil, t()} def consume_turn_marker(%__MODULE__{turn_start_marker: nil} = state, _outcome, _reason), do: {nil, state} def consume_turn_marker( %__MODULE__{turn_start_marker: marker} = state, outcome, abort_reason ) when outcome in [:finished, :aborted] do ended_at_ms = System.system_time(:millisecond) messages_diff = diff_messages_since(state.messages, marker.messages_offset) snap = marker.token_usage_snapshot token_usage_diff = %CMDC.TokenUsage{ prompt_tokens: state.token_usage.prompt_tokens - Map.get(snap, :prompt_tokens, 0), completion_tokens: state.token_usage.completion_tokens - Map.get(snap, :completion_tokens, 0), total_tokens: state.token_usage.total_tokens - Map.get(snap, :total_tokens, 0), cost_usd: state.cost_usd - marker.cost_usd_snapshot, cached_tokens: nil } payload = %{ outcome: outcome, abort_reason: abort_reason, messages_diff: messages_diff, token_usage_diff: token_usage_diff, started_at_ms: marker.started_at_ms, ended_at_ms: ended_at_ms, duration_ms: max(ended_at_ms - marker.started_at_ms, 0) } {payload, %{state | turn_start_marker: nil}} end defp diff_messages_since(messages, offset) when is_list(messages) and is_integer(offset) do take = max(length(messages) - offset, 0) messages |> Enum.take(take) |> Enum.reverse() end # ========================================================================== # Public API — Token & Cost 追踪 # ========================================================================== @doc """ 更新 token_usage,累加新的 usage 数据。 `new_usage` 接受 plain map 或 `%CMDC.TokenUsage{}` struct(自动取共有字段)。 Anthropic 的 cache_read_input_tokens 通过 `:cached_tokens` 字段累加到顶层 `state.cached_tokens`(独立于 token_usage map,保持向后兼容)。 """ @spec update_token_usage(t(), map() | CMDC.TokenUsage.t()) :: t() def update_token_usage(%__MODULE__{token_usage: current} = state, new_usage) do merged = %{ prompt_tokens: current.prompt_tokens + Map.get(new_usage, :prompt_tokens, 0), completion_tokens: current.completion_tokens + Map.get(new_usage, :completion_tokens, 0), total_tokens: current.total_tokens + Map.get(new_usage, :total_tokens, 0), context_window: Map.get(new_usage, :context_window, current.context_window), current_context_tokens: Map.get(new_usage, :current_context_tokens, current.current_context_tokens) } new_cached = case Map.get(new_usage, :cached_tokens) do n when is_integer(n) -> (state.cached_tokens || 0) + n _ -> state.cached_tokens end %{state | token_usage: merged, cached_tokens: new_cached} end @doc """ 更新累计成本(USD)。 在每轮 stream_done 后由 Agent 调用,基于 token 使用量和模型定价计算。 """ @spec update_cost(t(), float()) :: t() def update_cost(%__MODULE__{cost_usd: current} = state, delta_usd) when is_number(delta_usd) do %{state | cost_usd: current + delta_usd} end # ========================================================================== # Public API — 循环检测 # ========================================================================== @doc """ 记录一次工具调用 hash,用于 ToolRunner 循环检测。 保留最近 #{@max_hash_history} 条记录,超出时截断最旧的。 """ @spec record_tool_call_hash(t(), binary()) :: t() def record_tool_call_hash(%__MODULE__{tool_call_hashes: hashes} = state, hash) when is_binary(hash) do updated = [hash | hashes] |> Enum.take(@max_hash_history) %{state | tool_call_hashes: updated} end @doc "返回循环检测 hash 历史的最大保留数。" @spec max_hash_history() :: pos_integer() def max_hash_history, do: @max_hash_history # ========================================================================== # Public API — Steering(中段软中断) # ========================================================================== @typedoc "Steering queue 中的单条记录。" @type steering_entry :: %{ref: reference(), text: String.t(), queued_at: integer()} @doc """ 向 `:steering_queue` 末尾追加一条 steering 记录。 Queue 上限由 `state.config[:max_steering_queue]` 控制(缺省 #{@default_max_steering_queue})。 溢出时丢弃**最旧**一条(FIFO 截断),保证总长不超 limit。 """ @spec enqueue_steering(t(), reference(), String.t()) :: t() def enqueue_steering(%__MODULE__{} = state, ref, text) when is_reference(ref) and is_binary(text) do entry = %{ref: ref, text: text, queued_at: System.system_time(:millisecond)} limit = max_steering_queue(state) new_queue = (state.steering_queue ++ [entry]) |> Enum.take(-limit) %{state | steering_queue: new_queue} end @doc """ 一次性取出 `:steering_queue` 中的全部记录并清空。 返回 `{entries, state}`,`entries` 为按时间顺序排列的 list(最早在前)。 Steering 注入下一 turn 时由 ToolRunner.inject_steering 调用。 """ @spec drain_steering(t()) :: {[steering_entry()], t()} def drain_steering(%__MODULE__{steering_queue: queue} = state) do {queue, %{state | steering_queue: []}} end @doc """ 返回 `:steering_queue` 已满(达到 max_steering_queue 上限)。 调用方在 enqueue 之前用此判断决定是否拒绝新 steer。 """ @spec steering_queue_full?(t()) :: boolean() def steering_queue_full?(%__MODULE__{} = state) do length(state.steering_queue) >= max_steering_queue(state) end @doc """ 返回当前 state 的 `max_steering_queue` 配置(无配置时回落到默认值 #{@default_max_steering_queue})。 """ @spec max_steering_queue(t()) :: pos_integer() def max_steering_queue(%__MODULE__{config: config}) do case config_get(config, :max_steering_queue) do n when is_integer(n) and n > 0 -> n _ -> @default_max_steering_queue end end @doc """ 返回当前 state 的 `interrupt_immune_tools` 白名单(缺省即 Options 默认值)。 """ @spec interrupt_immune_tools(t()) :: [String.t()] def interrupt_immune_tools(%__MODULE__{config: config}) do case config_get(config, :interrupt_immune_tools) do list when is_list(list) -> list _ -> [] end end # 兼容 config 为 map / Config struct / nil 三种形态。 defp config_get(%CMDC.Config{} = config, key), do: Map.get(config, key) defp config_get(config, key) when is_map(config), do: Map.get(config, key) defp config_get(_, _), do: nil end