From 5da86b9c6e233d013ed581cee17dc29b4cd1bd69 Mon Sep 17 00:00:00 2001 From: baozaotumao Date: Tue, 2 Jun 2026 00:40:36 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20agent=20=E6=9E=B6=E6=9E=84=E5=9F=BA?= =?UTF-8?q?=E5=BA=A7=EF=BC=88copier=20=E9=A2=84=E7=94=9F=E6=88=90=E6=A8=AA?= =?UTF-8?q?=E5=88=87=E5=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 空层包: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 就位 --- template/agent/src/core/logging.py.jinja | 20 +++++++++++--- template/agent/src/dag/__init__.py | 0 template/agent/src/domain/__init__.py | 0 template/agent/src/domain/exceptions.py | 32 +++++++++++++++++++++++ template/agent/src/export/__init__.py | 0 template/agent/src/llm/__init__.py | 0 template/agent/src/main.py.jinja | 31 +++++++++++++++++++--- template/agent/src/middleware/__init__.py | 0 template/agent/src/providers/__init__.py | 0 template/agent/src/storage/__init__.py | 0 template/agent/src/utils/__init__.py | 0 template/agent/src/utils/ansi.py | 8 ++++++ template/agent/src/utils/concurrency.py | 10 +++++++ template/agent/src/utils/files.py | 26 ++++++++++++++++++ template/agent/src/utils/ids.py | 6 +++++ 15 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 template/agent/src/dag/__init__.py create mode 100644 template/agent/src/domain/__init__.py create mode 100644 template/agent/src/domain/exceptions.py create mode 100644 template/agent/src/export/__init__.py create mode 100644 template/agent/src/llm/__init__.py create mode 100644 template/agent/src/middleware/__init__.py create mode 100644 template/agent/src/providers/__init__.py create mode 100644 template/agent/src/storage/__init__.py create mode 100644 template/agent/src/utils/__init__.py create mode 100644 template/agent/src/utils/ansi.py create mode 100644 template/agent/src/utils/concurrency.py create mode 100644 template/agent/src/utils/files.py create mode 100644 template/agent/src/utils/ids.py 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())