create-windy 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/cli.js +392 -160
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/README.md +1 -0
- package/template/apps/agent-server/Dockerfile +20 -0
- package/template/apps/agent-server/README.md +46 -0
- package/template/apps/agent-server/pyproject.toml +44 -0
- package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
- package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
- package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
- package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
- package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
- package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
- package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
- package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
- package/template/apps/agent-server/tests/test_api.py +207 -0
- package/template/apps/agent-server/tests/test_config.py +56 -0
- package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
- package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
- package/template/apps/agent-server/uv.lock +925 -0
- package/template/apps/server/src/agent/execution-grants.ts +89 -0
- package/template/apps/server/src/agent/remote-runtime.ts +128 -0
- package/template/apps/server/src/agent/run-contracts.ts +43 -0
- package/template/apps/server/src/agent/run-facade.test.ts +257 -0
- package/template/apps/server/src/agent/run-facade.ts +190 -0
- package/template/apps/server/src/agent/run-routes.ts +222 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
- package/template/apps/server/src/agent/tool-host.test.ts +242 -0
- package/template/apps/server/src/agent/tool-host.ts +153 -0
- package/template/apps/server/src/application-services.ts +2 -2
- package/template/apps/server/src/audit/search-summary.ts +15 -8
- package/template/apps/server/src/example-modules.ts +58 -70
- package/template/apps/server/src/foundation.ts +8 -4
- package/template/apps/server/src/guards.test.ts +13 -0
- package/template/apps/server/src/guards.ts +12 -2
- package/template/apps/server/src/index.ts +48 -95
- package/template/apps/server/src/installed-business-modules.ts +23 -4
- package/template/apps/server/src/module-bootstrap.ts +83 -0
- package/template/apps/server/src/module-composition.test.ts +54 -1
- package/template/apps/server/src/module-composition.ts +58 -0
- package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
- package/template/apps/server/src/module-host/initialize.test.ts +192 -0
- package/template/apps/server/src/module-host/initialize.ts +328 -0
- package/template/apps/server/src/module-host/request-context.test.ts +66 -0
- package/template/apps/server/src/module-host/request-context.ts +156 -0
- package/template/apps/server/src/module-host/role-presets.test.ts +94 -0
- package/template/apps/server/src/module-host/role-presets.ts +76 -0
- package/template/apps/server/src/module-host/route-installer.test.ts +317 -0
- package/template/apps/server/src/module-host/route-installer.ts +97 -0
- package/template/apps/server/src/module-host/route-registration.test.ts +160 -0
- package/template/apps/server/src/module-host/search-providers.ts +31 -0
- package/template/apps/server/src/module-host/storage-port.test.ts +165 -0
- package/template/apps/server/src/module-host/storage-port.ts +59 -0
- package/template/apps/server/src/module-host/task-definitions.ts +41 -0
- package/template/apps/server/src/module-host/test-fixtures.ts +26 -0
- package/template/apps/server/src/module-host/tool-handlers.ts +25 -0
- package/template/apps/server/src/module-host.test.ts +202 -58
- package/template/apps/server/src/module-host.ts +35 -113
- package/template/apps/server/src/module-runtime-validation.ts +40 -0
- package/template/apps/server/src/route-guards.test.ts +112 -1
- package/template/apps/server/src/runtime-feature.ts +66 -31
- package/template/apps/server/src/runtime.test.ts +1 -0
- package/template/apps/server/src/runtime.ts +3 -1
- package/template/apps/server/src/system/built-in-roles.ts +1 -1
- package/template/apps/server/src/work-order/foundation-modules.test.ts +30 -10
- package/template/apps/server/src/work-order/routes.test.ts +30 -18
- package/template/apps/server/src/work-order/routes.ts +104 -135
- package/template/apps/server/src/work-order/search-api.test.ts +14 -3
- package/template/apps/server/src/work-order/search-provider.test.ts +23 -15
- package/template/apps/server/src/work-order/search-provider.ts +17 -12
- package/template/apps/server/src/work-order/task.test.ts +16 -11
- package/template/apps/web/Dockerfile +4 -1
- package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +4 -1
- package/template/apps/web/src/layout/GlobalWatermark.vue +2 -0
- package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +4 -1
- package/template/docker-compose.yml +25 -0
- package/template/docs/architecture/ai-runtime.md +744 -0
- package/template/docs/architecture/object-storage.md +12 -0
- package/template/docs/platform/agent-runtime.md +128 -0
- package/template/docs/platform/agent-tools.md +8 -1
- package/template/package.json +1 -0
- package/template/packages/config/index.test.ts +100 -0
- package/template/packages/config/index.ts +1 -0
- package/template/packages/config/package.json +2 -1
- package/template/packages/config/src/ai-openai-compatible.ts +131 -0
- package/template/packages/config/src/platform.ts +28 -1
- package/template/packages/database/package.json +1 -1
- package/template/packages/database/src/runner.ts +7 -9
- package/template/packages/example-work-order/index.ts +1 -0
- package/template/packages/example-work-order/package.json +2 -0
- package/template/packages/example-work-order/src/server-module.ts +61 -0
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs/src/types.ts +7 -1
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/catalog-validation.test.ts +212 -0
- package/template/packages/modules/src/catalog-validation.ts +19 -2
- package/template/packages/modules/src/migration-bundle-validation.test.ts +191 -0
- package/template/packages/server-sdk/index.ts +53 -0
- package/template/packages/server-sdk/package.json +18 -3
- package/template/packages/server-sdk/src/actor.ts +15 -0
- package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
- package/template/packages/server-sdk/src/audit-port.ts +22 -0
- package/template/packages/server-sdk/src/jobs-port.ts +24 -0
- package/template/packages/server-sdk/src/module-host.ts +41 -1
- package/template/packages/server-sdk/src/namespace.test.ts +26 -0
- package/template/packages/server-sdk/src/namespace.ts +13 -0
- package/template/packages/server-sdk/src/request-context.ts +21 -0
- package/template/packages/server-sdk/src/role-preset.ts +18 -0
- package/template/packages/server-sdk/src/route-definition.ts +26 -0
- package/template/packages/server-sdk/src/search-provider-port.ts +48 -0
- package/template/packages/server-sdk/src/storage-port.ts +25 -0
- package/template/packages/server-sdk/src/task-registration.ts +18 -0
- package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
- package/template/packages/server-sdk/src/tool-registration.ts +29 -0
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/license-catalog.test.ts +73 -1
- package/template/packages/shared/src/license-catalog.ts +95 -0
- package/template/packages/{database/src/bundle-validation.ts → shared/src/migration-bundle-validation.ts} +2 -1
- package/template/apps/server/src/work-order/task.ts +0 -30
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
FROM python:3.12-slim AS runtime
|
|
4
|
+
WORKDIR /app/apps/agent-server
|
|
5
|
+
|
|
6
|
+
RUN pip install --no-cache-dir uv==0.9.27 \
|
|
7
|
+
&& useradd --create-home --uid 10001 windy
|
|
8
|
+
COPY apps/agent-server/pyproject.toml apps/agent-server/uv.lock ./
|
|
9
|
+
RUN uv sync --frozen --no-dev --no-install-project
|
|
10
|
+
COPY apps/agent-server/src ./src
|
|
11
|
+
RUN uv sync --frozen --no-dev
|
|
12
|
+
|
|
13
|
+
ENV PATH="/app/apps/agent-server/.venv/bin:${PATH}"
|
|
14
|
+
ENV PYTHONUNBUFFERED=1
|
|
15
|
+
EXPOSE 8080
|
|
16
|
+
RUN mkdir -p /app/data/agent && chown -R windy:windy /app/data
|
|
17
|
+
USER windy
|
|
18
|
+
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
|
|
19
|
+
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
|
|
20
|
+
CMD ["uvicorn", "southwind_agent_server.app:app", "--host", "0.0.0.0", "--port", "8080"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Southwind AI Agent Server
|
|
2
|
+
|
|
3
|
+
Python 3.12 Agent Runtime。HTTP 层只公开 Southwind AI canonical Agent
|
|
4
|
+
contracts。当前可用 Adapter 连接 OpenAI-compatible `/chat/completions`;Google ADK
|
|
5
|
+
仍只是未配置 Provider 时安全拒绝的内部 Adapter。
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv sync --frozen
|
|
9
|
+
uv run pytest
|
|
10
|
+
uv run ruff check .
|
|
11
|
+
uv run ruff format --check .
|
|
12
|
+
uv run mypy
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
HTTP Interface:
|
|
16
|
+
|
|
17
|
+
- `POST /api/agent/runs`
|
|
18
|
+
- `GET /api/agent/runs/:runId/events`
|
|
19
|
+
- `GET /api/agent/runs/:runId/result`
|
|
20
|
+
- `POST /api/agent/runs/:runId/cancel`
|
|
21
|
+
|
|
22
|
+
Run 创建使用 `Idempotency-Key`。事件以 SSE 传输,支持 `Last-Event-ID` 和 `after`
|
|
23
|
+
断线重放,并周期发送 comment heartbeat。Provider 或框架异常只会映射为 canonical
|
|
24
|
+
安全错误,不会把原始异常正文暴露给调用方。
|
|
25
|
+
|
|
26
|
+
配置以下仅服务端可见的环境变量后,默认运行 OpenAI-compatible Adapter:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export AGENT_SERVER_INTERNAL_TOKEN='<random-token>'
|
|
30
|
+
export QWEN_API_URL='https://dashscope-intl.aliyuncs.com/compatible-mode/v1'
|
|
31
|
+
export QWEN_API_KEY='<server-secret>'
|
|
32
|
+
export QWEN_MODEL='qwen-plus'
|
|
33
|
+
export AGENT_RUN_STORE_PATH='.data/agent-runs.sqlite3'
|
|
34
|
+
uv run uvicorn southwind_agent_server.app:app
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
公网 URL 必须使用 HTTPS,本机、容器服务名和内网地址允许 HTTP。Provider 请求包含连接
|
|
38
|
+
和总超时、响应大小限制、错误脱敏与 `/models` readiness。缺少 URL/Model 时回退
|
|
39
|
+
`AdkEngineAdapter` 并返回 unavailable,不调用模型。测试另使用
|
|
40
|
+
`DeterministicEngineAdapter`。
|
|
41
|
+
|
|
42
|
+
未设置 `AGENT_RUN_STORE_PATH` 时使用的 `InMemoryRunStore` **仅用于 deterministic
|
|
43
|
+
测试**。配置路径后使用有界 SQLite Store,可在单副本重启后保留 Run/Event/Result 和
|
|
44
|
+
cursor;多副本部署仍必须替换为共享 Store。Execution Grant 与 Tool 授权由
|
|
45
|
+
Southwind AI Server 签发和复核,Agent Server 不能自行扩大 Operation 的
|
|
46
|
+
`allowedToolKeys`。
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "southwind-agent-server"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Southwind AI canonical Agent Runtime server"
|
|
5
|
+
requires-python = ">=3.12,<3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"fastapi==0.139.2",
|
|
8
|
+
"google-adk==2.5.0",
|
|
9
|
+
"httpx==0.28.1",
|
|
10
|
+
"pydantic==2.13.4",
|
|
11
|
+
"uvicorn==0.51.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = [
|
|
16
|
+
"mypy==2.3.0",
|
|
17
|
+
"pytest==9.1.1",
|
|
18
|
+
"pytest-asyncio==1.4.0",
|
|
19
|
+
"ruff==0.15.22",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
asyncio_mode = "auto"
|
|
24
|
+
pythonpath = ["src"]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 88
|
|
29
|
+
target-version = "py312"
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
select = ["E", "F", "I", "UP", "B", "ASYNC"]
|
|
33
|
+
|
|
34
|
+
[tool.mypy]
|
|
35
|
+
python_version = "3.12"
|
|
36
|
+
strict = true
|
|
37
|
+
files = ["src", "tests"]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/southwind_agent_server"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Mapping
|
|
4
|
+
|
|
5
|
+
from .engine import (
|
|
6
|
+
AgentEngineAdapter,
|
|
7
|
+
EngineRunRequest,
|
|
8
|
+
EngineSignal,
|
|
9
|
+
SafeEngineError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AdkEngineAdapter(AgentEngineAdapter):
|
|
14
|
+
"""Google ADK implementation skeleton.
|
|
15
|
+
|
|
16
|
+
Agent objects remain opaque and internal. A later slice will create ADK Runners,
|
|
17
|
+
consume ADK Events, and translate them into EngineSignal values at this seam.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, agents: Mapping[str, object] | None = None) -> None:
|
|
21
|
+
self._agents = dict(agents or {})
|
|
22
|
+
|
|
23
|
+
def supports(self, operation_key: str) -> bool:
|
|
24
|
+
return operation_key in self._agents
|
|
25
|
+
|
|
26
|
+
async def execute(
|
|
27
|
+
self,
|
|
28
|
+
request: EngineRunRequest,
|
|
29
|
+
) -> AsyncIterator[EngineSignal]:
|
|
30
|
+
del request
|
|
31
|
+
raise SafeEngineError(
|
|
32
|
+
"RUNTIME_UNAVAILABLE",
|
|
33
|
+
"Agent Runtime 尚未配置",
|
|
34
|
+
retryable=True,
|
|
35
|
+
)
|
|
36
|
+
yield # pragma: no cover - keeps this method an async generator
|
|
37
|
+
|
|
38
|
+
async def cancel(self, run_id: str) -> None:
|
|
39
|
+
del run_id
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Header, Query, Request
|
|
9
|
+
from fastapi.exceptions import RequestValidationError
|
|
10
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from .adk_adapter import AdkEngineAdapter
|
|
14
|
+
from .config import EnvironmentSecretResolver, OpenAiCompatibleConfig
|
|
15
|
+
from .contracts import (
|
|
16
|
+
AgentError,
|
|
17
|
+
CancelRunRequest,
|
|
18
|
+
CancelRunResponse,
|
|
19
|
+
RunResultResponse,
|
|
20
|
+
StartRunRequest,
|
|
21
|
+
StartRunResponse,
|
|
22
|
+
)
|
|
23
|
+
from .engine import AgentEngineAdapter
|
|
24
|
+
from .openai_engine import OpenAiCompatibleEngineAdapter
|
|
25
|
+
from .openai_provider import OpenAiCompatibleProvider
|
|
26
|
+
from .service import (
|
|
27
|
+
AgentRunService,
|
|
28
|
+
IdempotencyConflictError,
|
|
29
|
+
OperationNotFoundError,
|
|
30
|
+
RunNotFoundError,
|
|
31
|
+
)
|
|
32
|
+
from .sqlite_store import RunStoreCapacityError, SqliteRunStore
|
|
33
|
+
from .sse import event_stream
|
|
34
|
+
from .store import InMemoryRunStore, RunStore
|
|
35
|
+
from .tool_host import ToolHostClient
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_app(
|
|
39
|
+
engine: AgentEngineAdapter | None = None,
|
|
40
|
+
*,
|
|
41
|
+
heartbeat_seconds: float = 15.0,
|
|
42
|
+
internal_token: str | None = None,
|
|
43
|
+
store: RunStore | None = None,
|
|
44
|
+
) -> FastAPI:
|
|
45
|
+
runtime_engine = engine or default_engine()
|
|
46
|
+
runtime = AgentRunService(runtime_engine, store or default_store())
|
|
47
|
+
application = FastAPI(title="Southwind AI Agent Server", version="0.1.0")
|
|
48
|
+
|
|
49
|
+
@application.get("/health/live")
|
|
50
|
+
async def live() -> dict[str, str]:
|
|
51
|
+
return {"status": "live"}
|
|
52
|
+
|
|
53
|
+
@application.get("/health/ready", response_model=None)
|
|
54
|
+
async def ready() -> dict[str, str] | JSONResponse:
|
|
55
|
+
health = getattr(runtime_engine, "health", None)
|
|
56
|
+
if not health:
|
|
57
|
+
return safe_error(
|
|
58
|
+
503,
|
|
59
|
+
"RUNTIME_UNAVAILABLE",
|
|
60
|
+
"Agent Runtime 尚未配置",
|
|
61
|
+
True,
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
await health()
|
|
65
|
+
return {"status": "ready"}
|
|
66
|
+
except Exception:
|
|
67
|
+
return safe_error(
|
|
68
|
+
503,
|
|
69
|
+
"PROVIDER_UNAVAILABLE",
|
|
70
|
+
"模型服务暂不可用",
|
|
71
|
+
True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@application.exception_handler(OperationNotFoundError)
|
|
75
|
+
async def operation_not_found(
|
|
76
|
+
_request: Request,
|
|
77
|
+
_error: OperationNotFoundError,
|
|
78
|
+
) -> JSONResponse:
|
|
79
|
+
return safe_error(404, "OPERATION_NOT_FOUND", "Agent Operation 不存在", False)
|
|
80
|
+
|
|
81
|
+
@application.exception_handler(RunNotFoundError)
|
|
82
|
+
async def run_not_found(
|
|
83
|
+
_request: Request,
|
|
84
|
+
_error: RunNotFoundError,
|
|
85
|
+
) -> JSONResponse:
|
|
86
|
+
return safe_error(404, "RUN_CONFLICT", "Agent Run 不存在", False)
|
|
87
|
+
|
|
88
|
+
@application.exception_handler(IdempotencyConflictError)
|
|
89
|
+
async def idempotency_conflict(
|
|
90
|
+
_request: Request,
|
|
91
|
+
_error: IdempotencyConflictError,
|
|
92
|
+
) -> JSONResponse:
|
|
93
|
+
return safe_error(409, "RUN_CONFLICT", "幂等键已用于其他请求", False)
|
|
94
|
+
|
|
95
|
+
@application.exception_handler(RunStoreCapacityError)
|
|
96
|
+
async def run_store_capacity(
|
|
97
|
+
_request: Request,
|
|
98
|
+
_error: RunStoreCapacityError,
|
|
99
|
+
) -> JSONResponse:
|
|
100
|
+
return safe_error(
|
|
101
|
+
429,
|
|
102
|
+
"BUDGET_EXCEEDED",
|
|
103
|
+
"Agent Run 存储容量已满",
|
|
104
|
+
True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@application.exception_handler(ValidationError)
|
|
108
|
+
async def validation_error(
|
|
109
|
+
_request: Request,
|
|
110
|
+
_error: ValidationError,
|
|
111
|
+
) -> JSONResponse:
|
|
112
|
+
return safe_error(400, "INVALID_INPUT", "请求格式无效", False)
|
|
113
|
+
|
|
114
|
+
@application.exception_handler(RequestValidationError)
|
|
115
|
+
async def request_validation_error(
|
|
116
|
+
_request: Request,
|
|
117
|
+
_error: RequestValidationError,
|
|
118
|
+
) -> JSONResponse:
|
|
119
|
+
return safe_error(400, "INVALID_INPUT", "请求格式无效", False)
|
|
120
|
+
|
|
121
|
+
@application.post("/api/agent/runs", response_model=StartRunResponse)
|
|
122
|
+
async def start_run(
|
|
123
|
+
request: StartRunRequest,
|
|
124
|
+
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
|
|
125
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
126
|
+
) -> StartRunResponse | JSONResponse:
|
|
127
|
+
if not authorized(authorization, internal_token):
|
|
128
|
+
return safe_error(403, "FORBIDDEN", "内部调用认证失败", False)
|
|
129
|
+
if not idempotency_key.strip():
|
|
130
|
+
return safe_error(400, "INVALID_INPUT", "幂等键不能为空", False)
|
|
131
|
+
record = await runtime.start(
|
|
132
|
+
request,
|
|
133
|
+
idempotency_key,
|
|
134
|
+
)
|
|
135
|
+
return StartRunResponse(
|
|
136
|
+
run_id=record.run_id,
|
|
137
|
+
status=record.status,
|
|
138
|
+
events_url=f"/api/agent/runs/{record.run_id}/events",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@application.get("/api/agent/runs/{run_id}/events", response_model=None)
|
|
142
|
+
async def get_events(
|
|
143
|
+
run_id: str,
|
|
144
|
+
after: Annotated[int | None, Query(ge=0)] = None,
|
|
145
|
+
last_event_id: Annotated[str | None, Header(alias="Last-Event-ID")] = None,
|
|
146
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
147
|
+
) -> StreamingResponse | JSONResponse:
|
|
148
|
+
if not authorized(authorization, internal_token):
|
|
149
|
+
return safe_error(403, "FORBIDDEN", "内部调用认证失败", False)
|
|
150
|
+
await runtime.get(run_id)
|
|
151
|
+
try:
|
|
152
|
+
cursor = parse_cursor(after, last_event_id)
|
|
153
|
+
except ValueError:
|
|
154
|
+
return safe_error(400, "INVALID_INPUT", "事件游标无效", False)
|
|
155
|
+
return StreamingResponse(
|
|
156
|
+
event_stream(runtime, run_id, cursor, heartbeat_seconds),
|
|
157
|
+
media_type="text/event-stream",
|
|
158
|
+
headers={
|
|
159
|
+
"Cache-Control": "no-cache, no-transform",
|
|
160
|
+
"X-Accel-Buffering": "no",
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@application.get(
|
|
165
|
+
"/api/agent/runs/{run_id}/result",
|
|
166
|
+
response_model=RunResultResponse,
|
|
167
|
+
)
|
|
168
|
+
async def get_result(
|
|
169
|
+
run_id: str,
|
|
170
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
171
|
+
) -> RunResultResponse | JSONResponse:
|
|
172
|
+
if not authorized(authorization, internal_token):
|
|
173
|
+
return safe_error(403, "FORBIDDEN", "内部调用认证失败", False)
|
|
174
|
+
record = await runtime.get(run_id)
|
|
175
|
+
if record.status == "completed":
|
|
176
|
+
return RunResultResponse(
|
|
177
|
+
run_id=record.run_id,
|
|
178
|
+
status="completed",
|
|
179
|
+
output=record.output,
|
|
180
|
+
)
|
|
181
|
+
if record.status == "cancelled":
|
|
182
|
+
return safe_error(409, "RUN_CANCELLED", "Agent Run 已取消", False)
|
|
183
|
+
if record.status == "failed":
|
|
184
|
+
return safe_error(409, "RUNTIME_UNAVAILABLE", "Agent Run 执行失败", True)
|
|
185
|
+
return safe_error(409, "RUN_CONFLICT", "Agent Run 尚未完成", True)
|
|
186
|
+
|
|
187
|
+
@application.post(
|
|
188
|
+
"/api/agent/runs/{run_id}/cancel",
|
|
189
|
+
response_model=CancelRunResponse,
|
|
190
|
+
)
|
|
191
|
+
async def cancel_run(
|
|
192
|
+
run_id: str,
|
|
193
|
+
_request: CancelRunRequest,
|
|
194
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
195
|
+
) -> CancelRunResponse | JSONResponse:
|
|
196
|
+
if not authorized(authorization, internal_token):
|
|
197
|
+
return safe_error(403, "FORBIDDEN", "内部调用认证失败", False)
|
|
198
|
+
record = await runtime.cancel(run_id)
|
|
199
|
+
return CancelRunResponse(run_id=record.run_id, status=record.status)
|
|
200
|
+
|
|
201
|
+
application.state.run_service = runtime
|
|
202
|
+
return application
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def default_engine() -> AgentEngineAdapter:
|
|
206
|
+
config = OpenAiCompatibleConfig.from_environment()
|
|
207
|
+
if config is None:
|
|
208
|
+
return AdkEngineAdapter()
|
|
209
|
+
internal_token = os.environ.get("AGENT_SERVER_INTERNAL_TOKEN", "").strip()
|
|
210
|
+
if not internal_token:
|
|
211
|
+
raise ValueError("Agent Server Provider 已配置但内部 Token 缺失")
|
|
212
|
+
return OpenAiCompatibleEngineAdapter(
|
|
213
|
+
OpenAiCompatibleProvider(
|
|
214
|
+
config,
|
|
215
|
+
EnvironmentSecretResolver(os.environ),
|
|
216
|
+
),
|
|
217
|
+
ToolHostClient(internal_token),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def default_store() -> RunStore:
|
|
222
|
+
path = os.environ.get("AGENT_RUN_STORE_PATH", "").strip()
|
|
223
|
+
return SqliteRunStore(path) if path else InMemoryRunStore()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def authorized(provided: str | None, expected: str | None) -> bool:
|
|
227
|
+
if expected is None:
|
|
228
|
+
return True
|
|
229
|
+
if not expected or not provided or not provided.startswith("Bearer "):
|
|
230
|
+
return False
|
|
231
|
+
return hmac.compare_digest(provided.removeprefix("Bearer "), expected)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def parse_cursor(after: int | None, last_event_id: str | None) -> int:
|
|
235
|
+
header_cursor = 0
|
|
236
|
+
if last_event_id is not None:
|
|
237
|
+
if not last_event_id.isdigit():
|
|
238
|
+
raise ValueError
|
|
239
|
+
header_cursor = int(last_event_id)
|
|
240
|
+
return max(after or 0, header_cursor)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def safe_error(
|
|
244
|
+
status_code: int,
|
|
245
|
+
code: str,
|
|
246
|
+
message: str,
|
|
247
|
+
retryable: bool,
|
|
248
|
+
) -> JSONResponse:
|
|
249
|
+
error = AgentError.model_validate(
|
|
250
|
+
{
|
|
251
|
+
"code": code,
|
|
252
|
+
"message": message,
|
|
253
|
+
"requestId": f"request_{uuid.uuid4().hex}",
|
|
254
|
+
"retryable": retryable,
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
return JSONResponse(
|
|
258
|
+
status_code=status_code,
|
|
259
|
+
content=error.model_dump(by_alias=True),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
app = create_app(internal_token=os.environ.get("AGENT_SERVER_INTERNAL_TOKEN"))
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class SecretRef:
|
|
12
|
+
provider: str
|
|
13
|
+
locator: str
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
return "[SecretRef]"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EnvironmentSecretResolver:
|
|
20
|
+
def __init__(self, environment: Mapping[str, str]) -> None:
|
|
21
|
+
self._environment = environment
|
|
22
|
+
|
|
23
|
+
def resolve(self, ref: SecretRef) -> str | None:
|
|
24
|
+
if ref.provider != "env":
|
|
25
|
+
raise ValueError("Secret Provider 不匹配")
|
|
26
|
+
return self._environment.get(ref.locator)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class OpenAiCompatibleConfig:
|
|
31
|
+
base_url: str
|
|
32
|
+
model: str
|
|
33
|
+
api_key_ref: SecretRef | None
|
|
34
|
+
connect_timeout_seconds: float = 5.0
|
|
35
|
+
request_timeout_seconds: float = 60.0
|
|
36
|
+
max_response_bytes: int = 2 * 1024 * 1024
|
|
37
|
+
max_tool_rounds: int = 8
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_environment(
|
|
41
|
+
cls,
|
|
42
|
+
environment: Mapping[str, str] = os.environ,
|
|
43
|
+
) -> OpenAiCompatibleConfig | None:
|
|
44
|
+
base_url = environment.get("QWEN_API_URL", "").strip()
|
|
45
|
+
model = environment.get("QWEN_MODEL", "").strip()
|
|
46
|
+
if not base_url and not model:
|
|
47
|
+
return None
|
|
48
|
+
if not base_url or not model:
|
|
49
|
+
raise ValueError("OpenAI-compatible Endpoint 与 Model 必须同时配置")
|
|
50
|
+
return cls(
|
|
51
|
+
base_url=normalize_base_url(base_url),
|
|
52
|
+
model=model,
|
|
53
|
+
api_key_ref=(
|
|
54
|
+
SecretRef("env", "QWEN_API_KEY")
|
|
55
|
+
if environment.get("QWEN_API_KEY", "").strip()
|
|
56
|
+
else None
|
|
57
|
+
),
|
|
58
|
+
connect_timeout_seconds=read_milliseconds(
|
|
59
|
+
environment,
|
|
60
|
+
"AI_PROVIDER_CONNECT_TIMEOUT_MS",
|
|
61
|
+
5_000,
|
|
62
|
+
100,
|
|
63
|
+
30_000,
|
|
64
|
+
)
|
|
65
|
+
/ 1_000,
|
|
66
|
+
request_timeout_seconds=read_milliseconds(
|
|
67
|
+
environment,
|
|
68
|
+
"AI_PROVIDER_REQUEST_TIMEOUT_MS",
|
|
69
|
+
60_000,
|
|
70
|
+
1_000,
|
|
71
|
+
300_000,
|
|
72
|
+
)
|
|
73
|
+
/ 1_000,
|
|
74
|
+
max_response_bytes=read_integer(
|
|
75
|
+
environment,
|
|
76
|
+
"AI_PROVIDER_MAX_RESPONSE_BYTES",
|
|
77
|
+
2 * 1024 * 1024,
|
|
78
|
+
1_024,
|
|
79
|
+
16 * 1024 * 1024,
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize_base_url(raw: str) -> str:
|
|
85
|
+
parsed = urlsplit(raw.strip())
|
|
86
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
87
|
+
raise ValueError("OpenAI-compatible Endpoint 无效")
|
|
88
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
89
|
+
raise ValueError("OpenAI-compatible Endpoint 不允许凭据、查询串或片段")
|
|
90
|
+
hostname = parsed.hostname.lower()
|
|
91
|
+
if hostname in {
|
|
92
|
+
"169.254.169.254",
|
|
93
|
+
"metadata.google.internal",
|
|
94
|
+
"metadata.azure.internal",
|
|
95
|
+
}:
|
|
96
|
+
raise ValueError("OpenAI-compatible Endpoint 禁止访问云 metadata")
|
|
97
|
+
if parsed.scheme == "http" and not private_hostname(hostname):
|
|
98
|
+
raise ValueError("公网 OpenAI-compatible Endpoint 必须使用 HTTPS")
|
|
99
|
+
path = parsed.path.rstrip("/")
|
|
100
|
+
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def private_hostname(hostname: str) -> bool:
|
|
104
|
+
if (
|
|
105
|
+
hostname == "localhost"
|
|
106
|
+
or hostname.endswith((".localhost", ".internal", ".local"))
|
|
107
|
+
or "." not in hostname
|
|
108
|
+
):
|
|
109
|
+
return True
|
|
110
|
+
try:
|
|
111
|
+
address = ipaddress.ip_address(hostname)
|
|
112
|
+
except ValueError:
|
|
113
|
+
return False
|
|
114
|
+
return address.is_private or address.is_loopback
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_milliseconds(
|
|
118
|
+
environment: Mapping[str, str],
|
|
119
|
+
key: str,
|
|
120
|
+
default: int,
|
|
121
|
+
minimum: int,
|
|
122
|
+
maximum: int,
|
|
123
|
+
) -> int:
|
|
124
|
+
return read_integer(environment, key, default, minimum, maximum)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def read_integer(
|
|
128
|
+
environment: Mapping[str, str],
|
|
129
|
+
key: str,
|
|
130
|
+
default: int,
|
|
131
|
+
minimum: int,
|
|
132
|
+
maximum: int,
|
|
133
|
+
) -> int:
|
|
134
|
+
raw = environment.get(key)
|
|
135
|
+
try:
|
|
136
|
+
value = default if raw is None else int(raw)
|
|
137
|
+
except ValueError as error:
|
|
138
|
+
raise ValueError(f"{key} 必须是整数") from error
|
|
139
|
+
if not minimum <= value <= maximum:
|
|
140
|
+
raise ValueError(f"{key} 超出允许范围")
|
|
141
|
+
return value
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
type JsonValue = (
|
|
8
|
+
str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue]
|
|
9
|
+
)
|
|
10
|
+
type RunStatus = Literal[
|
|
11
|
+
"accepted",
|
|
12
|
+
"running",
|
|
13
|
+
"awaiting-approval",
|
|
14
|
+
"completed",
|
|
15
|
+
"failed",
|
|
16
|
+
"cancelled",
|
|
17
|
+
]
|
|
18
|
+
type AgentErrorCode = Literal[
|
|
19
|
+
"OPERATION_NOT_FOUND",
|
|
20
|
+
"OPERATION_DISABLED",
|
|
21
|
+
"INVALID_INPUT",
|
|
22
|
+
"FORBIDDEN",
|
|
23
|
+
"LICENSE_NOT_ALLOWED",
|
|
24
|
+
"RUNTIME_UNAVAILABLE",
|
|
25
|
+
"MODEL_ROUTE_UNAVAILABLE",
|
|
26
|
+
"PROVIDER_UNAVAILABLE",
|
|
27
|
+
"RATE_LIMITED",
|
|
28
|
+
"BUDGET_EXCEEDED",
|
|
29
|
+
"TOOL_DENIED",
|
|
30
|
+
"APPROVAL_REQUIRED",
|
|
31
|
+
"OUTPUT_VALIDATION_FAILED",
|
|
32
|
+
"RUN_CONFLICT",
|
|
33
|
+
"RUN_TIMEOUT",
|
|
34
|
+
"RUN_CANCELLED",
|
|
35
|
+
"STREAM_INTERRUPTED",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def to_camel(value: str) -> str:
|
|
40
|
+
first, *rest = value.split("_")
|
|
41
|
+
return first + "".join(part.capitalize() for part in rest)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CanonicalModel(BaseModel):
|
|
45
|
+
model_config = ConfigDict(
|
|
46
|
+
alias_generator=to_camel,
|
|
47
|
+
populate_by_name=True,
|
|
48
|
+
extra="forbid",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RuntimeToolDefinition(CanonicalModel):
|
|
53
|
+
key: str = Field(min_length=1)
|
|
54
|
+
label: str = Field(min_length=1)
|
|
55
|
+
description: str = Field(min_length=1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RuntimeLimits(CanonicalModel):
|
|
59
|
+
max_duration_seconds: int = Field(default=60, ge=1, le=300)
|
|
60
|
+
max_output_bytes: int = Field(default=1_048_576, ge=1_024, le=16_777_216)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StartRunRequest(CanonicalModel):
|
|
64
|
+
operation_key: str = Field(min_length=1)
|
|
65
|
+
input: JsonValue
|
|
66
|
+
instructions: str = ""
|
|
67
|
+
user_prompt: str = ""
|
|
68
|
+
allowed_tools: list[RuntimeToolDefinition] = Field(default_factory=list)
|
|
69
|
+
limits: RuntimeLimits = Field(default_factory=RuntimeLimits)
|
|
70
|
+
execution_grant: str = ""
|
|
71
|
+
tool_host_url: str = ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class StartRunResponse(CanonicalModel):
|
|
75
|
+
run_id: str
|
|
76
|
+
status: RunStatus
|
|
77
|
+
events_url: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RunResultResponse(CanonicalModel):
|
|
81
|
+
run_id: str
|
|
82
|
+
status: Literal["completed"]
|
|
83
|
+
output: JsonValue
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CancelRunRequest(CanonicalModel):
|
|
87
|
+
reason: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CancelRunResponse(CanonicalModel):
|
|
91
|
+
run_id: str
|
|
92
|
+
status: RunStatus
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AgentError(CanonicalModel):
|
|
96
|
+
code: AgentErrorCode
|
|
97
|
+
message: str
|
|
98
|
+
request_id: str
|
|
99
|
+
retryable: bool
|