butler/tools/conversation_state_tools.py
Rama main · réplica de lectura del repositorio original.
"""Conversation state tools — let the LLM read and update conversation state.
Provides tools:
- conversation_state_read: Get current conversation state (goal, plan, decisions, etc.)
- conversation_state_update: Update conversation state (add decision, plan step, task, etc.)
- conversation_state_search: Semantic search through historical turn summaries
Also provides cross-session persistence via JSON file storage.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from butler.core.conversation_state import ConversationState, TaskNode
_STATE_DIR = Path.home() / ".butler" / "conversation_states"
_STATE_FILE = _STATE_DIR / "current.json"
def persist_conversation_state(state: ConversationState) -> None:
_STATE_DIR.mkdir(parents=True, exist_ok=True)
data = {
"conversation_goal": state.conversation_goal,
"current_task_summary": state.current_task_summary,
"current_plan_steps": state.current_plan_steps,
"open_questions": state.open_questions,
"current_branch": state.current_branch,
"last_build_status": state.last_build_status,
"pending_todos": state.pending_todos,
"files_modified": state.files_modified,
"decisions_made": [
{"turn_number": d.turn_number, "decision": d.decision, "rationale": d.rationale, "outcome": d.outcome}
for d in state.decisions_made
],
"turn_summaries": [
{"turn_number": t.turn_number, "user_intent": t.user_intent,
"assistant_action": t.assistant_action, "result_summary": t.result_summary,
"files_touched": t.files_touched}
for t in state.turn_summaries
],
"chapter_summaries": [
{"chapter_number": c.chapter_number, "start_turn": c.start_turn,
"end_turn": c.end_turn, "summary": c.summary,
"key_decisions": c.key_decisions, "key_files": c.key_files}
for c in state.chapter_summaries
],
"file_change_log": [
{"path": f.path, "operation": f.operation, "description": f.description,
"turn_number": f.turn_number}
for f in state.file_change_log
],
"task_tree": state.task_tree.to_dict() if state.task_tree else None,
}
with open(_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_conversation_state() -> ConversationState | None:
if not _STATE_FILE.exists():
return None
try:
with open(_STATE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
state = ConversationState(
conversation_goal=data.get("conversation_goal", ""),
current_task_summary=data.get("current_task_summary", ""),
current_plan_steps=data.get("current_plan_steps", []),
open_questions=data.get("open_questions", []),
current_branch=data.get("current_branch", ""),
last_build_status=data.get("last_build_status", ""),
pending_todos=data.get("pending_todos", []),
files_modified=data.get("files_modified", []),
)
for d in data.get("decisions_made", []):
state.add_decision(
turn_number=d.get("turn_number", 0),
decision=d.get("decision", ""),
rationale=d.get("rationale", ""),
outcome=d.get("outcome", ""),
)
for t in data.get("turn_summaries", []):
state.add_turn_summary(
turn_number=t.get("turn_number", 0),
user_intent=t.get("user_intent", ""),
assistant_action=t.get("assistant_action", ""),
result_summary=t.get("result_summary", ""),
files_touched=t.get("files_touched", []),
)
for c in data.get("chapter_summaries", []):
state.add_chapter_summary(
chapter_number=c.get("chapter_number", 0),
start_turn=c.get("start_turn", 0),
end_turn=c.get("end_turn", 0),
summary=c.get("summary", ""),
key_decisions=c.get("key_decisions", []),
key_files=c.get("key_files", []),
)
for f in data.get("file_change_log", []):
state.add_file_change(
path=f.get("path", ""),
operation=f.get("operation", ""),
description=f.get("description", ""),
turn_number=f.get("turn_number", 0),
)
if data.get("task_tree"):
state.task_tree = TaskNode.from_dict(data["task_tree"])
return state
except Exception:
return None
def _format_task_tree(node: TaskNode | None, indent: int = 0) -> str:
if node is None:
return ""
lines = []
prefix = " " * indent
status_icon = {
"completed": "[✓]",
"in_progress": "[~]",
"pending": "[ ]",
"blocked": "[!]",
}.get(node.status, "[?]")
lines.append(f"{prefix}{status_icon} {node.title}")
if node.description:
lines.append(f"{prefix} 描述: {node.description[:100]}")
for child in node.children:
lines.append(_format_task_tree(child, indent + 1))
return "\n".join(lines)
def tool_conversation_state_read(mode: str = "quick") -> str:
"""Read conversation state."""
from butler.core.agent_loop.loop import AgentLoop
from butler.execution_context import get_current_orchestrator
orch = get_current_orchestrator()
loop: AgentLoop | None = None
if orch is not None:
loop = getattr(orch, "_loop", None)
state: ConversationState | None = None
if loop is not None and hasattr(loop, "_conversation_state"):
state = loop._conversation_state
else:
state = load_conversation_state()
if state is None:
return json.dumps({"ok": False, "error": "No conversation state found"}, ensure_ascii=False)
if mode == "full":
return json.dumps(state.to_full_state(), ensure_ascii=False, indent=2)
elif mode == "tasks":
tree_text = _format_task_tree(state.task_tree) if state.task_tree else "无任务树"
return f"任务树:\n{tree_text}"
elif mode == "files":
changes = "\n".join(
f" [{c.turn_number}] {c.operation} {c.path} - {c.description}"
for c in state.file_change_log[-20:]
)
return f"文件变更历史:\n{changes}"
elif mode == "decisions":
decisions = "\n".join(
f" [{d.turn_number}] {d.decision} - {d.outcome or '进行中'}"
for d in state.decisions_made[-10:]
)
return f"关键决策:\n{decisions}"
elif mode == "chapters":
chapters = "\n".join(
f" 章节{c.chapter_number} (Turn {c.start_turn}-{c.end_turn}): {c.summary[:200]}"
for c in state.chapter_summaries[-5:]
)
return f"章节摘要:\n{chapters}"
else:
return json.dumps({
"ok": True,
"conversation_goal": state.conversation_goal,
"current_task_summary": state.current_task_summary,
"current_plan_steps": state.current_plan_steps,
"open_questions": state.open_questions[:5],
"files_modified": state.files_modified[-10:],
"current_branch": state.current_branch,
"last_build_status": state.last_build_status,
"pending_todos": state.pending_todos[:5],
"turn_count": len(state.turn_summaries),
"chapter_count": len(state.chapter_summaries),
}, ensure_ascii=False)
def tool_conversation_state_update(
action: str,
**kwargs: Any,
) -> str:
"""Update conversation state."""
from butler.core.agent_loop.loop import AgentLoop
from butler.execution_context import get_current_orchestrator
orch = get_current_orchestrator()
loop: AgentLoop | None = None
if orch is not None:
loop = getattr(orch, "_loop", None)
state: ConversationState | None = None
if loop is not None and hasattr(loop, "_conversation_state"):
state = loop._conversation_state
else:
state = load_conversation_state()
if state is None:
state = ConversationState()
try:
if action == "add_decision":
state.add_decision(
turn_number=int(kwargs.get("turn_number", 0)),
decision=str(kwargs.get("decision", "")),
rationale=str(kwargs.get("rationale", "")),
outcome=str(kwargs.get("outcome", "")),
)
elif action == "add_plan_step":
state.add_plan_step(str(kwargs.get("step", "")))
elif action == "remove_plan_step":
state.remove_plan_step(str(kwargs.get("step", "")))
elif action == "add_open_question":
state.add_open_question(str(kwargs.get("question", "")))
elif action == "resolve_open_question":
state.resolve_open_question(str(kwargs.get("question", "")))
elif action == "update_task_summary":
state.update_task_summary(str(kwargs.get("summary", "")))
elif action == "update_goal":
state.update_conversation_goal(str(kwargs.get("goal", "")))
elif action == "add_todo":
state.add_pending_todo(str(kwargs.get("todo", "")))
elif action == "remove_todo":
state.remove_pending_todo(str(kwargs.get("todo", "")))
elif action == "update_branch":
state.current_branch = str(kwargs.get("branch", ""))
elif action == "update_build_status":
state.last_build_status = str(kwargs.get("status", ""))
elif action == "add_task":
state.add_task(
task_id=str(kwargs.get("task_id", "")),
title=str(kwargs.get("title", "")),
status=str(kwargs.get("status", "pending")),
description=str(kwargs.get("description", "")),
parent_id=kwargs.get("parent_id"),
turn_created=int(kwargs.get("turn_created", 0)),
)
elif action == "update_task_status":
result = state.update_task_status(
task_id=str(kwargs.get("task_id", "")),
status=str(kwargs.get("status", "")),
turn_completed=int(kwargs.get("turn_completed", 0)),
)
if not result:
return json.dumps({"ok": False, "error": "Task not found"}, ensure_ascii=False)
elif action == "add_file_change":
state.add_file_change(
path=str(kwargs.get("path", "")),
operation=str(kwargs.get("operation", "")),
description=str(kwargs.get("description", "")),
turn_number=int(kwargs.get("turn_number", 0)),
)
elif action == "reset":
state.reset()
else:
return json.dumps({"ok": False, "error": f"Unknown action: {action}"}, ensure_ascii=False)
if loop is not None and hasattr(loop, "_conversation_state"):
loop._conversation_state = state
persist_conversation_state(state)
return json.dumps({"ok": True, "action": action}, ensure_ascii=False)
except Exception as exc:
return json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)
def tool_conversation_state_search(query: str, limit: int = 5, semantic_mode: bool = False) -> str:
"""Search through historical turn summaries and chapter summaries."""
from butler.core.agent_loop.loop import AgentLoop
from butler.execution_context import get_current_orchestrator, get_current_session_key
if semantic_mode:
try:
from butler.memory.semantic_index import SemanticMemoryIndex, get_embedder
session_key = str(get_current_session_key() or "default")
index = SemanticMemoryIndex(
db_path=f"~/.butler/memory/semantic/{session_key}.db",
embedder=get_embedder(),
)
results = index.search(query, limit=limit)
index.close()
if results:
return json.dumps({"ok": True, "results": results, "retrieval": "semantic"}, ensure_ascii=False, indent=2)
except Exception:
pass
orch = get_current_orchestrator()
loop: AgentLoop | None = None
if orch is not None:
loop = getattr(orch, "_loop", None)
state: ConversationState | None = None
if loop is not None and hasattr(loop, "_conversation_state"):
state = loop._conversation_state
else:
state = load_conversation_state()
if state is None:
return json.dumps({"ok": False, "error": "No conversation state found"}, ensure_ascii=False)
results = []
if state.chapter_summaries:
for ch in reversed(state.chapter_summaries):
text = f"{ch.summary} {' '.join(ch.key_decisions)} {' '.join(ch.key_files)}".lower()
if query.lower() in text:
results.append({
"type": "chapter",
"chapter_number": ch.chapter_number,
"start_turn": ch.start_turn,
"end_turn": ch.end_turn,
"summary": ch.summary,
"key_decisions": ch.key_decisions,
"key_files": ch.key_files,
})
if len(results) >= limit:
break
if len(results) < limit and state.turn_summaries:
for ts in reversed(state.turn_summaries):
text = f"{ts.user_intent} {ts.assistant_action} {ts.result_summary}".lower()
if query.lower() in text:
results.append({
"type": "turn",
"turn_number": ts.turn_number,
"user_intent": ts.user_intent,
"assistant_action": ts.assistant_action,
"result_summary": ts.result_summary,
"files_touched": ts.files_touched,
})
if len(results) >= limit:
break
if not results and state.turn_summaries:
query_lower = query.lower()
for ts in reversed(state.turn_summaries):
text = f"{ts.user_intent} {ts.assistant_action} {ts.result_summary}".lower()
score = sum(1 for w in query_lower.split() if w in text)
if score > 0:
results.append({
"type": "turn",
"turn_number": ts.turn_number,
"user_intent": ts.user_intent,
"assistant_action": ts.assistant_action,
"result_summary": ts.result_summary,
"files_touched": ts.files_touched,
"score": score,
})
results.sort(key=lambda x: x.get("score", 0), reverse=True)
results = results[:limit]
return json.dumps({"ok": True, "results": results, "retrieval": "keyword"}, ensure_ascii=False, indent=2)
def register_conversation_state_tools(register_fn: Any) -> None:
register_fn(
name="conversation_state_read",
description=(
"读取当前对话状态,包含目标、任务、计划、决策、文件变更、任务树等信息。\n"
"mode 参数可选值:\n"
"- quick: 快速概览(默认)\n"
"- full: 完整状态\n"
"- tasks: 任务树详情\n"
"- files: 文件变更历史\n"
"- decisions: 关键决策\n"
"- chapters: 章节摘要"
),
schema={
"type": "object",
"properties": {
"mode": {
"type": "string",
"description": "读取模式: quick/full/tasks/files/decisions/chapters",
"default": "quick",
},
},
},
handler=tool_conversation_state_read,
toolset="conversation",
)
register_fn(
name="conversation_state_update",
description=(
"更新对话状态。action 参数指定操作类型:\n"
"- add_decision: 添加决策 (turn_number, decision, rationale, outcome)\n"
"- add_plan_step: 添加计划步骤 (step)\n"
"- remove_plan_step: 移除计划步骤 (step)\n"
"- add_open_question: 添加待决问题 (question)\n"
"- resolve_open_question: 解决待决问题 (question)\n"
"- update_task_summary: 更新任务摘要 (summary)\n"
"- update_goal: 更新对话目标 (goal)\n"
"- add_todo: 添加待办 (todo)\n"
"- remove_todo: 移除待办 (todo)\n"
"- update_branch: 更新当前分支 (branch)\n"
"- update_build_status: 更新构建状态 (status)\n"
"- add_task: 添加任务到任务树 (task_id, title, status, description, parent_id, turn_created)\n"
"- update_task_status: 更新任务状态 (task_id, status, turn_completed)\n"
"- add_file_change: 添加文件变更记录 (path, operation, description, turn_number)\n"
"- reset: 重置所有状态"
),
schema={
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "操作类型",
"enum": [
"add_decision", "add_plan_step", "remove_plan_step",
"add_open_question", "resolve_open_question",
"update_task_summary", "update_goal",
"add_todo", "remove_todo",
"update_branch", "update_build_status",
"add_task", "update_task_status",
"add_file_change", "reset",
],
},
"turn_number": {"type": "integer", "description": "轮次号"},
"decision": {"type": "string", "description": "决策内容"},
"rationale": {"type": "string", "description": "决策理由"},
"outcome": {"type": "string", "description": "决策结果"},
"step": {"type": "string", "description": "计划步骤"},
"question": {"type": "string", "description": "待决问题"},
"summary": {"type": "string", "description": "任务摘要"},
"goal": {"type": "string", "description": "对话目标"},
"todo": {"type": "string", "description": "待办事项"},
"branch": {"type": "string", "description": "当前分支"},
"status": {"type": "string", "description": "状态"},
"task_id": {"type": "string", "description": "任务ID"},
"title": {"type": "string", "description": "任务标题"},
"description": {"type": "string", "description": "任务描述"},
"parent_id": {"type": "string", "description": "父任务ID"},
"turn_created": {"type": "integer", "description": "创建轮次"},
"turn_completed": {"type": "integer", "description": "完成轮次"},
"path": {"type": "string", "description": "文件路径"},
"operation": {"type": "string", "description": "操作类型"},
},
"required": ["action"],
},
handler=tool_conversation_state_update,
toolset="conversation",
)
register_fn(
name="conversation_state_search",
description=(
"搜索历史对话轮次摘要,支持关键词匹配。\n"
"query: 搜索关键词\n"
"limit: 返回结果数量(默认5)"
),
schema={
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"},
"limit": {"type": "integer", "description": "返回数量", "default": 5},
},
"required": ["query"],
},
handler=tool_conversation_state_search,
toolset="conversation",
)
__all__ = [
"persist_conversation_state",
"load_conversation_state",
"tool_conversation_state_read",
"tool_conversation_state_update",
"tool_conversation_state_search",
"register_conversation_state_tools",
]