diff --git a/template/agent/src/core/logging.py.jinja b/template/agent/src/core/logging.py.jinja index d8f2d66..951aebf 100644 --- a/template/agent/src/core/logging.py.jinja +++ b/template/agent/src/core/logging.py.jinja @@ -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", + ) diff --git a/template/agent/src/dag/__init__.py b/template/agent/src/dag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/agent/src/domain/__init__.py b/template/agent/src/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/agent/src/domain/exceptions.py b/template/agent/src/domain/exceptions.py new file mode 100644 index 0000000..fe92915 --- /dev/null +++ b/template/agent/src/domain/exceptions.py @@ -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", "无法从输出中提取有效结果") diff --git a/template/agent/src/export/__init__.py b/template/agent/src/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/agent/src/llm/__init__.py b/template/agent/src/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/agent/src/main.py.jinja b/template/agent/src/main.py.jinja index 9f7a593..a8cf997 100644 --- a/template/agent/src/main.py.jinja +++ b/template/agent/src/main.py.jinja @@ -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, + ) diff --git a/template/agent/src/middleware/__init__.py b/template/agent/src/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/agent/src/providers/__init__.py b/template/agent/src/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/agent/src/storage/__init__.py b/template/agent/src/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/agent/src/utils/__init__.py b/template/agent/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/agent/src/utils/ansi.py b/template/agent/src/utils/ansi.py new file mode 100644 index 0000000..275f6b7 --- /dev/null +++ b/template/agent/src/utils/ansi.py @@ -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) diff --git a/template/agent/src/utils/concurrency.py b/template/agent/src/utils/concurrency.py new file mode 100644 index 0000000..5509dbf --- /dev/null +++ b/template/agent/src/utils/concurrency.py @@ -0,0 +1,10 @@ +import asyncio + + +def make_session_semaphore(max_concurrent: int) -> asyncio.Semaphore: + """全局会话并发上限信号量。 + + 每个会话持有 LLM 子进程/连接等昂贵资源,必须封顶(见 agent-concurrency.md)。 + 超出上限的会话应排队,并向前端反馈位置。 + """ + return asyncio.Semaphore(max_concurrent) diff --git a/template/agent/src/utils/files.py b/template/agent/src/utils/files.py new file mode 100644 index 0000000..6e02884 --- /dev/null +++ b/template/agent/src/utils/files.py @@ -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 diff --git a/template/agent/src/utils/ids.py b/template/agent/src/utils/ids.py new file mode 100644 index 0000000..16e59ea --- /dev/null +++ b/template/agent/src/utils/ids.py @@ -0,0 +1,6 @@ +import uuid + + +def new_uuid() -> str: + """生成新的 UUID4 字符串(用于 session_id 等)。""" + return str(uuid.uuid4())