create-windy 0.3.17 → 0.3.19

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 (58) hide show
  1. package/README.md +14 -36
  2. package/dist/cli.js +1534 -3079
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +6 -0
  6. package/template/README.md +5 -10
  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 +348 -0
  13. package/template/apps/agent-server/tests/test_adk_debug_result_polling.py +139 -0
  14. package/template/apps/agent-server/tests/test_adk_web_canonical_debug.py +235 -0
  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/guards.ts +16 -0
  26. package/template/apps/server/src/index.ts +23 -20
  27. package/template/apps/server/src/jobs/legacy-adapter.integration.test.ts +0 -1
  28. package/template/apps/server/src/jobs/legacy-adapter.test.ts +6 -0
  29. package/template/apps/server/src/jobs/legacy-service-runtime.ts +18 -0
  30. package/template/apps/server/src/module-host/durable-context-unavailable.test.ts +84 -0
  31. package/template/apps/server/src/module-host/durable-context.test.ts +14 -0
  32. package/template/apps/server/src/module-host/durable-context.ts +17 -15
  33. package/template/apps/server/src/module-host/request-context.test.ts +30 -1
  34. package/template/apps/server/src/module-host/request-context.ts +15 -2
  35. package/template/apps/server/src/module-host/storage-port.ts +1 -1
  36. package/template/apps/server/src/platform-bootstrap.ts +16 -0
  37. package/template/apps/server/src/runtime-adk-debug.test.ts +246 -0
  38. package/template/apps/server/src/runtime-adk-debug.ts +158 -0
  39. package/template/apps/server/src/runtime-request-actor.test.ts +55 -0
  40. package/template/apps/server/src/runtime-request-actor.ts +22 -0
  41. package/template/apps/server/src/runtime.test.ts +3 -0
  42. package/template/apps/server/src/runtime.ts +36 -3
  43. package/template/apps/server/src/scheduler/definitions.test.ts +16 -0
  44. package/template/docker-compose.agent-debug.yml +56 -0
  45. package/template/docker-compose.yml +1 -39
  46. package/template/docs/architecture/ai-runtime.md +14 -5
  47. package/template/docs/architecture/module-manifest-composition.md +14 -0
  48. package/template/docs/development/custom-login-page.md +2 -16
  49. package/template/docs/platform/agent-runtime.md +59 -16
  50. package/template/docs/platform/agent-tool-sandbox.md +4 -3
  51. package/template/docs/platform/platform-authentication.md +9 -1
  52. package/template/package.json +1 -1
  53. package/template/packages/database/drizzle/0043_activate_agent_dependencies.sql +196 -0
  54. package/template/packages/database/drizzle/meta/0043_snapshot.json +8335 -0
  55. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  56. package/template/packages/database/src/schema-notification-delivery.test.ts +15 -6
  57. package/template/packages/database/src/schema-workflow-notification.test.ts +44 -29
  58. 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.17",
3
+ "version": "0.3.19",
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": "267cc4a82fec7fa39819029ce6e967f93b4f0463",
4
- "generatorVersion": "0.3.17",
3
+ "templateCommit": "45fdbe8e2a55dff118933af58a60976e9bfe318c",
4
+ "generatorVersion": "0.3.19",
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
 
@@ -72,6 +76,8 @@
72
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`:本次生成器版本、所选模块和选项记录。
@@ -150,24 +150,19 @@ bun run windy update --dry-run
150
150
  bun run windy module list
151
151
  bun run windy module add work-order --dry-run
152
152
  bun run windy module remove work-order --dry-run
153
- bun run windy reconcile --to <精确目标版本>
154
153
  bun run lint
155
154
  bun run format
156
155
  ```
157
156
 
158
157
  提交时 Husky 会通过 lint-staged 与 Oxfmt 格式化暂存文件,不会把未暂存内容带入提交。lint、typecheck 和 test 保留为需要时显式运行的命令。
159
158
 
160
- 更新前必须保持 Git 工作区干净。`--dry-run` 只生成计划,不写项目;正式更新先备份,
161
- 再做 package 字段级合并、共享脚手架三方合并与完整验证。任何冲突都会在写入前中止,
159
+ 更新前无需提交、暂存或清理业务改动。`--dry-run` 只生成计划,不写项目;无关 WIP
160
+ 保持原样,命中同一路径时按冻结快照与三方合并 fail closed。正式更新先备份,再做
161
+ package 字段级合并、共享脚手架三方合并与完整验证。任何冲突都会在写入前中止,
162
162
  无并发变化的验证失败会自动回滚;中断恢复使用
163
163
  `bun run windy doctor --repair`。该命令在提交点前保留并发编辑并回滚其余路径,
164
164
  在 committed 提交点后只完成事务状态与备份清理,不回滚项目文件。
165
165
 
166
- 仅当 Windy 发布说明提供确定性 proof 时使用
167
- `reconcile --to <精确目标版本>`。它默认只演练;显式增加 `--apply` 后也只更新
168
- 受管清单元数据与无正文报告,不修改项目源码或推进 Project Lock。project-owned、
169
- 缺失和未知语义差异始终保守未决;shared-scaffold 只有精确命中已发布 proof 才可对账。
170
-
171
166
  ## 开发约束
172
167
 
173
168
  - 新增路由默认需要登录;只有显式 public 的页面允许匿名访问。
@@ -185,8 +180,8 @@ bun run format
185
180
  create-windy 默认建立 `main` 分支并创建 `chore: initialize project` 首次提交。若当时
186
181
  没有配置 Git 用户身份,文件会保留在暂存区;配置身份后手动提交即可。
187
182
 
188
- 当前已交付的 Windy 更新器会根据 Project Lock 和文件所有权,从 npm 历史 artifact
189
- 重建 Base 并执行三方合并;它不连接私有源仓库,也不会静默覆盖项目代码。模块
183
+ 当前已交付的 Windy 更新器会根据 Project Lock 和文件所有权,从 Project Lock 指向的
184
+ 精确 npm artifact 重建 Base 并执行三方合并;它不连接私有源仓库,也不会静默覆盖项目代码。模块
190
185
  生命周期命令复用同一计划、事务、验证与恢复边界;卸载默认保留数据库数据,并要求
191
186
  显式确认数据处置计划。
192
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,348 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import uuid
7
+ from collections.abc import AsyncIterator
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, cast
11
+
12
+ import httpx
13
+
14
+ from .config import normalize_base_url
15
+
16
+ MAX_QUESTION_LENGTH = 4_000
17
+ MAX_VISIBLE_EVENTS = 64
18
+ MAX_EVENT_FRAME_BYTES = 64 * 1024
19
+ DEFAULT_RESULT_POLL_TIMEOUT_SECONDS = 60.0
20
+ DEFAULT_RESULT_POLL_INTERVAL_SECONDS = 0.5
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class TextOperationDebugConfig:
25
+ operation_key: str
26
+ data_actor_id: str | None = None
27
+
28
+
29
+ def load_text_operation_debug_config(
30
+ path: str | Path | None = None,
31
+ ) -> TextOperationDebugConfig | None:
32
+ if path is None:
33
+ environment_key = os.environ.get("ADK_DEBUG_OPERATION_KEY", "").strip()
34
+ if environment_key:
35
+ return TextOperationDebugConfig(
36
+ operation_key=validate_operation_key(environment_key)
37
+ )
38
+ config_path = Path(path) if path else project_debug_config_path()
39
+ try:
40
+ value = json.loads(config_path.read_text(encoding="utf-8"))
41
+ except FileNotFoundError:
42
+ return None
43
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
44
+ raise RuntimeError("ADK Web 项目调试配置不可读") from error
45
+ if not isinstance(value, dict) or set(value) != {
46
+ "schemaVersion",
47
+ "textOperation",
48
+ }:
49
+ raise RuntimeError("ADK Web 项目调试配置格式无效")
50
+ schema_version = value.get("schemaVersion")
51
+ if (
52
+ not isinstance(schema_version, int)
53
+ or isinstance(schema_version, bool)
54
+ or schema_version != 1
55
+ ):
56
+ raise RuntimeError("ADK Web 项目调试配置版本不受支持")
57
+ operation = value.get("textOperation")
58
+ if operation is None:
59
+ return None
60
+ if not isinstance(operation, dict) or set(operation) != {
61
+ "key",
62
+ "dataActorId",
63
+ }:
64
+ raise RuntimeError("ADK Web 文本 Operation 配置格式无效")
65
+ return TextOperationDebugConfig(
66
+ operation_key=validate_identifier(
67
+ operation.get("key"), "ADK Web 文本 Operation Key 无效"
68
+ ),
69
+ data_actor_id=validate_identifier(
70
+ operation.get("dataActorId"), "ADK Web 真实开发数据用户无效"
71
+ ),
72
+ )
73
+
74
+
75
+ def validate_operation_key(value: object) -> str:
76
+ return validate_identifier(value, "ADK Web 文本 Operation Key 无效")
77
+
78
+
79
+ def validate_identifier(value: object, message: str) -> str:
80
+ if (
81
+ not isinstance(value, str)
82
+ or not value.strip()
83
+ or len(value) > 200
84
+ or any(character.isspace() for character in value)
85
+ ):
86
+ raise RuntimeError(message)
87
+ return value
88
+
89
+
90
+ def project_debug_config_path() -> Path:
91
+ configured = os.environ.get("ADK_DEBUG_PROJECT_CONFIG", "").strip()
92
+ if configured:
93
+ return Path(configured)
94
+ return Path(__file__).resolve().parents[2] / "project" / "adk-web-debug.json"
95
+
96
+
97
+ class CanonicalRunDebugClient:
98
+ def __init__(
99
+ self,
100
+ operation_key: str,
101
+ base_url: str,
102
+ bearer_token: str,
103
+ *,
104
+ transport: httpx.AsyncBaseTransport | None = None,
105
+ result_poll_timeout_seconds: float = DEFAULT_RESULT_POLL_TIMEOUT_SECONDS,
106
+ result_poll_interval_seconds: float = DEFAULT_RESULT_POLL_INTERVAL_SECONDS,
107
+ ) -> None:
108
+ self._operation_key = operation_key
109
+ self._base_url = normalize_base_url(base_url)
110
+ self._bearer_token = bearer_token.strip()
111
+ self._transport = transport
112
+ if not 0 <= result_poll_timeout_seconds <= 120:
113
+ raise ValueError("result_poll_timeout_seconds 必须在 0 到 120 秒之间")
114
+ if not 0 < result_poll_interval_seconds <= 1:
115
+ raise ValueError("result_poll_interval_seconds 必须大于 0 且不超过 1 秒")
116
+ self._result_poll_timeout_seconds = result_poll_timeout_seconds
117
+ self._result_poll_interval_seconds = result_poll_interval_seconds
118
+ if not self._bearer_token:
119
+ raise RuntimeError("ADK_DEBUG_PLATFORM_TOKEN 未配置,拒绝启动真实调试 Run")
120
+
121
+ @classmethod
122
+ def from_environment(
123
+ cls,
124
+ operation_key: str,
125
+ ) -> CanonicalRunDebugClient:
126
+ return cls(
127
+ operation_key,
128
+ os.environ.get("ADK_DEBUG_PLATFORM_URL", "http://server:9747"),
129
+ os.environ.get("ADK_DEBUG_PLATFORM_TOKEN", ""),
130
+ )
131
+
132
+ async def run(self, question: str) -> dict[str, object]:
133
+ prompt = question.strip()
134
+ if not prompt or len(prompt) > MAX_QUESTION_LENGTH:
135
+ raise RuntimeError("调试问题不能为空且最长 4000 字")
136
+ headers = {
137
+ "authorization": f"Bearer {self._bearer_token}",
138
+ "idempotency-key": f"adk-web-{uuid.uuid4()}",
139
+ "user-agent": "Windy-ADK-Web-Dev",
140
+ }
141
+ async with httpx.AsyncClient(
142
+ base_url=f"{self._base_url}/",
143
+ headers=headers,
144
+ timeout=httpx.Timeout(120),
145
+ transport=self._transport,
146
+ ) as client:
147
+ started = await client.post(
148
+ "api/agent/runs",
149
+ json={
150
+ "operationKey": self._operation_key,
151
+ "input": {"question": prompt},
152
+ },
153
+ )
154
+ run_id = read_run_id(started)
155
+ events = await collect_compact_events(self._events(client, run_id))
156
+ try:
157
+ async with asyncio.timeout(self._result_poll_timeout_seconds):
158
+ while True:
159
+ result = await client.get(
160
+ f"api/agent/runs/{run_id}/result",
161
+ params={"operationKey": self._operation_key},
162
+ )
163
+ if result.is_success:
164
+ break
165
+ try:
166
+ error = result.json()
167
+ except json.JSONDecodeError:
168
+ error = None
169
+ if (
170
+ result.status_code != 409
171
+ or not isinstance(error, dict)
172
+ or error.get("code") != "RUN_CONFLICT"
173
+ or error.get("retryable") is not True
174
+ ):
175
+ raise RuntimeError("平台 Agent Run 永久失败或结果读取失败")
176
+ await asyncio.sleep(self._result_poll_interval_seconds)
177
+ except TimeoutError as error:
178
+ raise RuntimeError("平台 Agent Run 结果等待超时") from error
179
+ answer = read_text_output(result, run_id)
180
+ if not any(event.get("type") == "run.completed" for event in events):
181
+ events = [*events[: MAX_VISIBLE_EVENTS - 1], {"type": "run.completed"}]
182
+ return {
183
+ "runId": run_id,
184
+ "answer": answer,
185
+ "events": events,
186
+ }
187
+
188
+ async def _events(
189
+ self,
190
+ client: httpx.AsyncClient,
191
+ run_id: str,
192
+ ) -> AsyncIterator[dict[str, object]]:
193
+ async with client.stream(
194
+ "GET",
195
+ f"api/agent/runs/{run_id}/events",
196
+ params={"operationKey": self._operation_key, "after": 0},
197
+ ) as response:
198
+ if not response.is_success:
199
+ raise RuntimeError("平台 Agent Run 事件读取失败")
200
+ async for event in sse_events(response):
201
+ yield summarize_event(event)
202
+
203
+
204
+ async def sse_events(response: httpx.Response) -> AsyncIterator[dict[str, object]]:
205
+ buffer = bytearray()
206
+ async for chunk in response.aiter_bytes(chunk_size=4096):
207
+ buffer.extend(chunk)
208
+ while separator := frame_separator(buffer):
209
+ index, width = separator
210
+ if index > MAX_EVENT_FRAME_BYTES:
211
+ raise RuntimeError("平台 Agent Run 单个事件超过调试上限")
212
+ frame = bytes(buffer[:index])
213
+ del buffer[: index + width]
214
+ event = parse_sse_frame(frame)
215
+ if event is not None:
216
+ yield event
217
+ if len(buffer) > MAX_EVENT_FRAME_BYTES:
218
+ raise RuntimeError("平台 Agent Run 单个事件超过调试上限")
219
+ if buffer:
220
+ event = parse_sse_frame(bytes(buffer))
221
+ if event is not None:
222
+ yield event
223
+
224
+
225
+ def frame_separator(buffer: bytearray) -> tuple[int, int] | None:
226
+ candidates = [
227
+ (index, len(separator))
228
+ for separator in (b"\n\n", b"\r\n\r\n")
229
+ if (index := buffer.find(separator)) >= 0
230
+ ]
231
+ return min(candidates) if candidates else None
232
+
233
+
234
+ def parse_sse_frame(frame: bytes) -> dict[str, object] | None:
235
+ try:
236
+ lines = frame.decode("utf-8").splitlines()
237
+ except UnicodeDecodeError as error:
238
+ raise RuntimeError("平台 Agent Run 事件格式无效") from error
239
+ event_name = "message"
240
+ data: list[str] = []
241
+ for line in lines:
242
+ if not line or line.startswith(":"):
243
+ continue
244
+ if line.startswith("event:"):
245
+ event_name = line.removeprefix("event:").strip()
246
+ elif line.startswith("data:"):
247
+ value = line.removeprefix("data:")
248
+ data.append(value[1:] if value.startswith(" ") else value)
249
+ return parse_event(event_name, "\n".join(data)) if data else None
250
+
251
+
252
+ def parse_event(event_name: str, data: str) -> dict[str, object]:
253
+ try:
254
+ value = json.loads(data)
255
+ except json.JSONDecodeError as error:
256
+ raise RuntimeError("平台 Agent Run 事件格式无效") from error
257
+ if not isinstance(value, dict) or value.get("type") != event_name:
258
+ raise RuntimeError("平台 Agent Run 事件格式无效")
259
+ return cast(dict[str, object], value)
260
+
261
+
262
+ def summarize_event(event: dict[str, object]) -> dict[str, object]:
263
+ fields = ("sequence", "type", "toolKey", "toolCallId", "workItemId", "outcome")
264
+ return {field: event[field] for field in fields if field in event}
265
+
266
+
267
+ def compact_events(events: list[dict[str, object]]) -> list[dict[str, object]]:
268
+ compactor = EventCompactor()
269
+ for event in events:
270
+ compactor.add(event)
271
+ return compactor.finish()
272
+
273
+
274
+ async def collect_compact_events(
275
+ events: AsyncIterator[dict[str, object]],
276
+ ) -> list[dict[str, object]]:
277
+ compactor = EventCompactor()
278
+ async for event in events:
279
+ compactor.add(event)
280
+ return compactor.finish()
281
+
282
+
283
+ @dataclass
284
+ class EventCompactor:
285
+ visible: list[dict[str, object]] | None = None
286
+ output_deltas: int = 0
287
+ hidden_events: int = 0
288
+
289
+ def add(self, event: dict[str, object]) -> None:
290
+ if event.get("type") == "output.delta":
291
+ self.output_deltas += 1
292
+ return
293
+ if self.visible is None:
294
+ self.visible = []
295
+ if len(self.visible) < MAX_VISIBLE_EVENTS:
296
+ self.visible.append(event)
297
+ else:
298
+ self.hidden_events += 1
299
+
300
+ def finish(self) -> list[dict[str, object]]:
301
+ visible = list(self.visible or [])
302
+ while True:
303
+ trailers = self._trailers()
304
+ if len(visible) + len(trailers) <= MAX_VISIBLE_EVENTS:
305
+ return [*visible, *trailers]
306
+ visible.pop()
307
+ self.hidden_events += 1
308
+
309
+ def _trailers(self) -> list[dict[str, object]]:
310
+ trailers: list[dict[str, object]] = []
311
+ if self.output_deltas:
312
+ trailers.append({"type": "output.delta", "count": self.output_deltas})
313
+ if self.hidden_events:
314
+ trailers.append({"type": "events.truncated", "count": self.hidden_events})
315
+ return trailers
316
+
317
+
318
+ def read_run_id(response: httpx.Response) -> str:
319
+ value = response_json(response, "平台 Agent Run 启动失败")
320
+ run_id = value.get("runId")
321
+ if not isinstance(run_id, str) or not run_id:
322
+ raise RuntimeError("平台 Agent Run 启动响应无效")
323
+ return run_id
324
+
325
+
326
+ def read_text_output(response: httpx.Response, expected_run_id: str) -> str:
327
+ value = response_json(response, "平台 Agent Run 未完成")
328
+ if value.get("runId") != expected_run_id or value.get("status") != "completed":
329
+ raise RuntimeError("平台 Agent Run 结果身份或状态无效")
330
+ output = value.get("output")
331
+ if not isinstance(output, dict):
332
+ raise RuntimeError("平台 Agent Run 输出不符合文本调试契约")
333
+ text = output.get("text")
334
+ if not isinstance(text, str):
335
+ raise RuntimeError("平台 Agent Run 输出不符合文本调试契约")
336
+ return text
337
+
338
+
339
+ def response_json(response: httpx.Response, message: str) -> dict[str, Any]:
340
+ if not response.is_success:
341
+ raise RuntimeError(message)
342
+ try:
343
+ value = response.json()
344
+ except json.JSONDecodeError as error:
345
+ raise RuntimeError(message) from error
346
+ if not isinstance(value, dict):
347
+ raise RuntimeError(message)
348
+ return cast(dict[str, Any], value)