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.
Files changed (65) hide show
  1. package/README.md +14 -36
  2. package/dist/cli.js +1831 -3178
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +7 -1
  6. package/template/README.md +8 -11
  7. package/template/apps/agent-server/Dockerfile +2 -1
  8. package/template/apps/agent-server/README.md +13 -5
  9. package/template/apps/agent-server/adk_web_agents/canonical_debug/__init__.py +1 -0
  10. package/template/apps/agent-server/adk_web_agents/canonical_debug/agent.py +125 -0
  11. package/template/apps/agent-server/project/adk-web-debug.json +4 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/adk_debug.py +315 -0
  13. package/template/apps/agent-server/tests/test_adk_web_canonical_debug.py +235 -0
  14. package/template/apps/server/Dockerfile +1 -1
  15. package/template/apps/server/src/agent/run-debug-authorization.ts +27 -0
  16. package/template/apps/server/src/agent/run-facade-adk-debug.test.ts +56 -0
  17. package/template/apps/server/src/agent/run-facade.test-support.ts +235 -0
  18. package/template/apps/server/src/agent/run-facade.test.ts +8 -244
  19. package/template/apps/server/src/agent/run-facade.ts +23 -48
  20. package/template/apps/server/src/agent/run-operation-registry.ts +51 -0
  21. package/template/apps/server/src/agent/runtime-bootstrap.ts +5 -2
  22. package/template/apps/server/src/agent/tool-audit.ts +3 -0
  23. package/template/apps/server/src/agent/tool-registry.test.ts +29 -0
  24. package/template/apps/server/src/application-services.ts +83 -15
  25. package/template/apps/server/src/audit/contracts.ts +8 -0
  26. package/template/apps/server/src/audit/recovery-queue.test.ts +48 -1
  27. package/template/apps/server/src/audit/recovery-queue.ts +73 -4
  28. package/template/apps/server/src/audit/reliable-writer.test.ts +120 -2
  29. package/template/apps/server/src/audit/reliable-writer.ts +98 -34
  30. package/template/apps/server/src/guards.ts +16 -0
  31. package/template/apps/server/src/index.ts +23 -20
  32. package/template/apps/server/src/jobs/legacy-adapter.integration.test.ts +0 -1
  33. package/template/apps/server/src/jobs/legacy-adapter.test.ts +6 -0
  34. package/template/apps/server/src/jobs/legacy-service-runtime.ts +18 -0
  35. package/template/apps/server/src/module-host/durable-context-unavailable.test.ts +84 -0
  36. package/template/apps/server/src/module-host/durable-context.test.ts +14 -0
  37. package/template/apps/server/src/module-host/durable-context.ts +17 -15
  38. package/template/apps/server/src/module-host/request-context.test.ts +30 -1
  39. package/template/apps/server/src/module-host/request-context.ts +15 -2
  40. package/template/apps/server/src/module-host/storage-port.ts +1 -1
  41. package/template/apps/server/src/platform-bootstrap.ts +16 -0
  42. package/template/apps/server/src/runtime-adk-debug.test.ts +246 -0
  43. package/template/apps/server/src/runtime-adk-debug.ts +158 -0
  44. package/template/apps/server/src/runtime-request-actor.test.ts +55 -0
  45. package/template/apps/server/src/runtime-request-actor.ts +22 -0
  46. package/template/apps/server/src/runtime.test.ts +3 -0
  47. package/template/apps/server/src/runtime.ts +36 -3
  48. package/template/apps/server/src/scheduler/definitions.test.ts +16 -0
  49. package/template/apps/web/Dockerfile +1 -1
  50. package/template/docker-compose.agent-debug.yml +56 -0
  51. package/template/docker-compose.yml +1 -39
  52. package/template/docs/architecture/ai-runtime.md +14 -5
  53. package/template/docs/architecture/module-manifest-composition.md +14 -0
  54. package/template/docs/development/custom-login-page.md +2 -16
  55. package/template/docs/platform/agent-runtime.md +57 -16
  56. package/template/docs/platform/agent-tool-sandbox.md +4 -3
  57. package/template/docs/platform/audit-reliability.md +33 -5
  58. package/template/docs/platform/platform-authentication.md +9 -1
  59. package/template/package.json +4 -3
  60. package/template/packages/database/drizzle/0043_activate_agent_dependencies.sql +196 -0
  61. package/template/packages/database/drizzle/meta/0043_snapshot.json +8335 -0
  62. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  63. package/template/packages/database/src/schema-notification-delivery.test.ts +15 -6
  64. package/template/packages/database/src/schema-workflow-notification.test.ts +44 -29
  65. package/template/scripts/dev-adk-web.ts +107 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "39074967cfcee6aca5ff6d3e2791b93ab90e70af",
4
- "generatorVersion": "0.3.16",
3
+ "templateCommit": "f1470acc1588deb087d3144a76b0729affed0988",
4
+ "generatorVersion": "0.3.18",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -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`:本次生成器版本、所选模块和选项记录。
@@ -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
- 更新前必须保持 Git 工作区干净。`--dry-run` 只生成计划,不写项目;正式更新先备份,
159
- 再做 package 字段级合并、共享脚手架三方合并与完整验证。任何冲突都会在写入前中止,
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 和文件所有权,从 npm 历史 artifact
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 && chown -R windy:windy /app/data
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
- 开发者可单独启动 ADK Web:
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`。该 `agent-debug` profile 不读取 Qwen、内部 Token 或
71
- 业务配置,只加载无 Provider、无业务 Tool 的确定性调试 Agent。它用于检查 ADK Web、
72
- Session 和 Event 交互,不能复现带 Actor/Execution Grant 的真实 Operation,也不能
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,4 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "textOperation": null
4
+ }
@@ -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)