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:
@@ -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:
|
||||
"""配置 loguru:stderr(人类可读)+ {WORK_DIR}/logs/agent.log(JSON 行)。
|
||||
|
||||
生产可改 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",
|
||||
)
|
||||
|
||||
@@ -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", "无法从输出中提取有效结果")
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,10 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
def make_session_semaphore(max_concurrent: int) -> asyncio.Semaphore:
|
||||
"""全局会话并发上限信号量。
|
||||
|
||||
每个会话持有 LLM 子进程/连接等昂贵资源,必须封顶(见 agent-concurrency.md)。
|
||||
超出上限的会话应排队,并向前端反馈位置。
|
||||
"""
|
||||
return asyncio.Semaphore(max_concurrent)
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
import uuid
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
"""生成新的 UUID4 字符串(用于 session_id 等)。"""
|
||||
return str(uuid.uuid4())
|
||||
Reference in New Issue
Block a user