create-windy 0.4.0 → 0.5.0

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 (194) hide show
  1. package/dist/cli.js +263 -121
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/agent-server/adk_web_agents/canonical_debug/agent.py +29 -9
  5. package/template/apps/agent-server/adk_web_agents/services.py +25 -0
  6. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +40 -11
  7. package/template/apps/agent-server/src/southwind_agent_server/adk_debug_sessions.py +325 -0
  8. package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +70 -6
  9. package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +1 -1
  10. package/template/apps/agent-server/src/southwind_agent_server/app.py +16 -4
  11. package/template/apps/agent-server/src/southwind_agent_server/approval_resolution.py +30 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +9 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +2 -1
  14. package/template/apps/agent-server/src/southwind_agent_server/engine.py +3 -1
  15. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +95 -130
  16. package/template/apps/agent-server/src/southwind_agent_server/openai_engine_validation.py +133 -0
  17. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +89 -20
  18. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_insert.py +34 -0
  19. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_queries.py +234 -0
  20. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_replay.py +44 -0
  21. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_status.py +83 -0
  22. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_store.py +331 -0
  23. package/template/apps/agent-server/src/southwind_agent_server/postgres_report_summary.py +304 -0
  24. package/template/apps/agent-server/src/southwind_agent_server/report_lifecycle.py +217 -0
  25. package/template/apps/agent-server/src/southwind_agent_server/report_metrics.py +149 -0
  26. package/template/apps/agent-server/src/southwind_agent_server/report_presenter.py +280 -0
  27. package/template/apps/agent-server/src/southwind_agent_server/report_routes.py +126 -0
  28. package/template/apps/agent-server/src/southwind_agent_server/report_store.py +344 -0
  29. package/template/apps/agent-server/src/southwind_agent_server/report_types.py +111 -0
  30. package/template/apps/agent-server/src/southwind_agent_server/runtime_factory.py +31 -2
  31. package/template/apps/agent-server/src/southwind_agent_server/service.py +56 -51
  32. package/template/apps/agent-server/src/southwind_agent_server/signal_events.py +18 -1
  33. package/template/apps/agent-server/tests/test_adk_adapter.py +7 -9
  34. package/template/apps/agent-server/tests/test_adk_debug_sessions.py +215 -0
  35. package/template/apps/agent-server/tests/test_adk_web_canonical_debug.py +48 -1
  36. package/template/apps/agent-server/tests/test_openai_provider.py +58 -6
  37. package/template/apps/agent-server/tests/test_postgres_report_status.py +59 -0
  38. package/template/apps/agent-server/tests/test_postgres_report_summary.py +298 -0
  39. package/template/apps/agent-server/tests/test_provider_attempt_reporting.py +152 -0
  40. package/template/apps/agent-server/tests/test_report_attempt_lifecycle.py +126 -0
  41. package/template/apps/agent-server/tests/test_report_resilience.py +181 -0
  42. package/template/apps/agent-server/tests/test_report_store.py +286 -0
  43. package/template/apps/agent-server/tests/test_report_summary_parity.py +115 -0
  44. package/template/apps/agent-server/tests/test_report_terminal_sync.py +177 -0
  45. package/template/apps/server/src/agent/debug-session-facade-provider-failure.test.ts +269 -0
  46. package/template/apps/server/src/agent/debug-session-facade.test.ts +335 -0
  47. package/template/apps/server/src/agent/debug-session-facade.ts +302 -0
  48. package/template/apps/server/src/agent/debug-session-id.test.ts +37 -0
  49. package/template/apps/server/src/agent/debug-session-id.ts +37 -0
  50. package/template/apps/server/src/agent/debug-session-provider-reader.ts +54 -0
  51. package/template/apps/server/src/agent/debug-session-routes.test.ts +64 -0
  52. package/template/apps/server/src/agent/debug-session-routes.ts +73 -0
  53. package/template/apps/server/src/agent/debug-session-validation.ts +106 -0
  54. package/template/apps/server/src/agent/drizzle-run-binding-repository.ts +2 -0
  55. package/template/apps/server/src/agent/provider-input-governance.test.ts +18 -0
  56. package/template/apps/server/src/agent/provider-input-governance.ts +37 -1
  57. package/template/apps/server/src/agent/remote-runtime.test.ts +7 -0
  58. package/template/apps/server/src/agent/remote-runtime.ts +1 -0
  59. package/template/apps/server/src/agent/run-authorization.test.ts +98 -0
  60. package/template/apps/server/src/agent/run-authorization.ts +32 -1
  61. package/template/apps/server/src/agent/run-binding-repository.ts +1 -0
  62. package/template/apps/server/src/agent/run-contracts.ts +5 -0
  63. package/template/apps/server/src/agent/run-facade-adk-debug.test.ts +39 -0
  64. package/template/apps/server/src/agent/run-facade.test.ts +78 -0
  65. package/template/apps/server/src/agent/run-facade.ts +33 -3
  66. package/template/apps/server/src/agent/run-input-commitment.ts +50 -0
  67. package/template/apps/server/src/agent/run-input-verification.ts +41 -0
  68. package/template/apps/server/src/agent/runtime-bootstrap-report.test.ts +136 -0
  69. package/template/apps/server/src/agent/runtime-bootstrap.ts +66 -1
  70. package/template/apps/server/src/agent/tool-host.test.ts +1 -0
  71. package/template/apps/server/src/agent/unavailable-routes.ts +4 -0
  72. package/template/apps/server/src/ai-runtime-report/actor-directory.ts +94 -0
  73. package/template/apps/server/src/ai-runtime-report/actor-handle.test.ts +27 -0
  74. package/template/apps/server/src/ai-runtime-report/actor-handle.ts +81 -0
  75. package/template/apps/server/src/ai-runtime-report/contracts.ts +91 -0
  76. package/template/apps/server/src/ai-runtime-report/remote-parser.ts +212 -0
  77. package/template/apps/server/src/ai-runtime-report/remote-port.ts +138 -0
  78. package/template/apps/server/src/ai-runtime-report/routes.ts +172 -0
  79. package/template/apps/server/src/ai-runtime-report/scope.ts +63 -0
  80. package/template/apps/server/src/ai-runtime-report/service.test.ts +271 -0
  81. package/template/apps/server/src/ai-runtime-report/service.ts +206 -0
  82. package/template/apps/server/src/license/runtime-audit-reliability.test.ts +2 -0
  83. package/template/apps/server/src/license/runtime-service.test.ts +6 -0
  84. package/template/apps/server/src/module-host/ai-operation-port.test.ts +63 -0
  85. package/template/apps/server/src/module-host/ai-operation-port.ts +30 -5
  86. package/template/apps/server/src/module-host/debug-session-provider-registration.test.ts +162 -0
  87. package/template/apps/server/src/module-host/debug-session-provider-registration.ts +84 -0
  88. package/template/apps/server/src/module-host/initialize-contracts.ts +7 -0
  89. package/template/apps/server/src/module-host/initialize.ts +6 -197
  90. package/template/apps/server/src/module-host/request-context.ts +1 -0
  91. package/template/apps/server/src/module-host/scoped-host.ts +214 -0
  92. package/template/apps/server/src/module-host.ts +1 -0
  93. package/template/apps/server/src/runtime-adk-debug.test.ts +25 -1
  94. package/template/apps/server/src/runtime-adk-debug.ts +8 -2
  95. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
  96. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +11 -3
  97. package/template/apps/server/src/settings/watermark-routes.test.ts +54 -1
  98. package/template/apps/server/src/settings/watermark-routes.ts +29 -1
  99. package/template/apps/server/src/system/operations.test.ts +54 -0
  100. package/template/apps/server/src/system/operations.ts +23 -1
  101. package/template/apps/server/src/system/seed.test.ts +4 -0
  102. package/template/apps/server/src/system/system-resource-monitoring.test.ts +82 -0
  103. package/template/apps/server/src/system/system-resource-monitoring.ts +300 -0
  104. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
  105. package/template/apps/web/src/composables/watermark-settings.ts +40 -11
  106. package/template/apps/web/src/composables/watermark-settings.webtest.ts +19 -6
  107. package/template/apps/web/src/layout/AppSidebar.vue +6 -1
  108. package/template/apps/web/src/layout/AppSidebar.webtest.ts +2 -0
  109. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +2 -0
  110. package/template/apps/web/src/layout/GlobalWatermark.vue +4 -8
  111. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +12 -5
  112. package/template/apps/web/src/pages/auth/LoginPage.vue +6 -1
  113. package/template/apps/web/src/pages/settings/BrandingSettingsSection.vue +20 -3
  114. package/template/apps/web/src/pages/settings/BrandingSettingsSection.webtest.ts +80 -0
  115. package/template/apps/web/src/pages/settings/WatermarkPreview.vue +72 -0
  116. package/template/apps/web/src/pages/settings/WatermarkPreview.webtest.ts +55 -0
  117. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +80 -24
  118. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.webtest.ts +115 -0
  119. package/template/apps/web/src/pages/system/AiRuntimePage.vue +129 -0
  120. package/template/apps/web/src/pages/system/SystemOperationsPage.vue +2 -1
  121. package/template/apps/web/src/pages/system/ai-runtime-presenter.ts +60 -0
  122. package/template/apps/web/src/pages/system/ai-runtime-presenter.webtest.ts +48 -0
  123. package/template/apps/web/src/pages/system/audit-time-range.ts +34 -2
  124. package/template/apps/web/src/pages/system/components/AuditLogFilters.vue +4 -3
  125. package/template/apps/web/src/pages/system/components/AuditLogFilters.webtest.ts +26 -1
  126. package/template/apps/web/src/pages/system/components/OperationGpuMonitor.vue +98 -0
  127. package/template/apps/web/src/pages/system/components/OperationGpuMonitor.webtest.ts +61 -0
  128. package/template/apps/web/src/pages/system/components/OperationServiceMonitor.vue +53 -0
  129. package/template/apps/web/src/pages/system/components/OperationServiceMonitor.webtest.ts +20 -1
  130. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +14 -1
  131. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeActivityChart.vue +85 -0
  132. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeCharts.webtest.ts +82 -0
  133. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.vue +98 -0
  134. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.webtest.ts +27 -0
  135. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunDetailDialog.vue +168 -0
  136. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunTable.vue +130 -0
  137. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeSummaryCards.vue +82 -0
  138. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeSummaryCards.webtest.ts +102 -0
  139. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeTokenByModelChart.vue +101 -0
  140. package/template/apps/web/src/pages/system/composables/useAiRuntimeReport.ts +185 -0
  141. package/template/apps/web/src/pages/system/composables/useAiRuntimeReport.webtest.ts +158 -0
  142. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +21 -7
  143. package/template/apps/web/src/router/error-navigation.ts +22 -0
  144. package/template/apps/web/src/router/index.ts +12 -23
  145. package/template/apps/web/src/router/platform-admin-route-bindings.ts +4 -0
  146. package/template/apps/web/src/services/ai-runtime-api.ts +164 -0
  147. package/template/apps/web/src/services/ai-runtime-api.webtest.ts +111 -0
  148. package/template/apps/web/src/services/operations-api.ts +28 -0
  149. package/template/docker-compose.agent-debug.yml +1 -1
  150. package/template/docs/architecture/ai-runtime.md +2 -1
  151. package/template/docs/architecture/module-host.md +3 -0
  152. package/template/docs/platform/agent-run-attestation.md +65 -0
  153. package/template/docs/platform/agent-runtime-observability.md +135 -0
  154. package/template/docs/platform/agent-runtime.md +47 -3
  155. package/template/docs/platform/operations-overview.md +13 -0
  156. package/template/docs/platform/platform-authentication.md +4 -3
  157. package/template/docs/platform/platform-shell-settings.md +15 -10
  158. package/template/docs/platform/web-system-crud.md +1 -1
  159. package/template/packages/config/package.json +1 -1
  160. package/template/packages/database/drizzle/0044_sweet_tarantula.sql +6 -0
  161. package/template/packages/database/drizzle/0045_tiny_cassandra_nova.sql +65 -0
  162. package/template/packages/database/drizzle/0046_tranquil_living_mummy.sql +6 -0
  163. package/template/packages/database/drizzle/0047_oval_luke_cage.sql +18 -0
  164. package/template/packages/database/drizzle/meta/0044_snapshot.json +8349 -0
  165. package/template/packages/database/drizzle/meta/0045_snapshot.json +8814 -0
  166. package/template/packages/database/drizzle/meta/0046_snapshot.json +8820 -0
  167. package/template/packages/database/drizzle/meta/0047_snapshot.json +8835 -0
  168. package/template/packages/database/drizzle/meta/_journal.json +28 -0
  169. package/template/packages/database/package.json +1 -1
  170. package/template/packages/database/src/agent-runtime-schema.test.ts +54 -0
  171. package/template/packages/database/src/schema/agent-runtime.ts +114 -0
  172. package/template/packages/database/src/schema/platform-settings.ts +3 -0
  173. package/template/packages/jobs/package.json +1 -1
  174. package/template/packages/modules/package.json +1 -1
  175. package/template/packages/modules/src/system-admin-routes.ts +10 -0
  176. package/template/packages/modules/src/system-admin-ui-catalog.ts +1 -1
  177. package/template/packages/modules/src/system-api-control-plane.ts +17 -1
  178. package/template/packages/modules/src/system-audit-actions.ts +10 -0
  179. package/template/packages/modules/src/system-features.ts +8 -0
  180. package/template/packages/modules/src/system-module-catalog.test.ts +35 -0
  181. package/template/packages/modules/src/system-module-catalog.ts +15 -4
  182. package/template/packages/modules/src/system-modules-access.test.ts +69 -0
  183. package/template/packages/modules/src/system-modules.ts +13 -0
  184. package/template/packages/modules/src/system-permissions.ts +2 -0
  185. package/template/packages/server-sdk/index.ts +11 -0
  186. package/template/packages/server-sdk/package.json +2 -1
  187. package/template/packages/server-sdk/src/ai-operation-port.ts +5 -0
  188. package/template/packages/server-sdk/src/debug-session-provider.ts +63 -0
  189. package/template/packages/server-sdk/src/module-host.ts +5 -0
  190. package/template/packages/shared/index.ts +3 -0
  191. package/template/packages/shared/package.json +1 -1
  192. package/template/packages/shared/src/ai-runtime-report.ts +134 -0
  193. package/template/packages/shared/src/platform-settings.ts +17 -0
  194. package/template/scripts/lib/postgres-regression.ts +201 -0
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+
6
+ from .contracts import JsonValue
7
+ from .engine import EngineRunRequest, SafeEngineError
8
+ from .openai_provider import ProviderToolCall
9
+ from .report_types import AttemptTerminalOutcome
10
+
11
+
12
+ def validate_runtime_request(
13
+ request: EngineRunRequest,
14
+ provider_deployment_key: str,
15
+ provider_capabilities: tuple[str, ...],
16
+ provider_input_modalities: tuple[str, ...],
17
+ ) -> None:
18
+ if (
19
+ not request.instructions.strip()
20
+ or not request.user_prompt.strip()
21
+ or not request.execution_grant.strip()
22
+ or not request.tool_host_url.strip()
23
+ ):
24
+ raise SafeEngineError(
25
+ "INVALID_INPUT",
26
+ "Agent Operation 运行计划不完整",
27
+ retryable=False,
28
+ )
29
+ if set(request.required_capabilities) - set(provider_capabilities):
30
+ raise SafeEngineError(
31
+ "MODEL_ROUTE_UNAVAILABLE",
32
+ "模型 Deployment 缺少 Operation 所需能力",
33
+ retryable=True,
34
+ )
35
+ if set(request.input_modalities) - set(provider_input_modalities):
36
+ raise SafeEngineError(
37
+ "MODEL_ROUTE_UNAVAILABLE",
38
+ "模型 Deployment 不支持 Operation 输入模态",
39
+ retryable=True,
40
+ )
41
+ if (
42
+ request.provider_egress.mode != "configured-endpoint"
43
+ or request.provider_egress.deployment_key != provider_deployment_key
44
+ ):
45
+ raise SafeEngineError(
46
+ "FORBIDDEN",
47
+ "Agent Operation 未授权当前模型部署出网",
48
+ retryable=False,
49
+ )
50
+ if request.tool_network_policy.mode != "forbidden":
51
+ raise SafeEngineError(
52
+ "TOOL_DENIED",
53
+ "Agent Tool 网络策略不受支持",
54
+ retryable=False,
55
+ )
56
+ if (
57
+ not request.provider_data_policy.input_fields
58
+ or not request.provider_data_policy.envelope_fields
59
+ ):
60
+ raise SafeEngineError(
61
+ "FORBIDDEN",
62
+ "Agent Operation 缺少 Provider 字段治理策略",
63
+ retryable=False,
64
+ )
65
+
66
+
67
+ def cancelled(event: asyncio.Event) -> None:
68
+ if event.is_set():
69
+ raise SafeEngineError(
70
+ "RUN_CANCELLED",
71
+ "Agent Run 已取消",
72
+ retryable=False,
73
+ )
74
+
75
+
76
+ def parse_arguments(raw: str) -> dict[str, JsonValue]:
77
+ try:
78
+ value = json.loads(raw or "{}")
79
+ except json.JSONDecodeError:
80
+ raise SafeEngineError(
81
+ "TOOL_DENIED",
82
+ "Agent Tool 参数无效",
83
+ retryable=False,
84
+ ) from None
85
+ if not isinstance(value, dict):
86
+ raise SafeEngineError(
87
+ "TOOL_DENIED",
88
+ "Agent Tool 参数必须是对象",
89
+ retryable=False,
90
+ )
91
+ return value
92
+
93
+
94
+ def assistant_tool_message(
95
+ calls: list[ProviderToolCall],
96
+ ) -> dict[str, JsonValue]:
97
+ return {
98
+ "role": "assistant",
99
+ "content": None,
100
+ "tool_calls": [
101
+ {
102
+ "id": call.call_id,
103
+ "type": "function",
104
+ "function": {
105
+ "name": call.name,
106
+ "arguments": call.arguments,
107
+ },
108
+ }
109
+ for call in calls
110
+ ],
111
+ }
112
+
113
+
114
+ def validate_provider_tool_calls(
115
+ calls: list[ProviderToolCall],
116
+ allowed: set[str],
117
+ ) -> None:
118
+ for call in calls:
119
+ if not call.call_id or not call.name or call.name not in allowed:
120
+ raise SafeEngineError(
121
+ "TOOL_DENIED",
122
+ "模型请求了未授权 Tool",
123
+ retryable=False,
124
+ )
125
+ parse_arguments(call.arguments)
126
+
127
+
128
+ def attempt_outcome(error: SafeEngineError) -> AttemptTerminalOutcome:
129
+ if error.code == "RUN_CANCELLED":
130
+ return "cancelled"
131
+ if error.code in {"FORBIDDEN", "INVALID_INPUT", "TOOL_DENIED"}:
132
+ return "blocked"
133
+ return "failed"
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- from dataclasses import dataclass, field
4
+ import time
5
+ from dataclasses import dataclass, field, replace
5
6
  from typing import cast
6
7
 
7
8
  import httpx
@@ -13,6 +14,7 @@ from .config import (
13
14
  from .contracts import AgentErrorCode, JsonValue, RuntimeToolDefinition
14
15
  from .engine import SafeEngineError
15
16
  from .multimodal import response_format
17
+ from .report_types import UsageFacts
16
18
 
17
19
 
18
20
  @dataclass
@@ -26,7 +28,8 @@ class ProviderToolCall:
26
28
  class ProviderTurn:
27
29
  text_deltas: list[str] = field(default_factory=list)
28
30
  tool_calls: list[ProviderToolCall] = field(default_factory=list)
29
- usage: dict[str, JsonValue] | None = None
31
+ usage: UsageFacts = field(default_factory=UsageFacts)
32
+ reported_model: str | None = None
30
33
 
31
34
  @property
32
35
  def text(self) -> str:
@@ -69,6 +72,7 @@ class OpenAiCompatibleProvider:
69
72
  output_schema,
70
73
  )
71
74
  turn = ProviderTurn()
75
+ started_at = time.perf_counter()
72
76
  async with self._client() as client:
73
77
  try:
74
78
  async with client.stream(
@@ -80,7 +84,7 @@ class OpenAiCompatibleProvider:
80
84
  if not response.is_success:
81
85
  await self._bounded_stream(response)
82
86
  raise provider_status_failure(response.status_code)
83
- await self._consume_stream(response, turn)
87
+ await self._consume_stream(response, turn, started_at)
84
88
  except SafeEngineError:
85
89
  raise
86
90
  except httpx.HTTPError as error:
@@ -135,6 +139,7 @@ class OpenAiCompatibleProvider:
135
139
  self,
136
140
  response: httpx.Response,
137
141
  turn: ProviderTurn,
142
+ started_at: float,
138
143
  ) -> None:
139
144
  consumed = 0
140
145
  async for line in response.aiter_lines():
@@ -152,7 +157,7 @@ class OpenAiCompatibleProvider:
152
157
  continue
153
158
  try:
154
159
  chunk = json.loads(data)
155
- consume_chunk(chunk, turn)
160
+ consume_chunk(chunk, turn, started_at)
156
161
  except (KeyError, TypeError, ValueError, json.JSONDecodeError):
157
162
  raise SafeEngineError(
158
163
  "PROVIDER_UNAVAILABLE",
@@ -179,12 +184,23 @@ class OpenAiCompatibleProvider:
179
184
  return body
180
185
 
181
186
 
182
- def consume_chunk(value: object, turn: ProviderTurn) -> None:
187
+ def consume_chunk(
188
+ value: object,
189
+ turn: ProviderTurn,
190
+ started_at: float | None = None,
191
+ ) -> None:
183
192
  if not isinstance(value, dict):
184
193
  raise TypeError
194
+ reported_model = value.get("model")
195
+ if isinstance(reported_model, str) and reported_model:
196
+ turn.reported_model = reported_model
185
197
  usage = value.get("usage")
186
198
  if isinstance(usage, dict):
187
- turn.usage = usage_value(usage)
199
+ measured = usage_value(usage)
200
+ turn.usage = replace(
201
+ measured,
202
+ first_token_latency_ms=turn.usage.first_token_latency_ms,
203
+ )
188
204
  choices = value.get("choices")
189
205
  if not isinstance(choices, list) or not choices:
190
206
  return
@@ -196,6 +212,7 @@ def consume_chunk(value: object, turn: ProviderTurn) -> None:
196
212
  return
197
213
  content = delta.get("content")
198
214
  if isinstance(content, str) and content:
215
+ observe_first_token(turn, started_at)
199
216
  turn.text_deltas.append(content)
200
217
  raw_calls = delta.get("tool_calls")
201
218
  if not isinstance(raw_calls, list):
@@ -208,6 +225,7 @@ def consume_chunk(value: object, turn: ProviderTurn) -> None:
208
225
  raise TypeError
209
226
  while len(turn.tool_calls) <= index:
210
227
  turn.tool_calls.append(ProviderToolCall())
228
+ observe_first_token(turn, started_at)
211
229
  call = turn.tool_calls[index]
212
230
  call_id = raw.get("id")
213
231
  if isinstance(call_id, str):
@@ -236,21 +254,72 @@ def tool_schema(tool: RuntimeToolDefinition) -> JsonValue:
236
254
  }
237
255
 
238
256
 
239
- def usage_value(raw: dict[object, object]) -> dict[str, JsonValue]:
240
- values = {
241
- "inputTokens": raw.get("prompt_tokens"),
242
- "outputTokens": raw.get("completion_tokens"),
243
- "totalTokens": raw.get("total_tokens"),
244
- }
245
- measurements = {
246
- key: value
247
- for key, value in values.items()
248
- if isinstance(value, int) and value >= 0
249
- }
257
+ def usage_value(raw: dict[object, object]) -> UsageFacts:
258
+ input_tokens = non_negative_integer(raw.get("prompt_tokens"))
259
+ output_tokens = non_negative_integer(raw.get("completion_tokens"))
260
+ total_tokens = non_negative_integer(raw.get("total_tokens"))
261
+ prompt_details = raw.get("prompt_tokens_details")
262
+ cached_tokens = (
263
+ non_negative_integer(prompt_details.get("cached_tokens"))
264
+ if isinstance(prompt_details, dict)
265
+ else None
266
+ )
267
+ if (
268
+ cached_tokens is not None
269
+ and input_tokens is not None
270
+ and cached_tokens > input_tokens
271
+ ):
272
+ cached_tokens = None
273
+ completion_details = raw.get("completion_tokens_details")
274
+ reasoning_tokens = (
275
+ non_negative_integer(completion_details.get("reasoning_tokens"))
276
+ if isinstance(completion_details, dict)
277
+ else None
278
+ )
279
+ known = any(
280
+ value is not None
281
+ for value in (
282
+ input_tokens,
283
+ output_tokens,
284
+ cached_tokens,
285
+ reasoning_tokens,
286
+ total_tokens,
287
+ )
288
+ )
289
+ return UsageFacts(
290
+ source="provider-reported" if known else "unknown",
291
+ input_tokens=input_tokens,
292
+ output_tokens=output_tokens,
293
+ cached_input_tokens=cached_tokens,
294
+ reasoning_tokens=reasoning_tokens,
295
+ total_tokens=total_tokens,
296
+ cache_status=(
297
+ "unknown"
298
+ if cached_tokens is None
299
+ else "hit"
300
+ if cached_tokens > 0
301
+ else "miss"
302
+ ),
303
+ )
304
+
305
+
306
+ def non_negative_integer(value: object) -> int | None:
250
307
  return (
251
- {"source": "provider-reported", **measurements}
252
- if measurements
253
- else {"source": "unknown"}
308
+ value
309
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0
310
+ else None
311
+ )
312
+
313
+
314
+ def observe_first_token(turn: ProviderTurn, started_at: float | None) -> None:
315
+ if started_at is None or turn.usage.first_token_latency_ms is not None:
316
+ return
317
+ turn.usage = replace(
318
+ turn.usage,
319
+ first_token_latency_ms=max(
320
+ 0,
321
+ round((time.perf_counter() - started_at) * 1_000),
322
+ ),
254
323
  )
255
324
 
256
325
 
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from .contracts import StartRunRequest
4
+ from .report_types import retention_seconds
5
+
6
+ RUN_INSERT_SQL = """
7
+ insert into agent_runtime_report_runs (
8
+ run_id, operation_key, release_id, trace_id,
9
+ actor_id, actor_type, department_id, status,
10
+ created_at, updated_at, expires_at
11
+ ) values (%s, %s, %s, %s, %s, %s, %s, 'accepted', %s, %s, %s)
12
+ on conflict (run_id) do nothing
13
+ """
14
+
15
+
16
+ def run_insert_values(
17
+ run_id: str,
18
+ request: StartRunRequest,
19
+ created_at: float,
20
+ retention_days: int,
21
+ ) -> tuple[object, ...]:
22
+ context = request.reporting
23
+ return (
24
+ run_id,
25
+ request.operation_key,
26
+ request.release.release_id,
27
+ request.trace_id,
28
+ context.actor_id if context else None,
29
+ context.actor_type if context else None,
30
+ context.department_id if context else None,
31
+ created_at,
32
+ created_at,
33
+ created_at + retention_seconds(retention_days),
34
+ )
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ from psycopg import AsyncConnection
6
+ from psycopg_pool import AsyncConnectionPool
7
+
8
+ from .report_presenter import (
9
+ attempt_value,
10
+ attempts_for,
11
+ run_value,
12
+ )
13
+ from .report_types import (
14
+ AttemptOutcome,
15
+ AttemptReport,
16
+ CacheStatus,
17
+ ReportFilter,
18
+ RunReport,
19
+ UsageFacts,
20
+ UsageSource,
21
+ )
22
+
23
+ type DatabaseConnection = AsyncConnection[dict[str, Any]]
24
+ type DatabasePool = AsyncConnectionPool[DatabaseConnection]
25
+
26
+
27
+ async def list_runs(
28
+ pool: DatabasePool,
29
+ query: ReportFilter,
30
+ page: int,
31
+ page_size: int,
32
+ now: float,
33
+ ) -> dict[str, object]:
34
+ clause, parameters = filter_clause(query, now)
35
+ async with pool.connection() as connection:
36
+ count_cursor = await connection.execute(
37
+ f"select count(*) as total from agent_runtime_report_runs {clause}",
38
+ parameters,
39
+ )
40
+ count_row = await count_cursor.fetchone()
41
+ cursor = await connection.execute(
42
+ f"""
43
+ select * from agent_runtime_report_runs {clause}
44
+ order by created_at desc limit %s offset %s
45
+ """,
46
+ (*parameters, page_size, (page - 1) * page_size),
47
+ )
48
+ runs = [run_from_row(row) for row in await cursor.fetchall()]
49
+ attempts = await select_attempts(
50
+ connection,
51
+ [run.run_id for run in runs],
52
+ )
53
+ return {
54
+ "items": [run_value(run, attempts_for(attempts, [run])) for run in runs],
55
+ "total": int(count_row["total"]) if count_row else 0,
56
+ "page": page,
57
+ "pageSize": page_size,
58
+ }
59
+
60
+
61
+ async def get_run(
62
+ pool: DatabasePool,
63
+ run_id: str,
64
+ now: float,
65
+ ) -> dict[str, object] | None:
66
+ async with pool.connection() as connection:
67
+ cursor = await connection.execute(
68
+ """
69
+ select * from agent_runtime_report_runs
70
+ where run_id = %s and expires_at > %s
71
+ """,
72
+ (run_id, now),
73
+ )
74
+ row = await cursor.fetchone()
75
+ if not row:
76
+ return None
77
+ run = run_from_row(row)
78
+ attempts = await select_attempts(connection, [run_id])
79
+ return {
80
+ **run_value(run, attempts),
81
+ "releaseId": run.release_id,
82
+ "traceId": run.trace_id,
83
+ "safeErrorCode": run.safe_error_code,
84
+ "attempts": [attempt_value(item) for item in attempts],
85
+ }
86
+
87
+
88
+ async def select_attempts(
89
+ connection: DatabaseConnection,
90
+ run_ids: list[str],
91
+ ) -> list[AttemptReport]:
92
+ if not run_ids:
93
+ return []
94
+ cursor = await connection.execute(
95
+ """
96
+ select * from agent_runtime_report_attempts
97
+ where run_id = any(%s) order by run_id, sequence
98
+ """,
99
+ (run_ids,),
100
+ )
101
+ return [attempt_from_row(row) for row in await cursor.fetchall()]
102
+
103
+
104
+ def filter_clause(
105
+ query: ReportFilter,
106
+ now: float,
107
+ alias: str = "",
108
+ ) -> tuple[str, tuple[object, ...]]:
109
+ prefix = f"{alias}." if alias else ""
110
+ conditions = [
111
+ f"{prefix}created_at >= %s",
112
+ f"{prefix}created_at <= %s",
113
+ f"{prefix}expires_at > %s",
114
+ ]
115
+ parameters: list[object] = [
116
+ query.from_timestamp,
117
+ query.to_timestamp,
118
+ now,
119
+ ]
120
+ scoped_conditions, scoped_parameters = scope_conditions(
121
+ query,
122
+ prefix,
123
+ )
124
+ conditions.extend(scoped_conditions)
125
+ parameters.extend(scoped_parameters)
126
+ return f"where {' and '.join(conditions)}", tuple(parameters)
127
+
128
+
129
+ def attempt_filter_clause(
130
+ query: ReportFilter,
131
+ now: float,
132
+ run_alias: str = "r",
133
+ attempt_alias: str = "a",
134
+ ) -> tuple[str, tuple[object, ...]]:
135
+ run_prefix = f"{run_alias}." if run_alias else ""
136
+ attempt_prefix = f"{attempt_alias}." if attempt_alias else ""
137
+ conditions = [
138
+ f"{attempt_prefix}started_at >= %s",
139
+ f"{attempt_prefix}started_at <= %s",
140
+ f"{attempt_prefix}finished_at is not null",
141
+ f"{run_prefix}expires_at > %s",
142
+ ]
143
+ parameters: list[object] = [
144
+ query.from_timestamp,
145
+ query.to_timestamp,
146
+ now,
147
+ ]
148
+ scoped_conditions, scoped_parameters = scope_conditions(
149
+ query,
150
+ run_prefix,
151
+ )
152
+ conditions.extend(scoped_conditions)
153
+ parameters.extend(scoped_parameters)
154
+ return f"where {' and '.join(conditions)}", tuple(parameters)
155
+
156
+
157
+ def scope_conditions(
158
+ query: ReportFilter,
159
+ prefix: str,
160
+ ) -> tuple[list[str], list[object]]:
161
+ conditions: list[str] = []
162
+ parameters: list[object] = []
163
+ if query.selected_actor_id is not None:
164
+ conditions.append(f"{prefix}actor_id = %s")
165
+ parameters.append(query.selected_actor_id)
166
+ if query.scope_restricted:
167
+ scopes: list[str] = []
168
+ if query.actor_ids:
169
+ scopes.append(f"{prefix}actor_id = any(%s)")
170
+ parameters.append(list(query.actor_ids))
171
+ if query.department_ids:
172
+ scopes.append(f"{prefix}department_id = any(%s)")
173
+ parameters.append(list(query.department_ids))
174
+ conditions.append(f"({' or '.join(scopes)})" if scopes else "false")
175
+ return conditions, parameters
176
+
177
+
178
+ def run_from_row(row: dict[str, Any]) -> RunReport:
179
+ return RunReport(
180
+ run_id=str(row["run_id"]),
181
+ operation_key=str(row["operation_key"]),
182
+ release_id=str(row["release_id"]),
183
+ trace_id=str(row["trace_id"]),
184
+ actor_id=optional_text(row.get("actor_id")),
185
+ actor_type=optional_text(row.get("actor_type")),
186
+ department_id=optional_text(row.get("department_id")),
187
+ status=cast(Any, str(row["status"])),
188
+ safe_error_code=optional_text(row.get("safe_error_code")),
189
+ started_at=optional_float(row.get("started_at")),
190
+ finished_at=optional_float(row.get("finished_at")),
191
+ duration_ms=optional_int(row.get("duration_ms")),
192
+ created_at=float(row["created_at"]),
193
+ updated_at=float(row["updated_at"]),
194
+ expires_at=float(row["expires_at"]),
195
+ )
196
+
197
+
198
+ def attempt_from_row(row: dict[str, Any]) -> AttemptReport:
199
+ return AttemptReport(
200
+ attempt_id=str(row["attempt_id"]),
201
+ run_id=str(row["run_id"]),
202
+ sequence=int(row["sequence"]),
203
+ tool_round=int(row["tool_round"]),
204
+ deployment_key=str(row["deployment_key"]),
205
+ requested_model=str(row["requested_model"]),
206
+ reported_model=optional_text(row.get("reported_model")),
207
+ outcome=cast(AttemptOutcome, str(row["outcome"])),
208
+ usage=UsageFacts(
209
+ source=cast(UsageSource, str(row["usage_source"])),
210
+ input_tokens=optional_int(row.get("input_tokens")),
211
+ output_tokens=optional_int(row.get("output_tokens")),
212
+ cached_input_tokens=optional_int(row.get("cached_input_tokens")),
213
+ reasoning_tokens=optional_int(row.get("reasoning_tokens")),
214
+ total_tokens=optional_int(row.get("total_tokens")),
215
+ first_token_latency_ms=optional_int(row.get("first_token_latency_ms")),
216
+ cache_status=cast(CacheStatus, str(row["cache_status"])),
217
+ ),
218
+ duration_ms=optional_int(row.get("duration_ms")),
219
+ safe_error_code=optional_text(row.get("safe_error_code")),
220
+ started_at=float(row["started_at"]),
221
+ finished_at=optional_float(row.get("finished_at")),
222
+ )
223
+
224
+
225
+ def optional_text(value: object) -> str | None:
226
+ return str(value) if value is not None else None
227
+
228
+
229
+ def optional_float(value: Any) -> float | None:
230
+ return float(value) if value is not None else None
231
+
232
+
233
+ def optional_int(value: Any) -> int | None:
234
+ return int(value) if value is not None else None
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ from .report_types import (
6
+ AttemptTerminalOutcome,
7
+ CacheStatus,
8
+ UsageFacts,
9
+ UsageSource,
10
+ )
11
+
12
+
13
+ def assert_attempt_terminal_replay(
14
+ current: dict[str, Any],
15
+ outcome: AttemptTerminalOutcome,
16
+ usage: UsageFacts,
17
+ reported_model: str | None,
18
+ safe_error_code: str | None,
19
+ ) -> None:
20
+ current_usage = UsageFacts(
21
+ source=cast(UsageSource, str(current["usage_source"])),
22
+ input_tokens=optional_int(current["input_tokens"]),
23
+ output_tokens=optional_int(current["output_tokens"]),
24
+ cached_input_tokens=optional_int(current["cached_input_tokens"]),
25
+ reasoning_tokens=optional_int(current["reasoning_tokens"]),
26
+ total_tokens=optional_int(current["total_tokens"]),
27
+ first_token_latency_ms=optional_int(current["first_token_latency_ms"]),
28
+ cache_status=cast(CacheStatus, str(current["cache_status"])),
29
+ )
30
+ if (
31
+ current["outcome"] != outcome
32
+ or current_usage != usage
33
+ or optional_text(current["reported_model"]) != reported_model
34
+ or optional_text(current["safe_error_code"]) != safe_error_code
35
+ ):
36
+ raise ValueError("Agent Runtime 报表 Attempt 终态冲突")
37
+
38
+
39
+ def optional_text(value: object) -> str | None:
40
+ return str(value) if value is not None else None
41
+
42
+
43
+ def optional_int(value: Any) -> int | None:
44
+ return int(value) if value is not None else None