add session处理,new ,histry,resume

This commit is contained in:
hc 2026-04-11 15:40:19 +08:00
parent c9b545538e
commit 192e6a56da
6 changed files with 222 additions and 4 deletions

View File

@ -25,6 +25,10 @@
行动时必须以运行时注入的环境信息为准特别是平台、shell、工作目录和可用工具列表。
REPL 内的会话管理命令优先使用 slash command。
`/history`、`/resume`、`/new` 由程序直接处理,不进入 agent loop。
未明确说明时,使用以下默认值:
- 工作目录:当前进程目录

View File

@ -23,6 +23,7 @@
- 搜索文件内容时,优先使用 `Grep`,而不是 `Bash`
- 修改已有文件时,优先使用 `Edit`,而不是 `Bash``Write`
- 需要创建或覆盖文件时,优先使用 `Write` 工具,而不是 `Bash`
- 会话管理优先使用 slash command而不是自然语言或 `Bash` 探查 session 文件
- Windows 下先验证 shell 兼容性,再选择命令写法
## 沟通风格

View File

@ -13,6 +13,7 @@ from typing import Any, Iterator
from anthropic import Anthropic
from openai import OpenAI
from cc_slim.session import SessionStore
from cc_slim.tools import Tool
@ -57,10 +58,20 @@ def resolve_config(workspace: Path, cli: dict[str, Any]) -> Config:
class Agent:
def __init__(self, config: Config, tools: list[Tool], workspace: Path) -> None:
def __init__(
self,
config: Config,
tools: list[Tool],
workspace: Path,
session_store: SessionStore | None = None,
session_id: str | None = None,
history: list[dict[str, Any]] | None = None,
) -> None:
self.config = config
self.tools = {tool.name: tool for tool in tools}
self.history: list[dict[str, Any]] = []
self.history: list[dict[str, Any]] = list(history or [])
self.session_store = session_store
self.session_id = session_id
self.system_prompt = self._build_system_prompt(workspace)
self.client = self._build_client()
@ -74,7 +85,9 @@ class Agent:
return "".join(parts).strip() or "(empty response)"
def stream_reply(self, user_input: str) -> Iterator[dict[str, Any]]:
self.history.append({"role": "user", "content": user_input})
user_message = {"role": "user", "content": user_input}
self.history.append(user_message)
self._save_message(user_message)
for _ in range(self.config.max_turns):
try:
@ -87,6 +100,7 @@ class Agent:
return
self.history.append(result["assistant"])
self._save_message(result["assistant"])
if not result["tool_calls"]:
yield {"type": "done"}
@ -104,6 +118,7 @@ class Agent:
"content": tool_output,
}
)
self._save_message(self.history[-1])
yield {"type": "error", "message": "已达到最大工具循环轮数,停止执行。"}
@ -345,6 +360,10 @@ class Agent:
"input": payload,
}
def _save_message(self, message: dict[str, Any]) -> None:
if self.session_store and self.session_id:
self.session_store.append_message(self.session_id, message)
def _load_file_config(path: Path) -> dict[str, Any]:
if not path.exists():

View File

@ -5,8 +5,10 @@ from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from cc_slim.engine import Agent, resolve_config
from cc_slim.session import SessionStore
from cc_slim.tools import build_default_tools
app = typer.Typer(add_completion=False, no_args_is_help=False)
@ -36,6 +38,77 @@ def render_stream(agent: Agent, user_input: str) -> None:
return
def render_history(store: SessionStore) -> None:
sessions = store.list_sessions()
if not sessions:
console.print("当前工作目录没有历史 session。")
return
table = Table(show_header=True)
table.add_column("#")
table.add_column("session_id")
table.add_column("title")
table.add_column("updated_at")
table.add_column("count")
for index, item in enumerate(sessions, start=1):
table.add_row(
str(index),
str(item.get("session_id", "")),
str(item.get("title", "")),
str(item.get("updated_at", "")),
str(item.get("message_count", 0)),
)
console.print(table)
def build_agent(
root: Path,
config: object,
store: SessionStore,
session_meta: dict[str, object],
restored_history: list[dict[str, object]] | None = None,
) -> Agent:
return Agent(
config=config,
tools=build_default_tools(root),
workspace=root,
session_store=store,
session_id=str(session_meta["session_id"]),
history=restored_history or [],
)
def handle_repl_command(
user_input: str,
store: SessionStore,
config: object,
root: Path,
agent: Agent,
) -> Agent:
command = user_input.strip()
if command in {"--history", "/history"}:
render_history(store)
return agent
if command == "/new":
session_meta = store.create_session(config.model)
console.print(f"已创建新 session: {session_meta.get('session_id')}")
return build_agent(root, config, store, session_meta, [])
if command.startswith("--resume ") or command.startswith("/resume "):
target = command.split(maxsplit=1)[1].strip()
if not target:
console.print("[red]error:[/red] 缺少 resume 目标")
return agent
session_id = store.resolve_session_id(target)
session_meta = store.load_meta(session_id)
restored_history = store.load_messages(session_id)
console.print(f"已恢复 session: {session_meta.get('session_id')}")
return build_agent(root, config, store, session_meta, restored_history)
return agent
@app.command()
def run(
prompt: Optional[str] = typer.Argument(None, help="单次执行的用户输入"),
@ -45,8 +118,15 @@ def run(
base_url: Optional[str] = typer.Option(None, help="可选的 API Base URL"),
cwd: Path = typer.Option(Path("."), help="工作区根目录"),
max_turns: Optional[int] = typer.Option(None, help="最大工具循环轮数"),
history: bool = typer.Option(False, "--history", help="列出当前工作目录的历史 session"),
resume: Optional[str] = typer.Option(None, "--resume", help="按 session id 或序号恢复历史 session"),
) -> None:
root = cwd.resolve()
store = SessionStore(root)
if history:
render_history(store)
return
config = resolve_config(
root,
{
@ -57,7 +137,17 @@ def run(
"max_turns": max_turns,
},
)
agent = Agent(config=config, tools=build_default_tools(root), workspace=root)
session_meta: dict[str, object]
restored_history: list[dict[str, object]] = []
if resume:
session_id = store.resolve_session_id(resume)
session_meta = store.load_meta(session_id)
restored_history = store.load_messages(session_id)
console.print(f"已恢复 session: {session_meta.get('session_id')}")
else:
session_meta = store.create_session(config.model)
agent = build_agent(root, config, store, session_meta, restored_history)
if prompt:
render_stream(agent, prompt)
@ -76,6 +166,13 @@ def run(
if not user_input.strip():
continue
if user_input.strip().startswith("/") or user_input.strip().startswith("--history") or user_input.strip().startswith("--resume"):
try:
agent = handle_repl_command(user_input, store, config, root, agent)
except Exception as exc: # pragma: no cover
console.print(f"[red]error:[/red] {exc}")
continue
try:
render_stream(agent, user_input)
except Exception as exc: # pragma: no cover

95
src/cc_slim/session.py Normal file
View File

@ -0,0 +1,95 @@
from __future__ import annotations
import hashlib
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
class SessionStore:
def __init__(self, cwd: Path) -> None:
self.cwd = cwd.resolve()
self.root = Path.home() / ".config" / "cc-slim" / "sessions" / self._sanitize_cwd(self.cwd)
self.root.mkdir(parents=True, exist_ok=True)
def create_session(self, model: str) -> dict[str, Any]:
now = datetime.now().strftime("%Y%m%d-%H%M%S")
cwd_hash = hashlib.sha1(str(self.cwd).encode("utf-8")).hexdigest()[:8]
session_id = f"{now}-{cwd_hash}"
meta = {
"session_id": session_id,
"title": "",
"cwd": str(self.cwd),
"model": model,
"created_at": datetime.now().isoformat(timespec="seconds"),
"updated_at": datetime.now().isoformat(timespec="seconds"),
"message_count": 0,
}
self._write_meta(session_id, meta)
self._messages_path(session_id).write_text("", encoding="utf-8")
return meta
def append_message(self, session_id: str, message: dict[str, Any]) -> None:
with self._messages_path(session_id).open("a", encoding="utf-8") as fh:
fh.write(json.dumps(message, ensure_ascii=False) + "\n")
meta = self.load_meta(session_id)
meta["message_count"] = int(meta.get("message_count", 0)) + 1
meta["updated_at"] = datetime.now().isoformat(timespec="seconds")
if not meta.get("title") and message.get("role") == "user":
meta["title"] = self._make_title(str(message.get("content", "")))
self._write_meta(session_id, meta)
def list_sessions(self) -> list[dict[str, Any]]:
sessions: list[dict[str, Any]] = []
for path in self.root.glob("*.meta.json"):
sessions.append(json.loads(path.read_text(encoding="utf-8")))
sessions.sort(key=lambda item: item.get("updated_at", ""), reverse=True)
return sessions
def load_messages(self, session_id: str) -> list[dict[str, Any]]:
path = self._messages_path(session_id)
if not path.exists():
return []
messages: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8").splitlines():
if line.strip():
messages.append(json.loads(line))
return messages
def load_meta(self, session_id: str) -> dict[str, Any]:
return json.loads(self._meta_path(session_id).read_text(encoding="utf-8"))
def resolve_session_id(self, value: str) -> str:
sessions = self.list_sessions()
if value.isdigit():
index = int(value) - 1
if 0 <= index < len(sessions):
return str(sessions[index]["session_id"])
raise ValueError(f"session index 不存在: {value}")
for item in sessions:
session_id = str(item.get("session_id", ""))
if session_id == value or session_id.startswith(value):
return session_id
raise ValueError(f"session 不存在: {value}")
def _messages_path(self, session_id: str) -> Path:
return self.root / f"{session_id}.jsonl"
def _meta_path(self, session_id: str) -> Path:
return self.root / f"{session_id}.meta.json"
def _write_meta(self, session_id: str, meta: dict[str, Any]) -> None:
self._meta_path(session_id).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
def _sanitize_cwd(self, cwd: Path) -> str:
text = re.sub(r"[^A-Za-z0-9._-]+", "_", str(cwd))
digest = hashlib.sha1(str(cwd).encode("utf-8")).hexdigest()[:8]
return f"{text[:48]}-{digest}"
def _make_title(self, text: str) -> str:
compact = " ".join(text.strip().split()) or "新会话"
return compact[:48]

View File

@ -188,6 +188,8 @@ def bash_tool(workspace: Path, data: dict[str, Any]) -> str:
shell=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
payload = {