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
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: str = "INFO") -> None:
|
def configure_logging(
|
||||||
"""配置 loguru:结构化日志、单一 stderr sink。
|
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.remove()
|
||||||
logger.add(sys.stderr, level=level.upper(), backtrace=False, diagnose=False)
|
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 loguru import logger
|
||||||
|
|
||||||
from .core.config import get_settings
|
from .core.config import get_settings
|
||||||
from .core.logging import configure_logging
|
from .core.logging import configure_logging
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level, settings.work_dir)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="{{ project_name }} Agent",
|
title="{{ project_name }} Agent",
|
||||||
@@ -17,16 +17,41 @@ app = FastAPI(
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
|
"""存活探针。"""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/ready")
|
@app.get("/ready")
|
||||||
async def ready() -> dict[str, str]:
|
async def ready() -> dict[str, str]:
|
||||||
|
"""就绪探针:可在此扩展外部依赖检查。"""
|
||||||
return {"status": "ok"}
|
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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
logger.info("Starting agent | port={}", settings.port)
|
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