create-windy 0.3.16 → 0.3.18
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 +14 -36
- package/dist/cli.js +1831 -3178
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +7 -1
- package/template/README.md +8 -11
- package/template/apps/agent-server/Dockerfile +2 -1
- package/template/apps/agent-server/README.md +13 -5
- package/template/apps/agent-server/adk_web_agents/canonical_debug/__init__.py +1 -0
- package/template/apps/agent-server/adk_web_agents/canonical_debug/agent.py +125 -0
- package/template/apps/agent-server/project/adk-web-debug.json +4 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_debug.py +315 -0
- package/template/apps/agent-server/tests/test_adk_web_canonical_debug.py +235 -0
- package/template/apps/server/Dockerfile +1 -1
- package/template/apps/server/src/agent/run-debug-authorization.ts +27 -0
- package/template/apps/server/src/agent/run-facade-adk-debug.test.ts +56 -0
- package/template/apps/server/src/agent/run-facade.test-support.ts +235 -0
- package/template/apps/server/src/agent/run-facade.test.ts +8 -244
- package/template/apps/server/src/agent/run-facade.ts +23 -48
- package/template/apps/server/src/agent/run-operation-registry.ts +51 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +5 -2
- package/template/apps/server/src/agent/tool-audit.ts +3 -0
- package/template/apps/server/src/agent/tool-registry.test.ts +29 -0
- package/template/apps/server/src/application-services.ts +83 -15
- package/template/apps/server/src/audit/contracts.ts +8 -0
- package/template/apps/server/src/audit/recovery-queue.test.ts +48 -1
- package/template/apps/server/src/audit/recovery-queue.ts +73 -4
- package/template/apps/server/src/audit/reliable-writer.test.ts +120 -2
- package/template/apps/server/src/audit/reliable-writer.ts +98 -34
- package/template/apps/server/src/guards.ts +16 -0
- package/template/apps/server/src/index.ts +23 -20
- package/template/apps/server/src/jobs/legacy-adapter.integration.test.ts +0 -1
- package/template/apps/server/src/jobs/legacy-adapter.test.ts +6 -0
- package/template/apps/server/src/jobs/legacy-service-runtime.ts +18 -0
- package/template/apps/server/src/module-host/durable-context-unavailable.test.ts +84 -0
- package/template/apps/server/src/module-host/durable-context.test.ts +14 -0
- package/template/apps/server/src/module-host/durable-context.ts +17 -15
- package/template/apps/server/src/module-host/request-context.test.ts +30 -1
- package/template/apps/server/src/module-host/request-context.ts +15 -2
- package/template/apps/server/src/module-host/storage-port.ts +1 -1
- package/template/apps/server/src/platform-bootstrap.ts +16 -0
- package/template/apps/server/src/runtime-adk-debug.test.ts +246 -0
- package/template/apps/server/src/runtime-adk-debug.ts +158 -0
- package/template/apps/server/src/runtime-request-actor.test.ts +55 -0
- package/template/apps/server/src/runtime-request-actor.ts +22 -0
- package/template/apps/server/src/runtime.test.ts +3 -0
- package/template/apps/server/src/runtime.ts +36 -3
- package/template/apps/server/src/scheduler/definitions.test.ts +16 -0
- package/template/apps/web/Dockerfile +1 -1
- package/template/docker-compose.agent-debug.yml +56 -0
- package/template/docker-compose.yml +1 -39
- package/template/docs/architecture/ai-runtime.md +14 -5
- package/template/docs/architecture/module-manifest-composition.md +14 -0
- package/template/docs/development/custom-login-page.md +2 -16
- package/template/docs/platform/agent-runtime.md +57 -16
- package/template/docs/platform/agent-tool-sandbox.md +4 -3
- package/template/docs/platform/audit-reliability.md +33 -5
- package/template/docs/platform/platform-authentication.md +9 -1
- package/template/package.json +4 -3
- package/template/packages/database/drizzle/0043_activate_agent_dependencies.sql +196 -0
- package/template/packages/database/drizzle/meta/0043_snapshot.json +8335 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/src/schema-notification-delivery.test.ts +15 -6
- package/template/packages/database/src/schema-workflow-notification.test.ts +44 -29
- package/template/scripts/dev-adk-web.ts +107 -0
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -59,6 +59,10 @@
|
|
|
59
59
|
`providerDataPolicy.inputFields` JSON Pointer allowlist 与
|
|
60
60
|
`envelopeFields`;raw input 会在进入 `prepare` 前裁剪并执行 DLP。接入与升级
|
|
61
61
|
诊断见 `docs/platform/agent-runtime.md`。
|
|
62
|
+
- 开发期用 ADK Web 调试真实数据时,只在 project-owned 的
|
|
63
|
+
`apps/agent-server/project/adk-web-debug.json` 绑定满足文本契约的 Operation 与
|
|
64
|
+
当前启用的真实开发数据用户;Compose、权限收窄、Scope 复核、专用审计主体与
|
|
65
|
+
canonical Run bridge 由 Windy 基线提供。
|
|
62
66
|
|
|
63
67
|
## 常用入口
|
|
64
68
|
|
|
@@ -69,9 +73,11 @@
|
|
|
69
73
|
- `docs/development/custom-error-pages.md`:统一错误语义、Request ID 与业务页面
|
|
70
74
|
替换方式。
|
|
71
75
|
- `bun run dev`:复用 Docker 镜像启动 PostgreSQL、自动 migration、Server 与支持热更新的 Web。
|
|
72
|
-
- `bun run dev:build`:依赖、Dockerfile 或 lockfile
|
|
76
|
+
- `bun run dev:build`:依赖、Dockerfile 或 lockfile 变化后重建并启动开发环境;Web 与 Server 并发复用 BuildKit Bun 下载缓存,冷安装会周期输出不含凭据的心跳。
|
|
73
77
|
- `bun run dev:web`:启动 Web 开发服务。
|
|
74
78
|
- `bun run dev:server`:启动 Server 开发服务。
|
|
79
|
+
- `bun run dev:adk-web`:选择 Agent 模块后,一条命令启动真实 canonical Run
|
|
80
|
+
调试链;生产环境不提供该入口。
|
|
75
81
|
- `bun run lint`、`bun run typecheck`、`bun run test`、`bun run build`:执行基础验证;未选择 Oxc 时没有 lint 脚本。
|
|
76
82
|
- `docs/README.md`:平台架构、模块接入和运维文档入口。
|
|
77
83
|
- `.windy/generation.json`:本次生成器版本、所选模块和选项记录。
|
package/template/README.md
CHANGED
|
@@ -31,7 +31,9 @@ create-windy 已创建仅供本机使用且被 Git 忽略的 `.env`,其中两
|
|
|
31
31
|
Drizzle migration,再启动 Server 与 Web;不要跳过 migration 后直接把 Server 连接到
|
|
32
32
|
空数据库。首次启动需要本机已经安装并启动 Docker 与 Docker Compose,并可能需要下载
|
|
33
33
|
基础镜像。后续 `bun run dev` 会复用已有镜像,不会重复构建;依赖、Dockerfile 或
|
|
34
|
-
`bun.lock` 变化后运行 `bun run dev:build
|
|
34
|
+
`bun.lock` 变化后运行 `bun run dev:build`。重建会让 Web 与 Server 并发复用
|
|
35
|
+
BuildKit 中的 Bun 包下载缓存,只下载新增或发生变化的依赖;冷安装每 15 秒输出一次
|
|
36
|
+
不含 registry 地址或凭据的进度心跳。
|
|
35
37
|
|
|
36
38
|
| 服务 | 地址 |
|
|
37
39
|
| ---- | ---- |
|
|
@@ -148,24 +150,19 @@ bun run windy update --dry-run
|
|
|
148
150
|
bun run windy module list
|
|
149
151
|
bun run windy module add work-order --dry-run
|
|
150
152
|
bun run windy module remove work-order --dry-run
|
|
151
|
-
bun run windy reconcile --to <精确目标版本>
|
|
152
153
|
bun run lint
|
|
153
154
|
bun run format
|
|
154
155
|
```
|
|
155
156
|
|
|
156
157
|
提交时 Husky 会通过 lint-staged 与 Oxfmt 格式化暂存文件,不会把未暂存内容带入提交。lint、typecheck 和 test 保留为需要时显式运行的命令。
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
更新前无需提交、暂存或清理业务改动。`--dry-run` 只生成计划,不写项目;无关 WIP
|
|
160
|
+
保持原样,命中同一路径时按冻结快照与三方合并 fail closed。正式更新先备份,再做
|
|
161
|
+
package 字段级合并、共享脚手架三方合并与完整验证。任何冲突都会在写入前中止,
|
|
160
162
|
无并发变化的验证失败会自动回滚;中断恢复使用
|
|
161
163
|
`bun run windy doctor --repair`。该命令在提交点前保留并发编辑并回滚其余路径,
|
|
162
164
|
在 committed 提交点后只完成事务状态与备份清理,不回滚项目文件。
|
|
163
165
|
|
|
164
|
-
仅当 Windy 发布说明提供确定性 proof 时使用
|
|
165
|
-
`reconcile --to <精确目标版本>`。它默认只演练;显式增加 `--apply` 后也只更新
|
|
166
|
-
受管清单元数据与无正文报告,不修改项目源码或推进 Project Lock。project-owned、
|
|
167
|
-
缺失和未知语义差异始终保守未决;shared-scaffold 只有精确命中已发布 proof 才可对账。
|
|
168
|
-
|
|
169
166
|
## 开发约束
|
|
170
167
|
|
|
171
168
|
- 新增路由默认需要登录;只有显式 public 的页面允许匿名访问。
|
|
@@ -183,8 +180,8 @@ bun run format
|
|
|
183
180
|
create-windy 默认建立 `main` 分支并创建 `chore: initialize project` 首次提交。若当时
|
|
184
181
|
没有配置 Git 用户身份,文件会保留在暂存区;配置身份后手动提交即可。
|
|
185
182
|
|
|
186
|
-
当前已交付的 Windy 更新器会根据 Project Lock 和文件所有权,从
|
|
187
|
-
重建 Base 并执行三方合并;它不连接私有源仓库,也不会静默覆盖项目代码。模块
|
|
183
|
+
当前已交付的 Windy 更新器会根据 Project Lock 和文件所有权,从 Project Lock 指向的
|
|
184
|
+
精确 npm artifact 重建 Base 并执行三方合并;它不连接私有源仓库,也不会静默覆盖项目代码。模块
|
|
188
185
|
生命周期命令复用同一计划、事务、验证与恢复边界;卸载默认保留数据库数据,并要求
|
|
189
186
|
显式确认数据处置计划。
|
|
190
187
|
|
|
@@ -14,7 +14,8 @@ RUN uv sync --frozen --no-dev
|
|
|
14
14
|
ENV PATH="/app/apps/agent-server/.venv/bin:${PATH}"
|
|
15
15
|
ENV PYTHONUNBUFFERED=1
|
|
16
16
|
EXPOSE 8080
|
|
17
|
-
RUN mkdir -p /app/data/agent
|
|
17
|
+
RUN mkdir -p /app/data/agent /app/apps/agent-server/project \
|
|
18
|
+
&& chown -R windy:windy /app/data /app/apps/agent-server/project
|
|
18
19
|
USER windy
|
|
19
20
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
|
|
20
21
|
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
|
|
@@ -61,13 +61,21 @@ SQLite append-only 表。审计不保存 prompt、请求正文或 Secret;审
|
|
|
61
61
|
`GET /health/provider-governance` 查看。SQLite 实现仅支持单副本;多副本部署必须替换
|
|
62
62
|
为共享、并发安全的审计 Adapter。
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
项目在 `project/adk-web-debug.json` 绑定一个满足
|
|
65
|
+
`{ question: string } -> { text: string }` 的真实 Agent Operation 和当前启用的开发
|
|
66
|
+
数据用户后,可启动 ADK Web:
|
|
65
67
|
|
|
66
68
|
```bash
|
|
67
69
|
bun run dev:adk-web
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
访问 `http://127.0.0.1:8000
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
访问 `http://127.0.0.1:8000` 并选择 `canonical_debug`。该命令会拉起完整开发
|
|
73
|
+
Runtime;Windy 启动脚本读取项目选择的 Operation,ADK Web 使用 create-windy 自动生成
|
|
74
|
+
的随机调试 Token,经 canonical HTTP/SSE 执行真实 Operation。Server 把它解析成仅限
|
|
75
|
+
Run 路径的专用服务身份,向业务 Module 投影真实用户的成员 ID 和当前 Scope,并把权限
|
|
76
|
+
收窄到该用户已有权限与 Operation/allowed Tools 闭包。Provider、业务 Tool、
|
|
77
|
+
Execution Grant 和审计仍由正式 Server/Agent Server 负责,ADK 容器不持有 Provider
|
|
78
|
+
Key 或内部服务 Token。
|
|
79
|
+
|
|
80
|
+
`safe_debug` 只用于纯 ADK UI smoke。生产 Compose 不包含 ADK Web;当前桥显示的是
|
|
81
|
+
canonical Run 及有界事件摘要,不是内部完整 Workflow 图。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Developer-only canonical Run bridge for ADK Web."""
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from google.adk.agents import LlmAgent
|
|
8
|
+
from google.adk.models.base_llm import BaseLlm
|
|
9
|
+
from google.adk.models.llm_request import LlmRequest
|
|
10
|
+
from google.adk.models.llm_response import LlmResponse
|
|
11
|
+
from google.genai import types
|
|
12
|
+
|
|
13
|
+
from southwind_agent_server.adk_debug import (
|
|
14
|
+
CanonicalRunDebugClient,
|
|
15
|
+
TextOperationDebugConfig,
|
|
16
|
+
load_text_operation_debug_config,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
TOOL_NAME = "run_canonical_operation"
|
|
20
|
+
PROJECT_OPERATION = load_text_operation_debug_config()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def run_canonical_operation(question: str) -> dict[str, object]:
|
|
24
|
+
"""Run the project-selected text Operation through canonical platform APIs."""
|
|
25
|
+
if PROJECT_OPERATION is None:
|
|
26
|
+
raise RuntimeError("项目尚未选择可调试的文本 Operation")
|
|
27
|
+
return await CanonicalRunDebugClient.from_environment(
|
|
28
|
+
PROJECT_OPERATION.operation_key
|
|
29
|
+
).run(question)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CanonicalDebugLlm(BaseLlm):
|
|
33
|
+
"""ADK dispatcher; real model and Tool execution stay in canonical Run."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, operation: TextOperationDebugConfig | None) -> None:
|
|
36
|
+
super().__init__(model="windy-canonical-run-bridge")
|
|
37
|
+
self._operation = operation
|
|
38
|
+
|
|
39
|
+
async def generate_content_async(
|
|
40
|
+
self,
|
|
41
|
+
llm_request: LlmRequest,
|
|
42
|
+
stream: bool = False,
|
|
43
|
+
) -> AsyncGenerator[LlmResponse, None]:
|
|
44
|
+
del stream
|
|
45
|
+
tool_response = latest_tool_response(llm_request)
|
|
46
|
+
if tool_response is not None:
|
|
47
|
+
yield text_response(render_result(tool_response))
|
|
48
|
+
return
|
|
49
|
+
if self._operation is None:
|
|
50
|
+
yield text_response(
|
|
51
|
+
"项目尚未在 apps/agent-server/project/adk-web-debug.json "
|
|
52
|
+
"选择真实文本 Operation。"
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
question = latest_user_text(llm_request)
|
|
56
|
+
if not question:
|
|
57
|
+
yield text_response("请输入要交给真实 Agent Runtime 调试的业务问题。")
|
|
58
|
+
return
|
|
59
|
+
yield LlmResponse(
|
|
60
|
+
content=types.Content(
|
|
61
|
+
role="model",
|
|
62
|
+
parts=[
|
|
63
|
+
types.Part(
|
|
64
|
+
function_call=types.FunctionCall(
|
|
65
|
+
name=TOOL_NAME,
|
|
66
|
+
args={"question": question},
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
],
|
|
70
|
+
),
|
|
71
|
+
partial=False,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def latest_user_text(request: LlmRequest) -> str:
|
|
76
|
+
for content in reversed(request.contents):
|
|
77
|
+
if content.role != "user":
|
|
78
|
+
continue
|
|
79
|
+
text = "".join(part.text or "" for part in content.parts or [])
|
|
80
|
+
if text.strip():
|
|
81
|
+
return text.strip()
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def latest_tool_response(request: LlmRequest) -> dict[str, object] | None:
|
|
86
|
+
for content in reversed(request.contents):
|
|
87
|
+
for part in content.parts or []:
|
|
88
|
+
response = part.function_response
|
|
89
|
+
if response and response.name == TOOL_NAME:
|
|
90
|
+
return cast(dict[str, object], response.response or {})
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def render_result(value: dict[str, object]) -> str:
|
|
95
|
+
answer = value.get("answer")
|
|
96
|
+
run_id = value.get("runId")
|
|
97
|
+
events = value.get("events")
|
|
98
|
+
if not isinstance(answer, str) or not isinstance(run_id, str):
|
|
99
|
+
return "真实 Agent 调试 Run 返回了无效结果。"
|
|
100
|
+
trace = json.dumps(events if isinstance(events, list) else [], ensure_ascii=False)
|
|
101
|
+
return f"{answer}\n\n---\n调试 Run:{run_id}\n事件摘要:{trace}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def text_response(text: str) -> LlmResponse:
|
|
105
|
+
return LlmResponse(
|
|
106
|
+
content=types.Content(role="model", parts=[types.Part(text=text)]),
|
|
107
|
+
partial=False,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_root_agent(
|
|
112
|
+
operation: TextOperationDebugConfig | None,
|
|
113
|
+
) -> LlmAgent:
|
|
114
|
+
return LlmAgent(
|
|
115
|
+
name="canonical_debug",
|
|
116
|
+
model=CanonicalDebugLlm(operation),
|
|
117
|
+
instruction=(
|
|
118
|
+
"每个业务问题都必须经 run_canonical_operation 交给 canonical Run;"
|
|
119
|
+
"不得自行回答、不得访问数据库或绕过平台授权。"
|
|
120
|
+
),
|
|
121
|
+
tools=[run_canonical_operation] if operation else [],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
root_agent = build_root_agent(PROJECT_OPERATION)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .config import normalize_base_url
|
|
14
|
+
|
|
15
|
+
MAX_QUESTION_LENGTH = 4_000
|
|
16
|
+
MAX_VISIBLE_EVENTS = 64
|
|
17
|
+
MAX_EVENT_FRAME_BYTES = 64 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class TextOperationDebugConfig:
|
|
22
|
+
operation_key: str
|
|
23
|
+
data_actor_id: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_text_operation_debug_config(
|
|
27
|
+
path: str | Path | None = None,
|
|
28
|
+
) -> TextOperationDebugConfig | None:
|
|
29
|
+
if path is None:
|
|
30
|
+
environment_key = os.environ.get("ADK_DEBUG_OPERATION_KEY", "").strip()
|
|
31
|
+
if environment_key:
|
|
32
|
+
return TextOperationDebugConfig(
|
|
33
|
+
operation_key=validate_operation_key(environment_key)
|
|
34
|
+
)
|
|
35
|
+
config_path = Path(path) if path else project_debug_config_path()
|
|
36
|
+
try:
|
|
37
|
+
value = json.loads(config_path.read_text(encoding="utf-8"))
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
return None
|
|
40
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
41
|
+
raise RuntimeError("ADK Web 项目调试配置不可读") from error
|
|
42
|
+
if not isinstance(value, dict) or set(value) != {
|
|
43
|
+
"schemaVersion",
|
|
44
|
+
"textOperation",
|
|
45
|
+
}:
|
|
46
|
+
raise RuntimeError("ADK Web 项目调试配置格式无效")
|
|
47
|
+
schema_version = value.get("schemaVersion")
|
|
48
|
+
if (
|
|
49
|
+
not isinstance(schema_version, int)
|
|
50
|
+
or isinstance(schema_version, bool)
|
|
51
|
+
or schema_version != 1
|
|
52
|
+
):
|
|
53
|
+
raise RuntimeError("ADK Web 项目调试配置版本不受支持")
|
|
54
|
+
operation = value.get("textOperation")
|
|
55
|
+
if operation is None:
|
|
56
|
+
return None
|
|
57
|
+
if not isinstance(operation, dict) or set(operation) != {
|
|
58
|
+
"key",
|
|
59
|
+
"dataActorId",
|
|
60
|
+
}:
|
|
61
|
+
raise RuntimeError("ADK Web 文本 Operation 配置格式无效")
|
|
62
|
+
return TextOperationDebugConfig(
|
|
63
|
+
operation_key=validate_identifier(
|
|
64
|
+
operation.get("key"), "ADK Web 文本 Operation Key 无效"
|
|
65
|
+
),
|
|
66
|
+
data_actor_id=validate_identifier(
|
|
67
|
+
operation.get("dataActorId"), "ADK Web 真实开发数据用户无效"
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_operation_key(value: object) -> str:
|
|
73
|
+
return validate_identifier(value, "ADK Web 文本 Operation Key 无效")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_identifier(value: object, message: str) -> str:
|
|
77
|
+
if (
|
|
78
|
+
not isinstance(value, str)
|
|
79
|
+
or not value.strip()
|
|
80
|
+
or len(value) > 200
|
|
81
|
+
or any(character.isspace() for character in value)
|
|
82
|
+
):
|
|
83
|
+
raise RuntimeError(message)
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def project_debug_config_path() -> Path:
|
|
88
|
+
configured = os.environ.get("ADK_DEBUG_PROJECT_CONFIG", "").strip()
|
|
89
|
+
if configured:
|
|
90
|
+
return Path(configured)
|
|
91
|
+
return Path(__file__).resolve().parents[2] / "project" / "adk-web-debug.json"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class CanonicalRunDebugClient:
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
operation_key: str,
|
|
98
|
+
base_url: str,
|
|
99
|
+
bearer_token: str,
|
|
100
|
+
*,
|
|
101
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._operation_key = operation_key
|
|
104
|
+
self._base_url = normalize_base_url(base_url)
|
|
105
|
+
self._bearer_token = bearer_token.strip()
|
|
106
|
+
self._transport = transport
|
|
107
|
+
if not self._bearer_token:
|
|
108
|
+
raise RuntimeError("ADK_DEBUG_PLATFORM_TOKEN 未配置,拒绝启动真实调试 Run")
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_environment(
|
|
112
|
+
cls,
|
|
113
|
+
operation_key: str,
|
|
114
|
+
) -> CanonicalRunDebugClient:
|
|
115
|
+
return cls(
|
|
116
|
+
operation_key,
|
|
117
|
+
os.environ.get("ADK_DEBUG_PLATFORM_URL", "http://server:9747"),
|
|
118
|
+
os.environ.get("ADK_DEBUG_PLATFORM_TOKEN", ""),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def run(self, question: str) -> dict[str, object]:
|
|
122
|
+
prompt = question.strip()
|
|
123
|
+
if not prompt or len(prompt) > MAX_QUESTION_LENGTH:
|
|
124
|
+
raise RuntimeError("调试问题不能为空且最长 4000 字")
|
|
125
|
+
headers = {
|
|
126
|
+
"authorization": f"Bearer {self._bearer_token}",
|
|
127
|
+
"idempotency-key": f"adk-web-{uuid.uuid4()}",
|
|
128
|
+
"user-agent": "Windy-ADK-Web-Dev",
|
|
129
|
+
}
|
|
130
|
+
async with httpx.AsyncClient(
|
|
131
|
+
base_url=f"{self._base_url}/",
|
|
132
|
+
headers=headers,
|
|
133
|
+
timeout=httpx.Timeout(120),
|
|
134
|
+
transport=self._transport,
|
|
135
|
+
) as client:
|
|
136
|
+
started = await client.post(
|
|
137
|
+
"api/agent/runs",
|
|
138
|
+
json={
|
|
139
|
+
"operationKey": self._operation_key,
|
|
140
|
+
"input": {"question": prompt},
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
run_id = read_run_id(started)
|
|
144
|
+
events = await collect_compact_events(self._events(client, run_id))
|
|
145
|
+
result = await client.get(
|
|
146
|
+
f"api/agent/runs/{run_id}/result",
|
|
147
|
+
params={"operationKey": self._operation_key},
|
|
148
|
+
)
|
|
149
|
+
return {
|
|
150
|
+
"runId": run_id,
|
|
151
|
+
"answer": read_text_output(result, run_id),
|
|
152
|
+
"events": events,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async def _events(
|
|
156
|
+
self,
|
|
157
|
+
client: httpx.AsyncClient,
|
|
158
|
+
run_id: str,
|
|
159
|
+
) -> AsyncIterator[dict[str, object]]:
|
|
160
|
+
async with client.stream(
|
|
161
|
+
"GET",
|
|
162
|
+
f"api/agent/runs/{run_id}/events",
|
|
163
|
+
params={"operationKey": self._operation_key, "after": 0},
|
|
164
|
+
) as response:
|
|
165
|
+
if not response.is_success:
|
|
166
|
+
raise RuntimeError("平台 Agent Run 事件读取失败")
|
|
167
|
+
async for event in sse_events(response):
|
|
168
|
+
yield summarize_event(event)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def sse_events(response: httpx.Response) -> AsyncIterator[dict[str, object]]:
|
|
172
|
+
buffer = bytearray()
|
|
173
|
+
async for chunk in response.aiter_bytes(chunk_size=4096):
|
|
174
|
+
buffer.extend(chunk)
|
|
175
|
+
while separator := frame_separator(buffer):
|
|
176
|
+
index, width = separator
|
|
177
|
+
if index > MAX_EVENT_FRAME_BYTES:
|
|
178
|
+
raise RuntimeError("平台 Agent Run 单个事件超过调试上限")
|
|
179
|
+
frame = bytes(buffer[:index])
|
|
180
|
+
del buffer[: index + width]
|
|
181
|
+
event = parse_sse_frame(frame)
|
|
182
|
+
if event is not None:
|
|
183
|
+
yield event
|
|
184
|
+
if len(buffer) > MAX_EVENT_FRAME_BYTES:
|
|
185
|
+
raise RuntimeError("平台 Agent Run 单个事件超过调试上限")
|
|
186
|
+
if buffer:
|
|
187
|
+
event = parse_sse_frame(bytes(buffer))
|
|
188
|
+
if event is not None:
|
|
189
|
+
yield event
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def frame_separator(buffer: bytearray) -> tuple[int, int] | None:
|
|
193
|
+
candidates = [
|
|
194
|
+
(index, len(separator))
|
|
195
|
+
for separator in (b"\n\n", b"\r\n\r\n")
|
|
196
|
+
if (index := buffer.find(separator)) >= 0
|
|
197
|
+
]
|
|
198
|
+
return min(candidates) if candidates else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_sse_frame(frame: bytes) -> dict[str, object] | None:
|
|
202
|
+
try:
|
|
203
|
+
lines = frame.decode("utf-8").splitlines()
|
|
204
|
+
except UnicodeDecodeError as error:
|
|
205
|
+
raise RuntimeError("平台 Agent Run 事件格式无效") from error
|
|
206
|
+
event_name = "message"
|
|
207
|
+
data: list[str] = []
|
|
208
|
+
for line in lines:
|
|
209
|
+
if not line or line.startswith(":"):
|
|
210
|
+
continue
|
|
211
|
+
if line.startswith("event:"):
|
|
212
|
+
event_name = line.removeprefix("event:").strip()
|
|
213
|
+
elif line.startswith("data:"):
|
|
214
|
+
value = line.removeprefix("data:")
|
|
215
|
+
data.append(value[1:] if value.startswith(" ") else value)
|
|
216
|
+
return parse_event(event_name, "\n".join(data)) if data else None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_event(event_name: str, data: str) -> dict[str, object]:
|
|
220
|
+
try:
|
|
221
|
+
value = json.loads(data)
|
|
222
|
+
except json.JSONDecodeError as error:
|
|
223
|
+
raise RuntimeError("平台 Agent Run 事件格式无效") from error
|
|
224
|
+
if not isinstance(value, dict) or value.get("type") != event_name:
|
|
225
|
+
raise RuntimeError("平台 Agent Run 事件格式无效")
|
|
226
|
+
return cast(dict[str, object], value)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def summarize_event(event: dict[str, object]) -> dict[str, object]:
|
|
230
|
+
fields = ("sequence", "type", "toolKey", "toolCallId", "workItemId", "outcome")
|
|
231
|
+
return {field: event[field] for field in fields if field in event}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def compact_events(events: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
235
|
+
compactor = EventCompactor()
|
|
236
|
+
for event in events:
|
|
237
|
+
compactor.add(event)
|
|
238
|
+
return compactor.finish()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def collect_compact_events(
|
|
242
|
+
events: AsyncIterator[dict[str, object]],
|
|
243
|
+
) -> list[dict[str, object]]:
|
|
244
|
+
compactor = EventCompactor()
|
|
245
|
+
async for event in events:
|
|
246
|
+
compactor.add(event)
|
|
247
|
+
return compactor.finish()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass
|
|
251
|
+
class EventCompactor:
|
|
252
|
+
visible: list[dict[str, object]] | None = None
|
|
253
|
+
output_deltas: int = 0
|
|
254
|
+
hidden_events: int = 0
|
|
255
|
+
|
|
256
|
+
def add(self, event: dict[str, object]) -> None:
|
|
257
|
+
if event.get("type") == "output.delta":
|
|
258
|
+
self.output_deltas += 1
|
|
259
|
+
return
|
|
260
|
+
if self.visible is None:
|
|
261
|
+
self.visible = []
|
|
262
|
+
if len(self.visible) < MAX_VISIBLE_EVENTS:
|
|
263
|
+
self.visible.append(event)
|
|
264
|
+
else:
|
|
265
|
+
self.hidden_events += 1
|
|
266
|
+
|
|
267
|
+
def finish(self) -> list[dict[str, object]]:
|
|
268
|
+
visible = list(self.visible or [])
|
|
269
|
+
while True:
|
|
270
|
+
trailers = self._trailers()
|
|
271
|
+
if len(visible) + len(trailers) <= MAX_VISIBLE_EVENTS:
|
|
272
|
+
return [*visible, *trailers]
|
|
273
|
+
visible.pop()
|
|
274
|
+
self.hidden_events += 1
|
|
275
|
+
|
|
276
|
+
def _trailers(self) -> list[dict[str, object]]:
|
|
277
|
+
trailers: list[dict[str, object]] = []
|
|
278
|
+
if self.output_deltas:
|
|
279
|
+
trailers.append({"type": "output.delta", "count": self.output_deltas})
|
|
280
|
+
if self.hidden_events:
|
|
281
|
+
trailers.append({"type": "events.truncated", "count": self.hidden_events})
|
|
282
|
+
return trailers
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def read_run_id(response: httpx.Response) -> str:
|
|
286
|
+
value = response_json(response, "平台 Agent Run 启动失败")
|
|
287
|
+
run_id = value.get("runId")
|
|
288
|
+
if not isinstance(run_id, str) or not run_id:
|
|
289
|
+
raise RuntimeError("平台 Agent Run 启动响应无效")
|
|
290
|
+
return run_id
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def read_text_output(response: httpx.Response, expected_run_id: str) -> str:
|
|
294
|
+
value = response_json(response, "平台 Agent Run 未完成")
|
|
295
|
+
if value.get("runId") != expected_run_id or value.get("status") != "completed":
|
|
296
|
+
raise RuntimeError("平台 Agent Run 结果身份或状态无效")
|
|
297
|
+
output = value.get("output")
|
|
298
|
+
if not isinstance(output, dict):
|
|
299
|
+
raise RuntimeError("平台 Agent Run 输出不符合文本调试契约")
|
|
300
|
+
text = output.get("text")
|
|
301
|
+
if not isinstance(text, str):
|
|
302
|
+
raise RuntimeError("平台 Agent Run 输出不符合文本调试契约")
|
|
303
|
+
return text
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def response_json(response: httpx.Response, message: str) -> dict[str, Any]:
|
|
307
|
+
if not response.is_success:
|
|
308
|
+
raise RuntimeError(message)
|
|
309
|
+
try:
|
|
310
|
+
value = response.json()
|
|
311
|
+
except json.JSONDecodeError as error:
|
|
312
|
+
raise RuntimeError(message) from error
|
|
313
|
+
if not isinstance(value, dict):
|
|
314
|
+
raise RuntimeError(message)
|
|
315
|
+
return cast(dict[str, Any], value)
|