Current section

Files

Jump to
cmdc lib cmdc agent state.ex
Raw

lib/cmdc/agent/state.ex

defmodule CMDC.Agent.State do
@moduledoc """
`CMDC.Agent` gen_statem 的内部运行时状态。
## 字段分组
- **Identity**`session_id``model``working_dir``config``status`
- **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`(每 turn 重置)
- **Stream transport**`streaming_resp``stream_task_pid`
- **Stream health**`last_chunk_at``stream_errored``stall_count`
- **Token & cost tracking**`token_usage``turn_count``tool_call_count``cost_usd`
- **Loop detection**`tool_call_hashes`(供 ToolRunner 内建循环检测)
- **Context relay**`sandbox``subagents``todos``memory_contents``user_data`(透传给 Context)
- **Resilience**`retry_count``max_retries`
- **Interaction**`pending_messages`
- **Steering**`steering_queue`(中段软中断 queue,详见 [`steering-design.md`](../../docs/dev/steering-design.md)
## 与旧项目的差异
- 新增 `cost_usd` — 内建成本追踪(吸收 CostTracker Plugin)
- 新增 `tool_call_hashes` — 内建循环检测(吸收 LoopDetector Plugin)
- 新增 `sandbox``subagents``todos``memory_contents` — 透传给 `CMDC.Context`
- `config` 类型改为 `CMDC.Config.t() | map()`(渐进迁移)
"""
@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 ────────────────────────────────────────────
pending_tool_tasks: %{reference() => {Task.t(), map()}},
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(v0.2 RFC C12)─────────────────────────────────
# 通过 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(v0.2 Phase 10B)─────────────────────────────
# 系统提示词模式 :full | :task | :minimal | :none
# 由 Options.prompt_mode 透传,SubAgent 默认 :task
prompt_mode: CMDC.Options.prompt_mode(),
# ── 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,
# 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
}
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 — 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