Current section
Files
Jump to
Current section
Files
lib/cmdc/agent/state.ex
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