create-windy 0.2.20 → 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 +1 -1
- package/dist/cli.js +84 -10
- 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/index.ts +12 -16
- package/template/apps/server/src/module-composition.test.ts +9 -0
- package/template/apps/server/src/module-composition.ts +13 -0
- package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
- package/template/apps/server/src/module-host/initialize.test.ts +69 -0
- package/template/apps/server/src/module-host/initialize.ts +50 -53
- package/template/apps/server/src/module-host.ts +1 -0
- package/template/apps/server/src/module-runtime-validation.ts +40 -0
- package/template/docker-compose.yml +25 -0
- package/template/docs/architecture/ai-runtime.md +744 -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 +43 -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/server-sdk/index.ts +11 -0
- package/template/packages/server-sdk/package.json +4 -2
- package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
- package/template/packages/server-sdk/src/module-host.ts +5 -0
- package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ npx create-windy@latest my-dashboard \
|
|
|
68
68
|
|
|
69
69
|
选择 `system.license` 时,Server 与 Web 通过 npm 依赖
|
|
70
70
|
`@southwind-ai/license@^0.1.1` 使用运行时能力;生成项目及 `create-windy` 模板快照都不包含
|
|
71
|
-
`packages/license-sdk` 源码。公网 npm 已发布兼容的 `@southwind-ai/license@0.1.
|
|
71
|
+
`packages/license-sdk` 源码。公网 npm 已发布兼容的 `@southwind-ai/license@0.1.2`;
|
|
72
72
|
发布包含该模块的新版创建器前,必须先确认目标 registry 中存在兼容版本,并完成真实
|
|
73
73
|
安装回归。
|
|
74
74
|
|
package/dist/cli.js
CHANGED
|
@@ -228,10 +228,15 @@ var optionalModuleManifests = [
|
|
|
228
228
|
"system.audit"
|
|
229
229
|
]),
|
|
230
230
|
fileRoots: [
|
|
231
|
+
"apps/agent-server",
|
|
231
232
|
"apps/server/src/agent",
|
|
232
233
|
"packages/modules/src/system-agent-tools.ts"
|
|
233
234
|
],
|
|
234
|
-
documentation: [
|
|
235
|
+
documentation: [
|
|
236
|
+
"docs/architecture/ai-runtime.md",
|
|
237
|
+
"docs/platform/agent-runtime.md",
|
|
238
|
+
"docs/platform/agent-tools.md"
|
|
239
|
+
]
|
|
235
240
|
},
|
|
236
241
|
{
|
|
237
242
|
...module("system.scheduler", "任务调度", "任务、重试、历史和恢复。", [
|
|
@@ -881,7 +886,10 @@ async function initializeLocalEnvironment(projectRoot, generateSecret = defaultS
|
|
|
881
886
|
if (!gitignore.split(/\r?\n/).some((line) => line.trim() === ".env")) {
|
|
882
887
|
throw new Error("生成项目的 .gitignore 未排除 .env,拒绝写入本地密码");
|
|
883
888
|
}
|
|
884
|
-
|
|
889
|
+
let content = setEmptyVariable(setEmptyVariable(example, "POSTGRES_PASSWORD", generateSecret()), "WINDY_BOOTSTRAP_ADMIN_PASSWORD", generateSecret());
|
|
890
|
+
if (/^AGENT_SERVER_INTERNAL_TOKEN=[ \t\r]*$/m.test(content)) {
|
|
891
|
+
content = setEmptyVariable(content, "AGENT_SERVER_INTERNAL_TOKEN", generateSecret());
|
|
892
|
+
}
|
|
885
893
|
let handle;
|
|
886
894
|
try {
|
|
887
895
|
handle = await open(target, "wx", 384);
|
|
@@ -1441,8 +1449,8 @@ function isMissing2(error) {
|
|
|
1441
1449
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
1442
1450
|
}
|
|
1443
1451
|
|
|
1444
|
-
// src/
|
|
1445
|
-
var
|
|
1452
|
+
// src/runtime-profile-files.ts
|
|
1453
|
+
var starterComposeBase = `services:
|
|
1446
1454
|
postgres:
|
|
1447
1455
|
image: postgres:17-alpine
|
|
1448
1456
|
environment:
|
|
@@ -1486,6 +1494,10 @@ var starterCompose = `services:
|
|
|
1486
1494
|
WINDY_CONTAINERIZED: "1"
|
|
1487
1495
|
BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD: "true"
|
|
1488
1496
|
WINDY_SYSTEM_USAGE_PATHS: /app/data/audit
|
|
1497
|
+
PLATFORM_ENABLE_AI: \${PLATFORM_ENABLE_AI:-false}
|
|
1498
|
+
AGENT_SERVER_URL: http://agent-server:8080
|
|
1499
|
+
AGENT_TOOL_HOST_URL: http://server:9747
|
|
1500
|
+
AGENT_SERVER_INTERNAL_TOKEN: \${AGENT_SERVER_INTERNAL_TOKEN:-}
|
|
1489
1501
|
ports:
|
|
1490
1502
|
- "9747:9747"
|
|
1491
1503
|
volumes:
|
|
@@ -1529,11 +1541,50 @@ var starterCompose = `services:
|
|
|
1529
1541
|
volumes:
|
|
1530
1542
|
postgres-data:
|
|
1531
1543
|
server-audit-data:
|
|
1544
|
+
__AGENT_VOLUME__
|
|
1545
|
+
`;
|
|
1546
|
+
var agentComposeService = ` agent-server:
|
|
1547
|
+
profiles: ["agent"]
|
|
1548
|
+
build:
|
|
1549
|
+
context: .
|
|
1550
|
+
dockerfile: apps/agent-server/Dockerfile
|
|
1551
|
+
environment:
|
|
1552
|
+
AGENT_SERVER_INTERNAL_TOKEN: \${AGENT_SERVER_INTERNAL_TOKEN:-}
|
|
1553
|
+
AGENT_RUN_STORE_PATH: /app/data/agent/runs.sqlite3
|
|
1554
|
+
QWEN_API_URL: \${QWEN_API_URL:-}
|
|
1555
|
+
QWEN_API_KEY: \${QWEN_API_KEY:-}
|
|
1556
|
+
QWEN_MODEL: \${QWEN_MODEL:-}
|
|
1557
|
+
AI_PROVIDER_CONNECT_TIMEOUT_MS: \${AI_PROVIDER_CONNECT_TIMEOUT_MS:-5000}
|
|
1558
|
+
AI_PROVIDER_REQUEST_TIMEOUT_MS: \${AI_PROVIDER_REQUEST_TIMEOUT_MS:-60000}
|
|
1559
|
+
AI_PROVIDER_MAX_RESPONSE_BYTES: \${AI_PROVIDER_MAX_RESPONSE_BYTES:-2097152}
|
|
1560
|
+
volumes:
|
|
1561
|
+
- agent-run-data:/app/data/agent
|
|
1562
|
+
depends_on:
|
|
1563
|
+
server:
|
|
1564
|
+
condition: service_started
|
|
1565
|
+
|
|
1532
1566
|
`;
|
|
1533
|
-
function
|
|
1567
|
+
function starterComposeFor(includeAgent) {
|
|
1568
|
+
return starterComposeBase.replace(` web:
|
|
1569
|
+
`, `${includeAgent ? agentComposeService : ""} web:
|
|
1570
|
+
`).replace("__AGENT_VOLUME__", includeAgent ? " agent-run-data:" : "");
|
|
1571
|
+
}
|
|
1572
|
+
var starterCompose = starterComposeFor(true);
|
|
1573
|
+
function environmentExample(includeExample, includeAgent = true) {
|
|
1534
1574
|
return `# 复制为 .env 后填写。不要提交真实密码。
|
|
1535
1575
|
POSTGRES_PASSWORD=
|
|
1536
1576
|
WINDY_BOOTSTRAP_ADMIN_PASSWORD=
|
|
1577
|
+
${includeAgent ? `
|
|
1578
|
+
# Agent profile 仅由 Server 读取;不要使用 VITE_ 前缀。
|
|
1579
|
+
PLATFORM_ENABLE_AI=false
|
|
1580
|
+
AGENT_SERVER_INTERNAL_TOKEN=
|
|
1581
|
+
QWEN_API_URL=
|
|
1582
|
+
QWEN_API_KEY=
|
|
1583
|
+
QWEN_MODEL=
|
|
1584
|
+
# AI_PROVIDER_CONNECT_TIMEOUT_MS=5000
|
|
1585
|
+
# AI_PROVIDER_REQUEST_TIMEOUT_MS=60000
|
|
1586
|
+
# AI_PROVIDER_MAX_RESPONSE_BYTES=2097152
|
|
1587
|
+
` : ""}
|
|
1537
1588
|
|
|
1538
1589
|
# 可选搜索配置;不配置时启用 Manifest 中默认开启的 Provider。${includeExample ? `
|
|
1539
1590
|
# SEARCH_PROVIDER_KEYS=work-order.ticket.search` : `
|
|
@@ -1543,7 +1594,8 @@ WINDY_BOOTSTRAP_ADMIN_PASSWORD=
|
|
|
1543
1594
|
# SEARCH_MAX_REQUESTS_PER_MINUTE=30
|
|
1544
1595
|
`;
|
|
1545
1596
|
}
|
|
1546
|
-
|
|
1597
|
+
// src/project-files.ts
|
|
1598
|
+
function starterDocsReadme(includeOfflineLicense = true, includeAgent = true) {
|
|
1547
1599
|
return `# Windy Starter 文档
|
|
1548
1600
|
|
|
1549
1601
|
本目录只包含客户运行项目需要的平台架构、开发接入和运维可观测性文档。
|
|
@@ -1554,11 +1606,13 @@ License 签发中心、独立签名服务、Vault、发布治理、内部任务
|
|
|
1554
1606
|
- 平台开发:从 [系统 CRUD API](./platform/system-crud-api.md) 和 [CRUD 生成器](./development/crud-generator.md) 开始。
|
|
1555
1607
|
${includeOfflineLicense ? "- License 消费端:[离线激活与安装协议](./license/license-offline-activation-code.md);运行时能力由 npm 依赖 `@southwind-ai/license` 提供。" : ""}
|
|
1556
1608
|
- 运维:[可观测性](./operations/observability.md) 与 [运维总览](./platform/operations-overview.md)。
|
|
1609
|
+
${includeAgent ? "- Agent:[Agent Runtime 接入](./platform/agent-runtime.md) 与 [受控工具](./platform/agent-tools.md)。" : ""}
|
|
1557
1610
|
- 兼容:[RuoYi-Vue Adapter](./adapters/ruoyi-vue-compatibility.md)。
|
|
1558
1611
|
`;
|
|
1559
1612
|
}
|
|
1560
1613
|
function starterReadme(projectName, includeExample = true, includeOxc = true, projectLicense = "UNLICENSED", selectedModules = []) {
|
|
1561
1614
|
const selected = new Set(selectedModules);
|
|
1615
|
+
const includeAgent = selected.has("system.agent");
|
|
1562
1616
|
const moduleRows = moduleDistributionCatalog.map(({ name, description, selection }) => `| \`${name}\` | ${selected.has(name) ? "已包含" : "未包含"} | ${selection === "required" ? "H0" : "H2"} | ${description} |`).join(`
|
|
1563
1617
|
`);
|
|
1564
1618
|
return `# ${projectName}
|
|
@@ -1614,6 +1668,21 @@ bun run dev:server
|
|
|
1614
1668
|
直接输出已有地址,其它服务占用时会自动使用后续端口,最多检查 100 个。实际地址始终
|
|
1615
1669
|
以终端输出为准。\`dev:server\` 不会自动启动 PostgreSQL 或执行 migration;未配置
|
|
1616
1670
|
\`DATABASE_URL\` 时使用内存模式。
|
|
1671
|
+
${includeAgent ? `
|
|
1672
|
+
## OpenAI-compatible Agent profile
|
|
1673
|
+
|
|
1674
|
+
Agent Runtime 默认不启动。先在仅供本机使用的 \`.env\` 中设置
|
|
1675
|
+
\`PLATFORM_ENABLE_AI=true\`、\`AGENT_SERVER_INTERNAL_TOKEN\`、\`QWEN_API_URL\`、
|
|
1676
|
+
\`QWEN_API_KEY\` 与 \`QWEN_MODEL\`,再运行:
|
|
1677
|
+
|
|
1678
|
+
\`\`\`bash
|
|
1679
|
+
bun run dev:agent
|
|
1680
|
+
\`\`\`
|
|
1681
|
+
|
|
1682
|
+
公网 Provider URL 必须使用 HTTPS;本机、容器服务名和内网地址允许 HTTP。
|
|
1683
|
+
\`QWEN_API_KEY\` 只进入 Agent Server,不会进入 Web 环境或返回给客户端。运行与模块
|
|
1684
|
+
接入细节见 [Agent Runtime 接入](./docs/platform/agent-runtime.md)。
|
|
1685
|
+
` : ""}
|
|
1617
1686
|
|
|
1618
1687
|
## 页面与登录
|
|
1619
1688
|
|
|
@@ -1738,6 +1807,7 @@ function createStarterPackage(source, projectName, includeOxc = true, projectLic
|
|
|
1738
1807
|
dev: "docker compose up",
|
|
1739
1808
|
"docker:sync": "create-windy sync-docker",
|
|
1740
1809
|
"dev:build": "bun run docker:sync && docker compose up --build",
|
|
1810
|
+
...selectedModules.includes("system.agent") ? { "dev:agent": "docker compose --profile agent up --build" } : {},
|
|
1741
1811
|
...generatorVersion ? { windy: "create-windy" } : {},
|
|
1742
1812
|
"dev:web": "bun run --cwd apps/web dev",
|
|
1743
1813
|
"dev:server": "bun run --cwd apps/server dev",
|
|
@@ -1785,6 +1855,7 @@ var templateRootFiles = [
|
|
|
1785
1855
|
];
|
|
1786
1856
|
var templateDirectories = [
|
|
1787
1857
|
"apps/server",
|
|
1858
|
+
"apps/agent-server",
|
|
1788
1859
|
"apps/web",
|
|
1789
1860
|
"examples",
|
|
1790
1861
|
"packages/ai-client",
|
|
@@ -1801,6 +1872,7 @@ var templateDirectories = [
|
|
|
1801
1872
|
var templateDocumentationFiles = [
|
|
1802
1873
|
"docs/adapters/ruoyi-vue-compatibility.md",
|
|
1803
1874
|
"docs/architecture/admin-surface-boundary.md",
|
|
1875
|
+
"docs/architecture/ai-runtime.md",
|
|
1804
1876
|
"docs/architecture/data-scope-query-enforcement.md",
|
|
1805
1877
|
"docs/architecture/durable-jobs.md",
|
|
1806
1878
|
"docs/architecture/minimal-approval-workflow.md",
|
|
@@ -1811,6 +1883,7 @@ var templateDocumentationFiles = [
|
|
|
1811
1883
|
"docs/license/license-offline-activation-code.md",
|
|
1812
1884
|
"docs/operations/observability.md",
|
|
1813
1885
|
"docs/platform/agent-tools.md",
|
|
1886
|
+
"docs/platform/agent-runtime.md",
|
|
1814
1887
|
"docs/platform/audit-reliability.md",
|
|
1815
1888
|
"docs/platform/bulk-data-jobs.md",
|
|
1816
1889
|
"docs/platform/data-access-control.md",
|
|
@@ -2025,10 +2098,10 @@ async function generateProject(input) {
|
|
|
2025
2098
|
const packageJson = JSON.parse(await readFile10(packagePath, "utf8"));
|
|
2026
2099
|
await writeFile9(packagePath, `${JSON.stringify(createStarterPackage(packageJson, input.projectName, includeOxc, projectLicense, selectedModules, templateMetadata.generatorVersion), null, 2)}
|
|
2027
2100
|
`);
|
|
2028
|
-
await writeFile9(join10(input.targetDirectory, "docker-compose.yml"),
|
|
2029
|
-
await writeFile9(join10(input.targetDirectory, ".env.example"), environmentExample(includeExample));
|
|
2101
|
+
await writeFile9(join10(input.targetDirectory, "docker-compose.yml"), starterComposeFor(selectedModules.includes("system.agent")));
|
|
2102
|
+
await writeFile9(join10(input.targetDirectory, ".env.example"), environmentExample(includeExample, selectedModules.includes("system.agent")));
|
|
2030
2103
|
await writeFile9(join10(input.targetDirectory, "README.md"), starterReadme(input.projectName, includeExample, includeOxc, projectLicense, selectedModules));
|
|
2031
|
-
await writeFile9(join10(input.targetDirectory, "docs/README.md"), starterDocsReadme(selectedModules.includes("system.license")));
|
|
2104
|
+
await writeFile9(join10(input.targetDirectory, "docs/README.md"), starterDocsReadme(selectedModules.includes("system.license"), selectedModules.includes("system.agent")));
|
|
2032
2105
|
await reflectCodeQualityChoice(input.targetDirectory, includeOxc);
|
|
2033
2106
|
await applyWindyBaseLicense(input.targetDirectory);
|
|
2034
2107
|
await applyProjectLicense(input.targetDirectory, projectLicense, input.licenseHolder);
|
|
@@ -4205,7 +4278,8 @@ var recipes = [
|
|
|
4205
4278
|
{ id: "starter-0.2.16-to-0.2.17", from: "0.2.16", to: "0.2.17" },
|
|
4206
4279
|
{ id: "starter-0.2.17-to-0.2.18", from: "0.2.17", to: "0.2.18" },
|
|
4207
4280
|
{ id: "starter-0.2.18-to-0.2.19", from: "0.2.18", to: "0.2.19" },
|
|
4208
|
-
{ id: "starter-0.2.19-to-0.2.20", from: "0.2.19", to: "0.2.20" }
|
|
4281
|
+
{ id: "starter-0.2.19-to-0.2.20", from: "0.2.19", to: "0.2.20" },
|
|
4282
|
+
{ id: "starter-0.2.20-to-0.2.21", from: "0.2.20", to: "0.2.21" }
|
|
4209
4283
|
];
|
|
4210
4284
|
function resolveRecipeChain(sourceVersion, targetVersion) {
|
|
4211
4285
|
if (sourceVersion === targetVersion)
|
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"))
|