feat: agent 架构基座(copier 预生成横切层)

- 空层包:domain / dag / llm / providers / export / storage(待功能填)
- domain/exceptions.py:AgentException + 中性化错误类(LLM_/EXTERNAL_SERVICE_)
- utils:ids / files(work_dir/会话目录/防穿越) / concurrency(会话信号量) / ansi(PTY 清洗)
- core/logging:文件 sink 到 {WORK_DIR}/logs/agent.log
- main.py:/health /ready + WS 接入点骨架 + 接线
- 验证:gemini-cli/openai-api 两 provider,ruff+mypy 全过,import + /ws 就位
This commit is contained in:
baozaotumao
2026-06-02 00:40:36 -07:00
parent 434a4683ea
commit 5da86b9c6e
15 changed files with 127 additions and 6 deletions
+17 -3
View File
@@ -1,12 +1,26 @@
import sys
from pathlib import Path
from loguru import logger
def configure_logging(level: str = "INFO") -> None:
"""配置 loguru:结构化日志、单一 stderr sink。
def configure_logging(
level: str = "INFO", work_dir: str = "~/{{ project_slug }}"
) -> None:
"""配置 logurustderr(人类可读)+ {WORK_DIR}/logs/agent.logJSON 行)。
生产可改 serialize=True 输出 JSON、或加文件 sink见 .claude/rules/observability.md
日志目录在运行时按需创建(不提交 git)。详见 .claude/rules/observability.md。
"""
logger.remove()
logger.add(sys.stderr, level=level.upper(), backtrace=False, diagnose=False)
log_dir = Path(work_dir).expanduser() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
logger.add(
log_dir / "agent.log",
level=level.upper(),
serialize=True,
rotation="100 MB",
retention="14 days",
compression="zip",
)
View File
+32
View File
@@ -0,0 +1,32 @@
class AgentException(Exception): # noqa: N818 # 规则约定名为 AgentException
"""Agent 领域异常基类。
`code` 对应错误码契约(见 .claude/rules/error-codes.md)。属领域层词汇,
由 domain 拥有;框架级 handler 注册在接口层。
"""
def __init__(self, code: str, message: str, status_code: int = 500) -> None:
self.code = code
self.message = message
self.status_code = status_code
super().__init__(message)
class LLMStartupError(AgentException):
def __init__(self) -> None:
super().__init__("LLM_STARTUP_ERROR", "LLM 启动/连接失败")
class ExternalServiceTimeoutError(AgentException):
def __init__(self, phase: str) -> None:
super().__init__("EXTERNAL_SERVICE_TIMEOUT", f"外部依赖在 {phase} 阶段超时")
class ExternalServiceError(AgentException):
def __init__(self, detail: str) -> None:
super().__init__("EXTERNAL_SERVICE_ERROR", f"外部依赖返回错误: {detail}")
class OutputValidationError(AgentException):
def __init__(self) -> None:
super().__init__("INVALID_INPUT", "无法从输出中提取有效结果")
View File
+28 -3
View File
@@ -1,11 +1,11 @@
from fastapi import FastAPI
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from loguru import logger
from .core.config import get_settings
from .core.logging import configure_logging
settings = get_settings()
configure_logging(settings.log_level)
configure_logging(settings.log_level, settings.work_dir)
app = FastAPI(
title="{{ project_name }} Agent",
@@ -17,16 +17,41 @@ app = FastAPI(
@app.get("/health")
async def health() -> dict[str, str]:
"""存活探针。"""
return {"status": "ok"}
@app.get("/ready")
async def ready() -> dict[str, str]:
"""就绪探针:可在此扩展外部依赖检查。"""
return {"status": "ok"}
@app.websocket("/ws/{session_id}")
async def ws_endpoint(ws: WebSocket, session_id: str) -> None:
"""会话接入点(骨架)。
功能开发时在此驱动 DAG runner:建会话 → 节点编排 → 流式推事件
(见 .claude/rules/agent-dag.md)。
"""
await ws.accept()
logger.info("ws connected | session_id={}", session_id)
try:
while True:
msg = await ws.receive_json()
# TODO: 交给 dag.runner 处理,流式回推事件
await ws.send_json({"type": "ack", "received": msg.get("type")})
except WebSocketDisconnect:
logger.info("ws disconnected | session_id={}", session_id)
if __name__ == "__main__":
import uvicorn
logger.info("Starting agent | port={}", settings.port)
uvicorn.run("src.main:app", host="0.0.0.0", port=settings.port, reload=True)
uvicorn.run(
"src.main:app",
host="0.0.0.0", # noqa: S104 容器内绑定 0.0.0.0 属预期
port=settings.port,
reload=True,
)
+8
View File
@@ -0,0 +1,8 @@
import re
_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def strip_ansi(text: str) -> str:
"""过滤 ANSI 转义码。PTY 驱动的 CLI 输出推送前必须清洗(见 agent-llm-pty.md)。"""
return _ANSI_ESCAPE.sub("", text)
+10
View File
@@ -0,0 +1,10 @@
import asyncio
def make_session_semaphore(max_concurrent: int) -> asyncio.Semaphore:
"""全局会话并发上限信号量。
每个会话持有 LLM 子进程/连接等昂贵资源,必须封顶(见 agent-concurrency.md)。
超出上限的会话应排队,并向前端反馈位置。
"""
return asyncio.Semaphore(max_concurrent)
+26
View File
@@ -0,0 +1,26 @@
from pathlib import Path
def expand(path: str) -> Path:
"""展开 ~ 与环境变量,返回绝对 Path。"""
return Path(path).expanduser().resolve()
def ensure_dir(path: Path) -> Path:
"""确保目录存在(含父级),返回该目录。"""
path.mkdir(parents=True, exist_ok=True)
return path
def session_dir(work_dir: str, session_id: str) -> Path:
"""某会话的独立工作目录 {work_dir}/{session_id}/,不存在则创建。"""
return ensure_dir(expand(work_dir) / session_id)
def safe_within(work_dir: str, candidate: str) -> Path:
"""校验 candidate 解析后仍在 work_dir 内(防路径穿越),否则抛 ValueError。"""
root = expand(work_dir)
target = (root / candidate).resolve()
if not target.is_relative_to(root):
raise ValueError(f"路径越界: {candidate}")
return target
+6
View File
@@ -0,0 +1,6 @@
import uuid
def new_uuid() -> str:
"""生成新的 UUID4 字符串(用于 session_id 等)。"""
return str(uuid.uuid4())