create-windy 0.2.20 → 0.2.22

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 +1 -1
  2. package/dist/cli.js +93 -11
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/README.md +1 -0
  6. package/template/apps/agent-server/Dockerfile +20 -0
  7. package/template/apps/agent-server/README.md +46 -0
  8. package/template/apps/agent-server/pyproject.toml +44 -0
  9. package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
  10. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
  11. package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
  15. package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
  17. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
  18. package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
  19. package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
  20. package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
  21. package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
  22. package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
  23. package/template/apps/agent-server/tests/test_api.py +207 -0
  24. package/template/apps/agent-server/tests/test_config.py +56 -0
  25. package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
  26. package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
  27. package/template/apps/agent-server/uv.lock +925 -0
  28. package/template/apps/server/src/agent/execution-grants.ts +89 -0
  29. package/template/apps/server/src/agent/remote-runtime.ts +128 -0
  30. package/template/apps/server/src/agent/run-contracts.ts +43 -0
  31. package/template/apps/server/src/agent/run-facade.test.ts +257 -0
  32. package/template/apps/server/src/agent/run-facade.ts +190 -0
  33. package/template/apps/server/src/agent/run-routes.ts +222 -0
  34. package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
  35. package/template/apps/server/src/agent/tool-host.test.ts +242 -0
  36. package/template/apps/server/src/agent/tool-host.ts +153 -0
  37. package/template/apps/server/src/index.ts +26 -22
  38. package/template/apps/server/src/module-composition.test.ts +9 -0
  39. package/template/apps/server/src/module-composition.ts +13 -0
  40. package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
  41. package/template/apps/server/src/module-host/initialize.test.ts +69 -0
  42. package/template/apps/server/src/module-host/initialize.ts +50 -53
  43. package/template/apps/server/src/module-host.ts +7 -0
  44. package/template/apps/server/src/module-runtime-validation.ts +39 -0
  45. package/template/docker-compose.yml +25 -0
  46. package/template/docs/architecture/ai-runtime.md +744 -0
  47. package/template/docs/platform/agent-runtime.md +128 -0
  48. package/template/docs/platform/agent-tools.md +8 -1
  49. package/template/package.json +1 -0
  50. package/template/packages/config/index.test.ts +43 -0
  51. package/template/packages/config/index.ts +1 -0
  52. package/template/packages/config/package.json +2 -1
  53. package/template/packages/config/src/ai-openai-compatible.ts +131 -0
  54. package/template/packages/server-sdk/index.ts +11 -0
  55. package/template/packages/server-sdk/package.json +4 -2
  56. package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
  57. package/template/packages/server-sdk/src/module-host.ts +5 -0
  58. package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncIterator
6
+
7
+ from .contracts import JsonValue
8
+ from .engine import (
9
+ AgentEngineAdapter,
10
+ EngineCompleted,
11
+ EngineRunRequest,
12
+ EngineSignal,
13
+ OutputDelta,
14
+ SafeEngineError,
15
+ ToolCompleted,
16
+ ToolRequested,
17
+ UsageReported,
18
+ )
19
+ from .openai_provider import OpenAiCompatibleProvider, ProviderToolCall
20
+ from .tool_host import ToolHostClient, ToolInvocation
21
+
22
+
23
+ class OpenAiCompatibleEngineAdapter(AgentEngineAdapter):
24
+ def __init__(
25
+ self,
26
+ provider: OpenAiCompatibleProvider,
27
+ tool_host: ToolHostClient,
28
+ ) -> None:
29
+ self._provider = provider
30
+ self._tool_host = tool_host
31
+ self._cancellations: dict[str, asyncio.Event] = {}
32
+
33
+ def supports(self, operation_key: str) -> bool:
34
+ return bool(operation_key.strip())
35
+
36
+ async def execute(
37
+ self,
38
+ request: EngineRunRequest,
39
+ ) -> AsyncIterator[EngineSignal]:
40
+ validate_runtime_request(request)
41
+ cancellation = self._cancellations.setdefault(
42
+ request.run_id,
43
+ asyncio.Event(),
44
+ )
45
+ allowed = {tool.key for tool in request.allowed_tools}
46
+ messages: list[dict[str, JsonValue]] = [
47
+ {"role": "system", "content": request.instructions},
48
+ {"role": "user", "content": request.user_prompt},
49
+ ]
50
+ output_bytes = 0
51
+ try:
52
+ for _round in range(self._provider.config.max_tool_rounds):
53
+ cancelled(cancellation)
54
+ turn = await self._provider.complete_turn(
55
+ messages,
56
+ request.allowed_tools,
57
+ )
58
+ if turn.tool_calls:
59
+ messages.append(assistant_tool_message(turn.tool_calls))
60
+ async for signal in self._execute_tools(
61
+ request,
62
+ turn.tool_calls,
63
+ allowed,
64
+ messages,
65
+ cancellation,
66
+ ):
67
+ yield signal
68
+ continue
69
+ for delta in turn.text_deltas:
70
+ output_bytes += len(delta.encode())
71
+ if output_bytes > request.limits.max_output_bytes:
72
+ raise SafeEngineError(
73
+ "BUDGET_EXCEEDED",
74
+ "Agent 输出超过 Operation 上限",
75
+ retryable=False,
76
+ )
77
+ yield OutputDelta(delta)
78
+ yield UsageReported(turn.usage or {"source": "unknown"})
79
+ yield EngineCompleted({"text": turn.text})
80
+ return
81
+ raise SafeEngineError(
82
+ "BUDGET_EXCEEDED",
83
+ "Agent Tool 调用轮次超过上限",
84
+ retryable=False,
85
+ )
86
+ finally:
87
+ self._cancellations.pop(request.run_id, None)
88
+
89
+ async def _execute_tools(
90
+ self,
91
+ request: EngineRunRequest,
92
+ calls: list[ProviderToolCall],
93
+ allowed: set[str],
94
+ messages: list[dict[str, JsonValue]],
95
+ cancellation: asyncio.Event,
96
+ ) -> AsyncIterator[EngineSignal]:
97
+ for call in calls:
98
+ cancelled(cancellation)
99
+ if not call.call_id or not call.name or call.name not in allowed:
100
+ raise SafeEngineError(
101
+ "TOOL_DENIED",
102
+ "模型请求了未授权 Tool",
103
+ retryable=False,
104
+ )
105
+ arguments = parse_arguments(call.arguments)
106
+ yield ToolRequested(call.call_id, call.name)
107
+ result = await self._tool_host.execute(
108
+ request.tool_host_url,
109
+ ToolInvocation(
110
+ request.run_id,
111
+ call.call_id,
112
+ call.name,
113
+ arguments,
114
+ request.execution_grant,
115
+ ),
116
+ )
117
+ yield ToolCompleted(call.call_id)
118
+ messages.append(
119
+ {
120
+ "role": "tool",
121
+ "tool_call_id": call.call_id,
122
+ "content": json.dumps(
123
+ result,
124
+ ensure_ascii=False,
125
+ separators=(",", ":"),
126
+ ),
127
+ }
128
+ )
129
+
130
+ async def cancel(self, run_id: str) -> None:
131
+ event = self._cancellations.get(run_id)
132
+ if event is not None:
133
+ event.set()
134
+
135
+ async def health(self) -> None:
136
+ await self._provider.health()
137
+
138
+
139
+ def validate_runtime_request(request: EngineRunRequest) -> None:
140
+ if (
141
+ not request.instructions.strip()
142
+ or not request.user_prompt.strip()
143
+ or not request.execution_grant.strip()
144
+ or not request.tool_host_url.strip()
145
+ ):
146
+ raise SafeEngineError(
147
+ "INVALID_INPUT",
148
+ "Agent Operation 运行计划不完整",
149
+ retryable=False,
150
+ )
151
+
152
+
153
+ def cancelled(event: asyncio.Event) -> None:
154
+ if event.is_set():
155
+ raise SafeEngineError(
156
+ "RUN_CANCELLED",
157
+ "Agent Run 已取消",
158
+ retryable=False,
159
+ )
160
+
161
+
162
+ def parse_arguments(raw: str) -> dict[str, JsonValue]:
163
+ try:
164
+ value = json.loads(raw or "{}")
165
+ except json.JSONDecodeError:
166
+ raise SafeEngineError(
167
+ "TOOL_DENIED",
168
+ "Agent Tool 参数无效",
169
+ retryable=False,
170
+ ) from None
171
+ if not isinstance(value, dict):
172
+ raise SafeEngineError(
173
+ "TOOL_DENIED",
174
+ "Agent Tool 参数必须是对象",
175
+ retryable=False,
176
+ )
177
+ return value
178
+
179
+
180
+ def assistant_tool_message(
181
+ calls: list[ProviderToolCall],
182
+ ) -> dict[str, JsonValue]:
183
+ return {
184
+ "role": "assistant",
185
+ "content": None,
186
+ "tool_calls": [
187
+ {
188
+ "id": call.call_id,
189
+ "type": "function",
190
+ "function": {
191
+ "name": call.name,
192
+ "arguments": call.arguments,
193
+ },
194
+ }
195
+ for call in calls
196
+ ],
197
+ }
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import cast
6
+
7
+ import httpx
8
+
9
+ from .config import (
10
+ EnvironmentSecretResolver,
11
+ OpenAiCompatibleConfig,
12
+ )
13
+ from .contracts import AgentErrorCode, JsonValue, RuntimeToolDefinition
14
+ from .engine import SafeEngineError
15
+
16
+
17
+ @dataclass
18
+ class ProviderToolCall:
19
+ call_id: str = ""
20
+ name: str = ""
21
+ arguments: str = ""
22
+
23
+
24
+ @dataclass
25
+ class ProviderTurn:
26
+ text_deltas: list[str] = field(default_factory=list)
27
+ tool_calls: list[ProviderToolCall] = field(default_factory=list)
28
+ usage: dict[str, JsonValue] | None = None
29
+
30
+ @property
31
+ def text(self) -> str:
32
+ return "".join(self.text_deltas)
33
+
34
+
35
+ class OpenAiCompatibleProvider:
36
+ def __init__(
37
+ self,
38
+ config: OpenAiCompatibleConfig,
39
+ resolver: EnvironmentSecretResolver,
40
+ *,
41
+ transport: httpx.AsyncBaseTransport | None = None,
42
+ ) -> None:
43
+ self.config = config
44
+ self._resolver = resolver
45
+ self._transport = transport
46
+
47
+ async def health(self) -> None:
48
+ async with self._client() as client:
49
+ try:
50
+ response = await client.get("/models")
51
+ await self._bounded_body(response)
52
+ except httpx.HTTPError as error:
53
+ raise provider_failure(error) from None
54
+ if not response.is_success:
55
+ raise provider_status_failure(response.status_code)
56
+
57
+ async def complete_turn(
58
+ self,
59
+ messages: list[dict[str, JsonValue]],
60
+ tools: tuple[RuntimeToolDefinition, ...],
61
+ ) -> ProviderTurn:
62
+ payload: dict[str, JsonValue] = {
63
+ "model": self.config.model,
64
+ "messages": cast(JsonValue, messages),
65
+ "stream": True,
66
+ "stream_options": {"include_usage": True},
67
+ }
68
+ if tools:
69
+ payload["tools"] = [tool_schema(tool) for tool in tools]
70
+ turn = ProviderTurn()
71
+ async with self._client() as client:
72
+ try:
73
+ async with client.stream(
74
+ "POST",
75
+ "/chat/completions",
76
+ json=payload,
77
+ ) as response:
78
+ if not response.is_success:
79
+ await self._bounded_stream(response)
80
+ raise provider_status_failure(response.status_code)
81
+ await self._consume_stream(response, turn)
82
+ except SafeEngineError:
83
+ raise
84
+ except httpx.HTTPError as error:
85
+ raise provider_failure(error) from None
86
+ return turn
87
+
88
+ def _client(self) -> httpx.AsyncClient:
89
+ api_key = (
90
+ self._resolver.resolve(self.config.api_key_ref)
91
+ if self.config.api_key_ref
92
+ else None
93
+ )
94
+ headers = {"accept": "text/event-stream"}
95
+ if api_key:
96
+ headers["authorization"] = f"Bearer {api_key}"
97
+ timeout = httpx.Timeout(
98
+ self.config.request_timeout_seconds,
99
+ connect=self.config.connect_timeout_seconds,
100
+ )
101
+ return httpx.AsyncClient(
102
+ base_url=f"{self.config.base_url}/",
103
+ headers=headers,
104
+ timeout=timeout,
105
+ transport=self._transport,
106
+ )
107
+
108
+ async def _consume_stream(
109
+ self,
110
+ response: httpx.Response,
111
+ turn: ProviderTurn,
112
+ ) -> None:
113
+ consumed = 0
114
+ async for line in response.aiter_lines():
115
+ consumed += len(line.encode()) + 1
116
+ if consumed > self.config.max_response_bytes:
117
+ raise SafeEngineError(
118
+ "PROVIDER_UNAVAILABLE",
119
+ "模型响应超过安全上限",
120
+ retryable=False,
121
+ )
122
+ if not line.startswith("data:"):
123
+ continue
124
+ data = line.removeprefix("data:").strip()
125
+ if not data or data == "[DONE]":
126
+ continue
127
+ try:
128
+ chunk = json.loads(data)
129
+ consume_chunk(chunk, turn)
130
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
131
+ raise SafeEngineError(
132
+ "PROVIDER_UNAVAILABLE",
133
+ "模型返回协议无效",
134
+ retryable=True,
135
+ ) from None
136
+
137
+ async def _bounded_stream(self, response: httpx.Response) -> bytes:
138
+ body = bytearray()
139
+ async for chunk in response.aiter_bytes():
140
+ body.extend(chunk)
141
+ if len(body) > self.config.max_response_bytes:
142
+ break
143
+ return bytes(body)
144
+
145
+ async def _bounded_body(self, response: httpx.Response) -> bytes:
146
+ body = await response.aread()
147
+ if len(body) > self.config.max_response_bytes:
148
+ raise SafeEngineError(
149
+ "PROVIDER_UNAVAILABLE",
150
+ "模型响应超过安全上限",
151
+ retryable=False,
152
+ )
153
+ return body
154
+
155
+
156
+ def consume_chunk(value: object, turn: ProviderTurn) -> None:
157
+ if not isinstance(value, dict):
158
+ raise TypeError
159
+ usage = value.get("usage")
160
+ if isinstance(usage, dict):
161
+ turn.usage = usage_value(usage)
162
+ choices = value.get("choices")
163
+ if not isinstance(choices, list) or not choices:
164
+ return
165
+ choice = choices[0]
166
+ if not isinstance(choice, dict):
167
+ raise TypeError
168
+ delta = choice.get("delta")
169
+ if not isinstance(delta, dict):
170
+ return
171
+ content = delta.get("content")
172
+ if isinstance(content, str) and content:
173
+ turn.text_deltas.append(content)
174
+ raw_calls = delta.get("tool_calls")
175
+ if not isinstance(raw_calls, list):
176
+ return
177
+ for raw in raw_calls:
178
+ if not isinstance(raw, dict):
179
+ raise TypeError
180
+ index = raw.get("index")
181
+ if not isinstance(index, int) or index < 0:
182
+ raise TypeError
183
+ while len(turn.tool_calls) <= index:
184
+ turn.tool_calls.append(ProviderToolCall())
185
+ call = turn.tool_calls[index]
186
+ call_id = raw.get("id")
187
+ if isinstance(call_id, str):
188
+ call.call_id += call_id
189
+ function = raw.get("function")
190
+ if isinstance(function, dict):
191
+ name = function.get("name")
192
+ arguments = function.get("arguments")
193
+ if isinstance(name, str):
194
+ call.name += name
195
+ if isinstance(arguments, str):
196
+ call.arguments += arguments
197
+
198
+
199
+ def tool_schema(tool: RuntimeToolDefinition) -> JsonValue:
200
+ return {
201
+ "type": "function",
202
+ "function": {
203
+ "name": tool.key,
204
+ "description": tool.description,
205
+ "parameters": {
206
+ "type": "object",
207
+ "additionalProperties": True,
208
+ },
209
+ },
210
+ }
211
+
212
+
213
+ def usage_value(raw: dict[object, object]) -> dict[str, JsonValue]:
214
+ values = {
215
+ "inputTokens": raw.get("prompt_tokens"),
216
+ "outputTokens": raw.get("completion_tokens"),
217
+ "totalTokens": raw.get("total_tokens"),
218
+ }
219
+ measurements = {
220
+ key: value
221
+ for key, value in values.items()
222
+ if isinstance(value, int) and value >= 0
223
+ }
224
+ return (
225
+ {"source": "provider-reported", **measurements}
226
+ if measurements
227
+ else {"source": "unknown"}
228
+ )
229
+
230
+
231
+ def provider_failure(error: httpx.HTTPError) -> SafeEngineError:
232
+ del error
233
+ return SafeEngineError(
234
+ "PROVIDER_UNAVAILABLE",
235
+ "模型服务暂不可用",
236
+ retryable=True,
237
+ )
238
+
239
+
240
+ def provider_status_failure(status: int) -> SafeEngineError:
241
+ code: AgentErrorCode = "RATE_LIMITED" if status == 429 else "PROVIDER_UNAVAILABLE"
242
+ return SafeEngineError(
243
+ code,
244
+ "模型服务限流" if status == 429 else "模型服务暂不可用",
245
+ retryable=status == 429 or status >= 500,
246
+ )
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import uuid
7
+
8
+ from .contracts import AgentError, JsonValue, StartRunRequest
9
+ from .engine import (
10
+ AgentEngineAdapter,
11
+ ApprovalRequired,
12
+ EngineCompleted,
13
+ EngineRunRequest,
14
+ OutputDelta,
15
+ SafeEngineError,
16
+ ToolCompleted,
17
+ ToolRequested,
18
+ UsageReported,
19
+ )
20
+ from .store import IdempotencyConflictError, RunRecord, RunStore
21
+
22
+
23
+ class RunNotFoundError(Exception):
24
+ pass
25
+
26
+
27
+ class OperationNotFoundError(Exception):
28
+ pass
29
+
30
+
31
+ class AgentRunService:
32
+ def __init__(self, engine: AgentEngineAdapter, store: RunStore) -> None:
33
+ self.engine = engine
34
+ self.store = store
35
+ self._tasks: set[asyncio.Task[None]] = set()
36
+
37
+ async def start(
38
+ self,
39
+ request: StartRunRequest,
40
+ idempotency_key: str,
41
+ ) -> RunRecord:
42
+ if not self.engine.supports(request.operation_key):
43
+ raise OperationNotFoundError
44
+ fingerprint = request_fingerprint(request.operation_key, request.input)
45
+ candidate = RunRecord(
46
+ run_id=f"run_{uuid.uuid4().hex}",
47
+ operation_key=request.operation_key,
48
+ input=request.input,
49
+ idempotency_key=idempotency_key,
50
+ fingerprint=fingerprint,
51
+ )
52
+ record, created = await self.store.create_or_get(candidate)
53
+ if created:
54
+ task = asyncio.create_task(self._execute(record, request))
55
+ self._tasks.add(task)
56
+ task.add_done_callback(self._tasks.discard)
57
+ return record
58
+
59
+ async def get(self, run_id: str) -> RunRecord:
60
+ record = await self.store.get(run_id)
61
+ if record is None:
62
+ raise RunNotFoundError
63
+ return record
64
+
65
+ async def cancel(self, run_id: str) -> RunRecord:
66
+ record = await self.get(run_id)
67
+ if record.terminal:
68
+ return record
69
+ await self.engine.cancel(run_id)
70
+ await self.store.append(run_id, "run.cancelled")
71
+ return record
72
+
73
+ async def _execute(
74
+ self,
75
+ record: RunRecord,
76
+ invocation: StartRunRequest,
77
+ ) -> None:
78
+ await self.store.append(record.run_id, "run.started")
79
+ try:
80
+ request = EngineRunRequest(
81
+ run_id=record.run_id,
82
+ operation_key=record.operation_key,
83
+ input=record.input,
84
+ instructions=invocation.instructions,
85
+ user_prompt=invocation.user_prompt,
86
+ allowed_tools=tuple(invocation.allowed_tools),
87
+ limits=invocation.limits,
88
+ execution_grant=invocation.execution_grant,
89
+ tool_host_url=invocation.tool_host_url,
90
+ )
91
+ async with asyncio.timeout(invocation.limits.max_duration_seconds):
92
+ async for signal in self.engine.execute(request):
93
+ if isinstance(signal, EngineCompleted):
94
+ await self.store.complete(record.run_id, signal.output)
95
+ return
96
+ await self._append_signal(record.run_id, signal)
97
+ await self._fail(
98
+ record.run_id,
99
+ SafeEngineError(
100
+ "STREAM_INTERRUPTED",
101
+ "Agent Runtime 未返回终态",
102
+ retryable=True,
103
+ ),
104
+ )
105
+ except SafeEngineError as error:
106
+ await self._fail(record.run_id, error)
107
+ except TimeoutError:
108
+ await self._fail(
109
+ record.run_id,
110
+ SafeEngineError(
111
+ "RUN_TIMEOUT",
112
+ "Agent Run 执行超时",
113
+ retryable=True,
114
+ ),
115
+ )
116
+ except Exception:
117
+ await self._fail(
118
+ record.run_id,
119
+ SafeEngineError(
120
+ "RUNTIME_UNAVAILABLE",
121
+ "Agent Runtime 执行失败",
122
+ retryable=True,
123
+ ),
124
+ )
125
+
126
+ async def _append_signal(self, run_id: str, signal: object) -> None:
127
+ if isinstance(signal, OutputDelta):
128
+ await self.store.append(run_id, "output.delta", {"text": signal.text})
129
+ elif isinstance(signal, ToolRequested):
130
+ await self.store.append(
131
+ run_id,
132
+ "tool.requested",
133
+ {"toolCallId": signal.tool_call_id, "toolKey": signal.tool_key},
134
+ )
135
+ elif isinstance(signal, ApprovalRequired):
136
+ await self.store.append(
137
+ run_id,
138
+ "approval.required",
139
+ {"workItemId": signal.work_item_id},
140
+ )
141
+ elif isinstance(signal, ToolCompleted):
142
+ await self.store.append(
143
+ run_id,
144
+ "tool.completed",
145
+ {"toolCallId": signal.tool_call_id},
146
+ )
147
+ elif isinstance(signal, UsageReported):
148
+ await self.store.append(
149
+ run_id,
150
+ "usage.reported",
151
+ {"usage": signal.usage},
152
+ )
153
+ else:
154
+ raise TypeError("unsupported engine signal")
155
+
156
+ async def _fail(self, run_id: str, failure: SafeEngineError) -> None:
157
+ error = AgentError(
158
+ code=failure.code,
159
+ message=failure.safe_message,
160
+ request_id=f"request_{uuid.uuid4().hex}",
161
+ retryable=failure.retryable,
162
+ )
163
+ await self.store.append(
164
+ run_id,
165
+ "run.failed",
166
+ {"error": error.model_dump(by_alias=True)},
167
+ )
168
+
169
+
170
+ def request_fingerprint(operation_key: str, input_value: JsonValue) -> str:
171
+ encoded = json.dumps(
172
+ {"operationKey": operation_key, "input": input_value},
173
+ ensure_ascii=False,
174
+ separators=(",", ":"),
175
+ sort_keys=True,
176
+ ).encode()
177
+ return hashlib.sha256(encoded).hexdigest()
178
+
179
+
180
+ __all__ = ["AgentRunService", "IdempotencyConflictError"]