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,317 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from .contracts import AgentError, AgentErrorCode, JsonValue, RunStatus
|
|
11
|
+
from .store import (
|
|
12
|
+
CanonicalEvent,
|
|
13
|
+
IdempotencyConflictError,
|
|
14
|
+
RunRecord,
|
|
15
|
+
status_for,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RunStoreCapacityError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SqliteRunStore:
|
|
24
|
+
"""Single-replica durable Run store with bounded rows, events and output."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
path: str,
|
|
29
|
+
*,
|
|
30
|
+
max_runs: int = 10_000,
|
|
31
|
+
max_events_per_run: int = 2_000,
|
|
32
|
+
max_output_bytes: int = 16 * 1024 * 1024,
|
|
33
|
+
terminal_ttl_seconds: int = 7 * 24 * 60 * 60,
|
|
34
|
+
) -> None:
|
|
35
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self._database = sqlite3.connect(path, check_same_thread=False)
|
|
37
|
+
self._database.row_factory = sqlite3.Row
|
|
38
|
+
self._max_runs = max_runs
|
|
39
|
+
self._max_events = max_events_per_run
|
|
40
|
+
self._max_output_bytes = max_output_bytes
|
|
41
|
+
self._terminal_ttl = terminal_ttl_seconds
|
|
42
|
+
self._condition = asyncio.Condition()
|
|
43
|
+
self._initialize()
|
|
44
|
+
self._recover_incomplete()
|
|
45
|
+
|
|
46
|
+
async def create_or_get(self, record: RunRecord) -> tuple[RunRecord, bool]:
|
|
47
|
+
async with self._condition:
|
|
48
|
+
existing = self._database.execute(
|
|
49
|
+
"select run_id from agent_runs where idempotency_key = ?",
|
|
50
|
+
(record.idempotency_key,),
|
|
51
|
+
).fetchone()
|
|
52
|
+
if existing:
|
|
53
|
+
current = self._read(str(existing["run_id"]))
|
|
54
|
+
if current is None or current.fingerprint != record.fingerprint:
|
|
55
|
+
raise IdempotencyConflictError
|
|
56
|
+
return current, False
|
|
57
|
+
self._prune()
|
|
58
|
+
count = int(
|
|
59
|
+
self._database.execute("select count(*) from agent_runs").fetchone()[0]
|
|
60
|
+
)
|
|
61
|
+
if count >= self._max_runs:
|
|
62
|
+
raise RunStoreCapacityError("Agent Run Store 容量已满")
|
|
63
|
+
now = time.time()
|
|
64
|
+
self._database.execute(
|
|
65
|
+
"""
|
|
66
|
+
insert into agent_runs (
|
|
67
|
+
run_id, operation_key, input_json, idempotency_key,
|
|
68
|
+
fingerprint, status, output_json, created_at, updated_at
|
|
69
|
+
) values (?, ?, ?, ?, ?, 'accepted', 'null', ?, ?)
|
|
70
|
+
""",
|
|
71
|
+
(
|
|
72
|
+
record.run_id,
|
|
73
|
+
record.operation_key,
|
|
74
|
+
"null",
|
|
75
|
+
record.idempotency_key,
|
|
76
|
+
record.fingerprint,
|
|
77
|
+
now,
|
|
78
|
+
now,
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
self._database.commit()
|
|
82
|
+
self._condition.notify_all()
|
|
83
|
+
return record, True
|
|
84
|
+
|
|
85
|
+
async def get(self, run_id: str) -> RunRecord | None:
|
|
86
|
+
async with self._condition:
|
|
87
|
+
return self._read(run_id)
|
|
88
|
+
|
|
89
|
+
async def append(
|
|
90
|
+
self,
|
|
91
|
+
run_id: str,
|
|
92
|
+
event_type: str,
|
|
93
|
+
fields: dict[str, object] | None = None,
|
|
94
|
+
) -> CanonicalEvent | None:
|
|
95
|
+
async with self._condition:
|
|
96
|
+
record = self._read(run_id)
|
|
97
|
+
if record is None:
|
|
98
|
+
raise KeyError(run_id)
|
|
99
|
+
if record.terminal:
|
|
100
|
+
return None
|
|
101
|
+
sequence = len(record.events) + 1
|
|
102
|
+
if sequence >= self._max_events and not event_type.startswith("run."):
|
|
103
|
+
self._append_failure(run_id, sequence, "Agent 事件超过安全上限")
|
|
104
|
+
self._condition.notify_all()
|
|
105
|
+
raise RunStoreCapacityError("Agent 事件超过安全上限")
|
|
106
|
+
event: CanonicalEvent = {
|
|
107
|
+
"type": event_type,
|
|
108
|
+
"runId": run_id,
|
|
109
|
+
"sequence": sequence,
|
|
110
|
+
**(fields or {}),
|
|
111
|
+
}
|
|
112
|
+
self._insert_event(run_id, event, status_for(event_type, record.status))
|
|
113
|
+
self._condition.notify_all()
|
|
114
|
+
return event
|
|
115
|
+
|
|
116
|
+
async def complete(self, run_id: str, output: JsonValue) -> None:
|
|
117
|
+
async with self._condition:
|
|
118
|
+
record = self._read(run_id)
|
|
119
|
+
if record is None:
|
|
120
|
+
raise KeyError(run_id)
|
|
121
|
+
if record.terminal:
|
|
122
|
+
return
|
|
123
|
+
encoded = encode(output)
|
|
124
|
+
sequence = len(record.events) + 1
|
|
125
|
+
if len(encoded.encode()) > self._max_output_bytes:
|
|
126
|
+
self._append_failure(run_id, sequence, "Agent 输出超过安全上限")
|
|
127
|
+
self._condition.notify_all()
|
|
128
|
+
return
|
|
129
|
+
event: CanonicalEvent = {
|
|
130
|
+
"type": "run.completed",
|
|
131
|
+
"runId": run_id,
|
|
132
|
+
"sequence": sequence,
|
|
133
|
+
}
|
|
134
|
+
self._insert_event(run_id, event, "completed", encoded)
|
|
135
|
+
self._condition.notify_all()
|
|
136
|
+
|
|
137
|
+
async def events_after(
|
|
138
|
+
self,
|
|
139
|
+
run_id: str,
|
|
140
|
+
after: int,
|
|
141
|
+
wait_seconds: float,
|
|
142
|
+
) -> tuple[list[CanonicalEvent], bool]:
|
|
143
|
+
async with self._condition:
|
|
144
|
+
|
|
145
|
+
def ready() -> bool:
|
|
146
|
+
record = self._read(run_id)
|
|
147
|
+
return bool(
|
|
148
|
+
record
|
|
149
|
+
and (
|
|
150
|
+
any(sequence_of(event) > after for event in record.events)
|
|
151
|
+
or record.terminal
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if not ready():
|
|
156
|
+
try:
|
|
157
|
+
await asyncio.wait_for(
|
|
158
|
+
self._condition.wait_for(ready),
|
|
159
|
+
wait_seconds,
|
|
160
|
+
)
|
|
161
|
+
except TimeoutError:
|
|
162
|
+
return [], False
|
|
163
|
+
record = self._read(run_id)
|
|
164
|
+
if record is None:
|
|
165
|
+
raise KeyError(run_id)
|
|
166
|
+
return (
|
|
167
|
+
[event for event in record.events if sequence_of(event) > after],
|
|
168
|
+
record.terminal,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def close(self) -> None:
|
|
172
|
+
self._database.close()
|
|
173
|
+
|
|
174
|
+
def _initialize(self) -> None:
|
|
175
|
+
self._database.executescript(
|
|
176
|
+
"""
|
|
177
|
+
create table if not exists agent_runs (
|
|
178
|
+
run_id text primary key,
|
|
179
|
+
operation_key text not null,
|
|
180
|
+
input_json text not null,
|
|
181
|
+
idempotency_key text not null unique,
|
|
182
|
+
fingerprint text not null,
|
|
183
|
+
status text not null,
|
|
184
|
+
output_json text not null,
|
|
185
|
+
created_at real not null,
|
|
186
|
+
updated_at real not null
|
|
187
|
+
);
|
|
188
|
+
create table if not exists agent_events (
|
|
189
|
+
run_id text not null references agent_runs(run_id) on delete cascade,
|
|
190
|
+
sequence integer not null,
|
|
191
|
+
event_json text not null,
|
|
192
|
+
primary key (run_id, sequence)
|
|
193
|
+
);
|
|
194
|
+
"""
|
|
195
|
+
)
|
|
196
|
+
self._database.commit()
|
|
197
|
+
|
|
198
|
+
def _recover_incomplete(self) -> None:
|
|
199
|
+
rows = self._database.execute(
|
|
200
|
+
"""
|
|
201
|
+
select run_id from agent_runs
|
|
202
|
+
where status not in ('completed', 'failed', 'cancelled')
|
|
203
|
+
"""
|
|
204
|
+
).fetchall()
|
|
205
|
+
for row in rows:
|
|
206
|
+
run_id = str(row["run_id"])
|
|
207
|
+
sequence = (
|
|
208
|
+
int(
|
|
209
|
+
self._database.execute(
|
|
210
|
+
"select count(*) from agent_events where run_id = ?",
|
|
211
|
+
(run_id,),
|
|
212
|
+
).fetchone()[0]
|
|
213
|
+
)
|
|
214
|
+
+ 1
|
|
215
|
+
)
|
|
216
|
+
self._append_failure(
|
|
217
|
+
run_id,
|
|
218
|
+
sequence,
|
|
219
|
+
"Agent Runtime 重启中断了 Run",
|
|
220
|
+
code="RUNTIME_UNAVAILABLE",
|
|
221
|
+
retryable=True,
|
|
222
|
+
)
|
|
223
|
+
self._database.commit()
|
|
224
|
+
|
|
225
|
+
def _read(self, run_id: str) -> RunRecord | None:
|
|
226
|
+
row = self._database.execute(
|
|
227
|
+
"select * from agent_runs where run_id = ?",
|
|
228
|
+
(run_id,),
|
|
229
|
+
).fetchone()
|
|
230
|
+
if not row:
|
|
231
|
+
return None
|
|
232
|
+
events = [
|
|
233
|
+
json.loads(str(event["event_json"]))
|
|
234
|
+
for event in self._database.execute(
|
|
235
|
+
"""
|
|
236
|
+
select event_json from agent_events
|
|
237
|
+
where run_id = ? order by sequence
|
|
238
|
+
""",
|
|
239
|
+
(run_id,),
|
|
240
|
+
)
|
|
241
|
+
]
|
|
242
|
+
return RunRecord(
|
|
243
|
+
run_id=str(row["run_id"]),
|
|
244
|
+
operation_key=str(row["operation_key"]),
|
|
245
|
+
input=json.loads(str(row["input_json"])),
|
|
246
|
+
idempotency_key=str(row["idempotency_key"]),
|
|
247
|
+
fingerprint=str(row["fingerprint"]),
|
|
248
|
+
status=cast(RunStatus, str(row["status"])),
|
|
249
|
+
events=events,
|
|
250
|
+
output=json.loads(str(row["output_json"])),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _insert_event(
|
|
254
|
+
self,
|
|
255
|
+
run_id: str,
|
|
256
|
+
event: CanonicalEvent,
|
|
257
|
+
status: RunStatus,
|
|
258
|
+
output_json: str | None = None,
|
|
259
|
+
) -> None:
|
|
260
|
+
self._database.execute(
|
|
261
|
+
"insert into agent_events values (?, ?, ?)",
|
|
262
|
+
(run_id, event["sequence"], encode(event)),
|
|
263
|
+
)
|
|
264
|
+
self._database.execute(
|
|
265
|
+
"""
|
|
266
|
+
update agent_runs
|
|
267
|
+
set status = ?, output_json = coalesce(?, output_json), updated_at = ?
|
|
268
|
+
where run_id = ?
|
|
269
|
+
""",
|
|
270
|
+
(status, output_json, time.time(), run_id),
|
|
271
|
+
)
|
|
272
|
+
self._database.commit()
|
|
273
|
+
|
|
274
|
+
def _append_failure(
|
|
275
|
+
self,
|
|
276
|
+
run_id: str,
|
|
277
|
+
sequence: int,
|
|
278
|
+
message: str,
|
|
279
|
+
*,
|
|
280
|
+
code: AgentErrorCode = "BUDGET_EXCEEDED",
|
|
281
|
+
retryable: bool = False,
|
|
282
|
+
) -> None:
|
|
283
|
+
event: CanonicalEvent = {
|
|
284
|
+
"type": "run.failed",
|
|
285
|
+
"runId": run_id,
|
|
286
|
+
"sequence": sequence,
|
|
287
|
+
"error": AgentError(
|
|
288
|
+
code=code,
|
|
289
|
+
message=message,
|
|
290
|
+
request_id=f"request_store_{sequence}",
|
|
291
|
+
retryable=retryable,
|
|
292
|
+
).model_dump(by_alias=True),
|
|
293
|
+
}
|
|
294
|
+
self._insert_event(run_id, event, "failed")
|
|
295
|
+
|
|
296
|
+
def _prune(self) -> None:
|
|
297
|
+
cutoff = time.time() - self._terminal_ttl
|
|
298
|
+
self._database.execute(
|
|
299
|
+
"""
|
|
300
|
+
delete from agent_runs
|
|
301
|
+
where status in ('completed', 'failed', 'cancelled')
|
|
302
|
+
and updated_at < ?
|
|
303
|
+
""",
|
|
304
|
+
(cutoff,),
|
|
305
|
+
)
|
|
306
|
+
self._database.commit()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def encode(value: object) -> str:
|
|
310
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def sequence_of(event: CanonicalEvent) -> int:
|
|
314
|
+
value = event.get("sequence")
|
|
315
|
+
if not isinstance(value, int):
|
|
316
|
+
raise RuntimeError("Agent Event sequence 无效")
|
|
317
|
+
return value
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
|
|
6
|
+
from .service import AgentRunService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def encode_event(event: dict[str, object]) -> str:
|
|
10
|
+
sequence = event["sequence"]
|
|
11
|
+
event_type = event["type"]
|
|
12
|
+
data = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
|
|
13
|
+
return f"id: {sequence}\nevent: {event_type}\ndata: {data}\n\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def event_stream(
|
|
17
|
+
service: AgentRunService,
|
|
18
|
+
run_id: str,
|
|
19
|
+
after: int,
|
|
20
|
+
heartbeat_seconds: float,
|
|
21
|
+
) -> AsyncIterator[str]:
|
|
22
|
+
cursor = after
|
|
23
|
+
while True:
|
|
24
|
+
events, terminal = await service.store.events_after(
|
|
25
|
+
run_id,
|
|
26
|
+
cursor,
|
|
27
|
+
heartbeat_seconds,
|
|
28
|
+
)
|
|
29
|
+
if not events:
|
|
30
|
+
if terminal:
|
|
31
|
+
return
|
|
32
|
+
yield ": heartbeat\n\n"
|
|
33
|
+
continue
|
|
34
|
+
for event in events:
|
|
35
|
+
sequence = event["sequence"]
|
|
36
|
+
if not isinstance(sequence, int):
|
|
37
|
+
raise RuntimeError("invalid stored sequence")
|
|
38
|
+
cursor = sequence
|
|
39
|
+
yield encode_event(event)
|
|
40
|
+
if terminal:
|
|
41
|
+
return
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from .contracts import JsonValue, RunStatus
|
|
8
|
+
|
|
9
|
+
CanonicalEvent = dict[str, object]
|
|
10
|
+
TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RunRecord:
|
|
15
|
+
run_id: str
|
|
16
|
+
operation_key: str
|
|
17
|
+
input: JsonValue
|
|
18
|
+
idempotency_key: str
|
|
19
|
+
fingerprint: str
|
|
20
|
+
status: RunStatus = "accepted"
|
|
21
|
+
events: list[CanonicalEvent] = field(default_factory=list)
|
|
22
|
+
output: JsonValue = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def terminal(self) -> bool:
|
|
26
|
+
return self.status in TERMINAL_STATUSES
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IdempotencyConflictError(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RunStore(Protocol):
|
|
34
|
+
async def create_or_get(self, record: RunRecord) -> tuple[RunRecord, bool]: ...
|
|
35
|
+
async def get(self, run_id: str) -> RunRecord | None: ...
|
|
36
|
+
async def append(
|
|
37
|
+
self,
|
|
38
|
+
run_id: str,
|
|
39
|
+
event_type: str,
|
|
40
|
+
fields: dict[str, object] | None = None,
|
|
41
|
+
) -> CanonicalEvent | None: ...
|
|
42
|
+
async def complete(self, run_id: str, output: JsonValue) -> None: ...
|
|
43
|
+
async def events_after(
|
|
44
|
+
self,
|
|
45
|
+
run_id: str,
|
|
46
|
+
after: int,
|
|
47
|
+
wait_seconds: float,
|
|
48
|
+
) -> tuple[list[CanonicalEvent], bool]: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class InMemoryRunStore:
|
|
52
|
+
"""Non-production store for deterministic tests and local development only."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self._runs: dict[str, RunRecord] = {}
|
|
56
|
+
self._idempotency: dict[str, str] = {}
|
|
57
|
+
self._condition = asyncio.Condition()
|
|
58
|
+
|
|
59
|
+
async def create_or_get(self, record: RunRecord) -> tuple[RunRecord, bool]:
|
|
60
|
+
async with self._condition:
|
|
61
|
+
existing_id = self._idempotency.get(record.idempotency_key)
|
|
62
|
+
if existing_id is not None:
|
|
63
|
+
existing = self._runs[existing_id]
|
|
64
|
+
if existing.fingerprint != record.fingerprint:
|
|
65
|
+
raise IdempotencyConflictError
|
|
66
|
+
return existing, False
|
|
67
|
+
self._runs[record.run_id] = record
|
|
68
|
+
self._idempotency[record.idempotency_key] = record.run_id
|
|
69
|
+
self._condition.notify_all()
|
|
70
|
+
return record, True
|
|
71
|
+
|
|
72
|
+
async def get(self, run_id: str) -> RunRecord | None:
|
|
73
|
+
async with self._condition:
|
|
74
|
+
return self._runs.get(run_id)
|
|
75
|
+
|
|
76
|
+
async def append(
|
|
77
|
+
self,
|
|
78
|
+
run_id: str,
|
|
79
|
+
event_type: str,
|
|
80
|
+
fields: dict[str, object] | None = None,
|
|
81
|
+
) -> CanonicalEvent | None:
|
|
82
|
+
async with self._condition:
|
|
83
|
+
record = self._runs[run_id]
|
|
84
|
+
if record.terminal:
|
|
85
|
+
return None
|
|
86
|
+
event: CanonicalEvent = {
|
|
87
|
+
"type": event_type,
|
|
88
|
+
"runId": run_id,
|
|
89
|
+
"sequence": len(record.events) + 1,
|
|
90
|
+
**(fields or {}),
|
|
91
|
+
}
|
|
92
|
+
record.events.append(event)
|
|
93
|
+
record.status = status_for(event_type, record.status)
|
|
94
|
+
self._condition.notify_all()
|
|
95
|
+
return event
|
|
96
|
+
|
|
97
|
+
async def complete(self, run_id: str, output: JsonValue) -> None:
|
|
98
|
+
async with self._condition:
|
|
99
|
+
record = self._runs[run_id]
|
|
100
|
+
if record.terminal:
|
|
101
|
+
return
|
|
102
|
+
record.output = output
|
|
103
|
+
event: CanonicalEvent = {
|
|
104
|
+
"type": "run.completed",
|
|
105
|
+
"runId": run_id,
|
|
106
|
+
"sequence": len(record.events) + 1,
|
|
107
|
+
}
|
|
108
|
+
record.events.append(event)
|
|
109
|
+
record.status = "completed"
|
|
110
|
+
self._condition.notify_all()
|
|
111
|
+
|
|
112
|
+
async def events_after(
|
|
113
|
+
self,
|
|
114
|
+
run_id: str,
|
|
115
|
+
after: int,
|
|
116
|
+
wait_seconds: float,
|
|
117
|
+
) -> tuple[list[CanonicalEvent], bool]:
|
|
118
|
+
async with self._condition:
|
|
119
|
+
record = self._runs[run_id]
|
|
120
|
+
|
|
121
|
+
def ready() -> bool:
|
|
122
|
+
return len(record.events) > after or record.terminal
|
|
123
|
+
|
|
124
|
+
if not ready():
|
|
125
|
+
try:
|
|
126
|
+
await asyncio.wait_for(
|
|
127
|
+
self._condition.wait_for(ready),
|
|
128
|
+
wait_seconds,
|
|
129
|
+
)
|
|
130
|
+
except TimeoutError:
|
|
131
|
+
return [], False
|
|
132
|
+
return list(record.events[after:]), record.terminal
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def status_for(event_type: str, current: RunStatus) -> RunStatus:
|
|
136
|
+
if event_type == "run.started":
|
|
137
|
+
return "running"
|
|
138
|
+
if event_type == "approval.required":
|
|
139
|
+
return "awaiting-approval"
|
|
140
|
+
if event_type == "run.failed":
|
|
141
|
+
return "failed"
|
|
142
|
+
if event_type == "run.cancelled":
|
|
143
|
+
return "cancelled"
|
|
144
|
+
return current
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .config import normalize_base_url
|
|
9
|
+
from .contracts import JsonValue
|
|
10
|
+
from .engine import SafeEngineError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ToolInvocation:
|
|
15
|
+
run_id: str
|
|
16
|
+
tool_call_id: str
|
|
17
|
+
tool_key: str
|
|
18
|
+
arguments: dict[str, JsonValue]
|
|
19
|
+
execution_grant: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolHostClient:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
internal_token: str,
|
|
26
|
+
*,
|
|
27
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
28
|
+
timeout_seconds: float = 15.0,
|
|
29
|
+
max_response_bytes: int = 1024 * 1024,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._internal_token = internal_token
|
|
32
|
+
self._transport = transport
|
|
33
|
+
self._timeout_seconds = timeout_seconds
|
|
34
|
+
self._max_response_bytes = max_response_bytes
|
|
35
|
+
|
|
36
|
+
async def execute(
|
|
37
|
+
self,
|
|
38
|
+
base_url: str,
|
|
39
|
+
invocation: ToolInvocation,
|
|
40
|
+
) -> JsonValue:
|
|
41
|
+
endpoint = normalize_base_url(base_url)
|
|
42
|
+
headers = {
|
|
43
|
+
"authorization": f"Bearer {self._internal_token}",
|
|
44
|
+
"content-type": "application/json",
|
|
45
|
+
}
|
|
46
|
+
try:
|
|
47
|
+
async with httpx.AsyncClient(
|
|
48
|
+
base_url=f"{endpoint}/",
|
|
49
|
+
headers=headers,
|
|
50
|
+
timeout=self._timeout_seconds,
|
|
51
|
+
transport=self._transport,
|
|
52
|
+
) as client:
|
|
53
|
+
response = await client.post(
|
|
54
|
+
"api/internal/agent/tools/execute",
|
|
55
|
+
json={
|
|
56
|
+
"executionGrant": invocation.execution_grant,
|
|
57
|
+
"runId": invocation.run_id,
|
|
58
|
+
"toolCallId": invocation.tool_call_id,
|
|
59
|
+
"toolKey": invocation.tool_key,
|
|
60
|
+
"arguments": invocation.arguments,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
body = await response.aread()
|
|
64
|
+
except httpx.HTTPError:
|
|
65
|
+
raise denied("Agent Tool Host 暂不可用", retryable=True) from None
|
|
66
|
+
if len(body) > self._max_response_bytes:
|
|
67
|
+
raise denied("Agent Tool 响应超过安全上限")
|
|
68
|
+
if not response.is_success:
|
|
69
|
+
raise denied("Agent Tool 执行被拒绝")
|
|
70
|
+
try:
|
|
71
|
+
value = json.loads(body)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
raise denied("Agent Tool 响应无效") from None
|
|
74
|
+
if not isinstance(value, dict) or "value" not in value:
|
|
75
|
+
raise denied("Agent Tool 响应无效")
|
|
76
|
+
return value["value"] # type: ignore[no-any-return]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def denied(message: str, *, retryable: bool = False) -> SafeEngineError:
|
|
80
|
+
return SafeEngineError("TOOL_DENIED", message, retryable=retryable)
|