add session处理,new ,histry,resume
This commit is contained in:
parent
c9b545538e
commit
192e6a56da
@ -25,6 +25,10 @@
|
|||||||
|
|
||||||
行动时必须以运行时注入的环境信息为准,特别是平台、shell、工作目录和可用工具列表。
|
行动时必须以运行时注入的环境信息为准,特别是平台、shell、工作目录和可用工具列表。
|
||||||
|
|
||||||
|
REPL 内的会话管理命令优先使用 slash command。
|
||||||
|
|
||||||
|
`/history`、`/resume`、`/new` 由程序直接处理,不进入 agent loop。
|
||||||
|
|
||||||
未明确说明时,使用以下默认值:
|
未明确说明时,使用以下默认值:
|
||||||
|
|
||||||
- 工作目录:当前进程目录
|
- 工作目录:当前进程目录
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
- 搜索文件内容时,优先使用 `Grep`,而不是 `Bash`
|
- 搜索文件内容时,优先使用 `Grep`,而不是 `Bash`
|
||||||
- 修改已有文件时,优先使用 `Edit`,而不是 `Bash` 或 `Write`
|
- 修改已有文件时,优先使用 `Edit`,而不是 `Bash` 或 `Write`
|
||||||
- 需要创建或覆盖文件时,优先使用 `Write` 工具,而不是 `Bash`
|
- 需要创建或覆盖文件时,优先使用 `Write` 工具,而不是 `Bash`
|
||||||
|
- 会话管理优先使用 slash command,而不是自然语言或 `Bash` 探查 session 文件
|
||||||
- Windows 下先验证 shell 兼容性,再选择命令写法
|
- Windows 下先验证 shell 兼容性,再选择命令写法
|
||||||
|
|
||||||
## 沟通风格
|
## 沟通风格
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from typing import Any, Iterator
|
|||||||
from anthropic import Anthropic
|
from anthropic import Anthropic
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from cc_slim.session import SessionStore
|
||||||
from cc_slim.tools import Tool
|
from cc_slim.tools import Tool
|
||||||
|
|
||||||
|
|
||||||
@ -57,10 +58,20 @@ def resolve_config(workspace: Path, cli: dict[str, Any]) -> Config:
|
|||||||
|
|
||||||
|
|
||||||
class Agent:
|
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.config = config
|
||||||
self.tools = {tool.name: tool for tool in tools}
|
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.system_prompt = self._build_system_prompt(workspace)
|
||||||
self.client = self._build_client()
|
self.client = self._build_client()
|
||||||
|
|
||||||
@ -74,7 +85,9 @@ class Agent:
|
|||||||
return "".join(parts).strip() or "(empty response)"
|
return "".join(parts).strip() or "(empty response)"
|
||||||
|
|
||||||
def stream_reply(self, user_input: str) -> Iterator[dict[str, Any]]:
|
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):
|
for _ in range(self.config.max_turns):
|
||||||
try:
|
try:
|
||||||
@ -87,6 +100,7 @@ class Agent:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.history.append(result["assistant"])
|
self.history.append(result["assistant"])
|
||||||
|
self._save_message(result["assistant"])
|
||||||
|
|
||||||
if not result["tool_calls"]:
|
if not result["tool_calls"]:
|
||||||
yield {"type": "done"}
|
yield {"type": "done"}
|
||||||
@ -104,6 +118,7 @@ class Agent:
|
|||||||
"content": tool_output,
|
"content": tool_output,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
self._save_message(self.history[-1])
|
||||||
|
|
||||||
yield {"type": "error", "message": "已达到最大工具循环轮数,停止执行。"}
|
yield {"type": "error", "message": "已达到最大工具循环轮数,停止执行。"}
|
||||||
|
|
||||||
@ -345,6 +360,10 @@ class Agent:
|
|||||||
"input": payload,
|
"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]:
|
def _load_file_config(path: Path) -> dict[str, Any]:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
|
|||||||
@ -5,8 +5,10 @@ from typing import Optional
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
from cc_slim.engine import Agent, resolve_config
|
from cc_slim.engine import Agent, resolve_config
|
||||||
|
from cc_slim.session import SessionStore
|
||||||
from cc_slim.tools import build_default_tools
|
from cc_slim.tools import build_default_tools
|
||||||
|
|
||||||
app = typer.Typer(add_completion=False, no_args_is_help=False)
|
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
|
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()
|
@app.command()
|
||||||
def run(
|
def run(
|
||||||
prompt: Optional[str] = typer.Argument(None, help="单次执行的用户输入"),
|
prompt: Optional[str] = typer.Argument(None, help="单次执行的用户输入"),
|
||||||
@ -45,8 +118,15 @@ def run(
|
|||||||
base_url: Optional[str] = typer.Option(None, help="可选的 API Base URL"),
|
base_url: Optional[str] = typer.Option(None, help="可选的 API Base URL"),
|
||||||
cwd: Path = typer.Option(Path("."), help="工作区根目录"),
|
cwd: Path = typer.Option(Path("."), help="工作区根目录"),
|
||||||
max_turns: Optional[int] = typer.Option(None, 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:
|
) -> None:
|
||||||
root = cwd.resolve()
|
root = cwd.resolve()
|
||||||
|
store = SessionStore(root)
|
||||||
|
if history:
|
||||||
|
render_history(store)
|
||||||
|
return
|
||||||
|
|
||||||
config = resolve_config(
|
config = resolve_config(
|
||||||
root,
|
root,
|
||||||
{
|
{
|
||||||
@ -57,7 +137,17 @@ def run(
|
|||||||
"max_turns": max_turns,
|
"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:
|
if prompt:
|
||||||
render_stream(agent, prompt)
|
render_stream(agent, prompt)
|
||||||
@ -76,6 +166,13 @@ def run(
|
|||||||
if not user_input.strip():
|
if not user_input.strip():
|
||||||
continue
|
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:
|
try:
|
||||||
render_stream(agent, user_input)
|
render_stream(agent, user_input)
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
|
|||||||
95
src/cc_slim/session.py
Normal file
95
src/cc_slim/session.py
Normal 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]
|
||||||
@ -188,6 +188,8 @@ def bash_tool(workspace: Path, data: dict[str, Any]) -> str:
|
|||||||
shell=True,
|
shell=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user