create-windy 0.2.20 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +84 -10
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/README.md +1 -0
- package/template/apps/agent-server/Dockerfile +20 -0
- package/template/apps/agent-server/README.md +46 -0
- package/template/apps/agent-server/pyproject.toml +44 -0
- package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
- package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
- package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
- package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
- package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
- package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
- package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
- package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
- package/template/apps/agent-server/tests/test_api.py +207 -0
- package/template/apps/agent-server/tests/test_config.py +56 -0
- package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
- package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
- package/template/apps/agent-server/uv.lock +925 -0
- package/template/apps/server/src/agent/execution-grants.ts +89 -0
- package/template/apps/server/src/agent/remote-runtime.ts +128 -0
- package/template/apps/server/src/agent/run-contracts.ts +43 -0
- package/template/apps/server/src/agent/run-facade.test.ts +257 -0
- package/template/apps/server/src/agent/run-facade.ts +190 -0
- package/template/apps/server/src/agent/run-routes.ts +222 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
- package/template/apps/server/src/agent/tool-host.test.ts +242 -0
- package/template/apps/server/src/agent/tool-host.ts +153 -0
- package/template/apps/server/src/index.ts +12 -16
- package/template/apps/server/src/module-composition.test.ts +9 -0
- package/template/apps/server/src/module-composition.ts +13 -0
- package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
- package/template/apps/server/src/module-host/initialize.test.ts +69 -0
- package/template/apps/server/src/module-host/initialize.ts +50 -53
- package/template/apps/server/src/module-host.ts +1 -0
- package/template/apps/server/src/module-runtime-validation.ts +40 -0
- package/template/docker-compose.yml +25 -0
- package/template/docs/architecture/ai-runtime.md +744 -0
- package/template/docs/platform/agent-runtime.md +128 -0
- package/template/docs/platform/agent-tools.md +8 -1
- package/template/package.json +1 -0
- package/template/packages/config/index.test.ts +43 -0
- package/template/packages/config/index.ts +1 -0
- package/template/packages/config/package.json +2 -1
- package/template/packages/config/src/ai-openai-compatible.ts +131 -0
- package/template/packages/server-sdk/index.ts +11 -0
- package/template/packages/server-sdk/package.json +4 -2
- package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
- package/template/packages/server-sdk/src/module-host.ts +5 -0
- package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
|
@@ -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"]
|