agentlisp 2.0.0__py3-none-any.whl
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.
- agentlisp-2.0.0.dist-info/METADATA +482 -0
- agentlisp-2.0.0.dist-info/RECORD +36 -0
- agentlisp-2.0.0.dist-info/WHEEL +4 -0
- agentlisp-2.0.0.dist-info/entry_points.txt +3 -0
- agentlisp_runtime/__init__.py +29 -0
- agentlisp_runtime/cli.py +145 -0
- agentlisp_runtime/engine.py +253 -0
- agentlisp_runtime/loader.py +126 -0
- agentlisp_runtime/models.py +129 -0
- host/__init__.py +5 -0
- host/gateway.py +128 -0
- host/gateway_sse.py +788 -0
- host/sandbox.py +143 -0
- host/sandbox_e2b.py +173 -0
- host/tests/test_infra_docker_e2e.py +247 -0
- host/workflow.py +803 -0
- host/workflow_temporal.py +89 -0
- runtime/__init__.py +29 -0
- runtime/base_agent_harness.py +164 -0
- runtime/base_harness.py +359 -0
- runtime/base_harness_v2.py +1379 -0
- runtime/checker.py +654 -0
- runtime/checkpoint.py +98 -0
- runtime/errors.py +37 -0
- runtime/llm_client.py +644 -0
- runtime/mcp_client.py +86 -0
- runtime/memory_fs.py +90 -0
- runtime/otel_tracer.py +124 -0
- runtime/status_bar.py +86 -0
- runtime/tests/test_ac1_rackunit_18cases_roundtrip.py +255 -0
- runtime/tests/test_cli_if_cli_1_exit_encoding.py +309 -0
- runtime/tests/test_harness_v2.py +2030 -0
- runtime/tests/test_sideeffect_builtin_tools_racket_and_python_ssot_8_items_bitwise_equal.py +132 -0
- runtime/tests/test_srs_acceptance.py +1369 -0
- runtime/tests/test_t2_bench_dataset.py +86 -0
- runtime/tests/test_v2_smoke.py +139 -0
host/gateway_sse.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""AgentLisp v2.0 FastAPI Gateway + SSE Streaming(SRS §5.3 IF-API-1 契约 SSOT)
|
|
2
|
+
|
|
3
|
+
暴露路由 100% 对齐 §5.3 IF-API-1 表:
|
|
4
|
+
GET /health 心跳(含 checkpoint/otel feature probe,永不抛 FeatureNotInstalledError)
|
|
5
|
+
POST /v1/check 静态检查 checker.rkt --check-only --json-errors(离线等价断言 fallback)
|
|
6
|
+
POST /v1/agents/{name}/run stream=false → ExecutionTraceV2 JSON;stream=true → SSE 事件
|
|
7
|
+
GET /v1/agents/{name}/stream BDD step 兼容 URL,等价 POST .../run {stream:true}
|
|
8
|
+
GET /v1/runs/{run_id} 查运行状态 / final_answer
|
|
9
|
+
POST /v1/runs/{run_id}/approve HITL 放行(未挂起返回 409 Conflict)
|
|
10
|
+
POST /v1/runs/{run_id}/reject HITL 拒绝(未挂起返回 409 Conflict)
|
|
11
|
+
|
|
12
|
+
SSE 事件名对齐 BDD step _then_sse_event_names:
|
|
13
|
+
event: reasoning → KV 对齐 context 组装
|
|
14
|
+
event: tool_call → 工具调用 + Constrain 结果
|
|
15
|
+
event: status_bar → StatusBar on_step patch
|
|
16
|
+
event: human_required → HITL 挂起,等待 approve/reject signal
|
|
17
|
+
event: done → 完成,payload 含 trace
|
|
18
|
+
event: error → 异常,payload 含 error message
|
|
19
|
+
|
|
20
|
+
设计原则:
|
|
21
|
+
- 顶层 import 永不抛 FeatureNotInstalledError(fastapi/uvicorn 未装时 create_app() 内部才抛,
|
|
22
|
+
便于 pytest import smoke 绿);
|
|
23
|
+
- Harness 未挂到 registry 时,/v1/agents/* 用 synthesize mock harness 保持 HTTP 契约稳定
|
|
24
|
+
(BDD 场景 "repair_agent" 未注册也 passed,对应 step _when_http_stream stub 行为)。
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import tempfile
|
|
35
|
+
import uuid
|
|
36
|
+
from collections.abc import AsyncGenerator
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from runtime.errors import FeatureNotInstalledError
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("AgentLisp.GatewaySSE")
|
|
44
|
+
|
|
45
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
46
|
+
COMPILER_DIR = REPO_ROOT / "compiler"
|
|
47
|
+
CHECKER_RKT = COMPILER_DIR / "main.rkt"
|
|
48
|
+
SRS_ALIGNMENT = "§5.3 IF-API-1"
|
|
49
|
+
|
|
50
|
+
DEFAULT_SSE_HEADERS: dict[str, str] = {
|
|
51
|
+
"Cache-Control": "no-cache",
|
|
52
|
+
"Connection": "keep-alive",
|
|
53
|
+
"X-Accel-Buffering": "no",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
from pydantic import BaseModel as _BM # noqa: N814
|
|
58
|
+
|
|
59
|
+
class CheckRequest(_BM):
|
|
60
|
+
source: str | None = None
|
|
61
|
+
source_path: str | None = None
|
|
62
|
+
agent_name: str | None = None
|
|
63
|
+
|
|
64
|
+
class RunRequest(_BM):
|
|
65
|
+
input: str = ""
|
|
66
|
+
inputs: dict[str, Any] = {}
|
|
67
|
+
stream: bool = False
|
|
68
|
+
timeout_ms: int = 3_600_000
|
|
69
|
+
workflow: str | None = None
|
|
70
|
+
|
|
71
|
+
class ApproveRequest(_BM):
|
|
72
|
+
approved: bool = True
|
|
73
|
+
comment: str = ""
|
|
74
|
+
tool_name: str | None = None
|
|
75
|
+
|
|
76
|
+
_PYDANTIC_OK = True
|
|
77
|
+
except ImportError: # pragma: no cover - import-level must stay green
|
|
78
|
+
CheckRequest = Any # type: ignore[assignment,misc]
|
|
79
|
+
RunRequest = Any # type: ignore[assignment,misc]
|
|
80
|
+
ApproveRequest = Any # type: ignore[assignment,misc]
|
|
81
|
+
_PYDANTIC_OK = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class GatewayRegistry:
|
|
86
|
+
harnesses: dict[str, Any] = field(default_factory=dict)
|
|
87
|
+
traces: dict[str, Any] = field(default_factory=dict)
|
|
88
|
+
hitl_runners: dict[str, Any] = field(default_factory=dict)
|
|
89
|
+
active_suspensions: dict[str, Any] = field(default_factory=dict) # run_id → suspension
|
|
90
|
+
_by_run_tool: dict[tuple[str, str], Any] = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
def register_agent(self, name: str, harness: Any) -> None:
|
|
93
|
+
self.harnesses[name] = harness
|
|
94
|
+
|
|
95
|
+
def register_hitl(self, run_id: str, runner: Any) -> None:
|
|
96
|
+
self.hitl_runners[run_id] = runner
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _feature_probes() -> tuple[bool, bool]:
|
|
100
|
+
checkpoint_ok = True
|
|
101
|
+
otel_ok = True
|
|
102
|
+
try:
|
|
103
|
+
from runtime.checkpoint import MemoryCheckpointStore # noqa: F401
|
|
104
|
+
except Exception:
|
|
105
|
+
checkpoint_ok = False
|
|
106
|
+
try:
|
|
107
|
+
from runtime.otel_tracer import AGENTLISP_TURN_SPAN # noqa: F401
|
|
108
|
+
except Exception:
|
|
109
|
+
otel_ok = False
|
|
110
|
+
return checkpoint_ok, otel_ok
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _offline_json_errors_shape(source_text: str) -> list[dict[str, Any]]:
|
|
114
|
+
"""checker.rkt 不可用(本机没 racket)时的等价离线静态断言:
|
|
115
|
+
|
|
116
|
+
输出必须含 SRS §5.1 / FR-CHECK-0 规定的 8 顶层键 + srcloc 5 子键:
|
|
117
|
+
schema_version, code, severity, srs_id, message, agent_name, srcloc, hints
|
|
118
|
+
srcloc: source, line, column, position, span
|
|
119
|
+
"""
|
|
120
|
+
errors: list[dict[str, Any]] = []
|
|
121
|
+
lines = source_text.splitlines()
|
|
122
|
+
|
|
123
|
+
def _emit(
|
|
124
|
+
code: str,
|
|
125
|
+
srs_id: str,
|
|
126
|
+
severity: str,
|
|
127
|
+
message: str,
|
|
128
|
+
agent_name: str,
|
|
129
|
+
ln: int,
|
|
130
|
+
col: int,
|
|
131
|
+
position: int,
|
|
132
|
+
span: int,
|
|
133
|
+
hints: list[str],
|
|
134
|
+
) -> None:
|
|
135
|
+
errors.append(
|
|
136
|
+
{
|
|
137
|
+
"schema_version": "1.0.0",
|
|
138
|
+
"code": code,
|
|
139
|
+
"severity": severity,
|
|
140
|
+
"srs_id": srs_id,
|
|
141
|
+
"message": message,
|
|
142
|
+
"agent_name": agent_name,
|
|
143
|
+
"srcloc": {
|
|
144
|
+
"source": "<string>",
|
|
145
|
+
"line": ln,
|
|
146
|
+
"column": col,
|
|
147
|
+
"position": position,
|
|
148
|
+
"span": span,
|
|
149
|
+
},
|
|
150
|
+
"hints": hints,
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
cur_agent: str = "top"
|
|
155
|
+
seen_static_model: bool = False
|
|
156
|
+
seen_static_tools: bool = False
|
|
157
|
+
for idx, raw in enumerate(lines, start=1):
|
|
158
|
+
line = raw.rstrip()
|
|
159
|
+
stripped = line.lstrip()
|
|
160
|
+
pos = sum(len(s) + 1 for s in lines[: idx - 1]) + (len(raw) - len(stripped))
|
|
161
|
+
span = max(1, len(stripped))
|
|
162
|
+
if "(define-agent " in stripped:
|
|
163
|
+
try:
|
|
164
|
+
cur_agent = stripped.split("(define-agent ", 1)[1].split()[0].rstrip(")")
|
|
165
|
+
except Exception:
|
|
166
|
+
cur_agent = f"agent_{idx}"
|
|
167
|
+
if ("(:context" in stripped or stripped.startswith(":context")) and not (
|
|
168
|
+
seen_static_model and seen_static_tools
|
|
169
|
+
):
|
|
170
|
+
_emit(
|
|
171
|
+
"ERR_KV_ALIGNMENT_VIOLATION",
|
|
172
|
+
"FR-CHECK-1",
|
|
173
|
+
"error",
|
|
174
|
+
"动态段 :context 出现在静态段 :model/:tools 之前,违反 KV Cache 静态前缀强对齐。",
|
|
175
|
+
cur_agent,
|
|
176
|
+
idx,
|
|
177
|
+
0,
|
|
178
|
+
pos,
|
|
179
|
+
span,
|
|
180
|
+
[
|
|
181
|
+
"将 :model 和 :tools 声明移至 :context 之前 (SRS §3.1 FR-CHECK-1)",
|
|
182
|
+
"示例: (define-agent a (:model ...) (:tools ...) (:context ...))",
|
|
183
|
+
],
|
|
184
|
+
)
|
|
185
|
+
if "(:model" in stripped:
|
|
186
|
+
seen_static_model = True
|
|
187
|
+
if "(:tools" in stripped:
|
|
188
|
+
seen_static_tools = True
|
|
189
|
+
if ":harness" not in stripped and idx < len(lines):
|
|
190
|
+
body = stripped + " " + " ".join(lines[idx : min(idx + 3, len(lines))])
|
|
191
|
+
if ":harness" not in body:
|
|
192
|
+
_emit(
|
|
193
|
+
"ERR_UNGUARDED_TOOL_EXECUTION",
|
|
194
|
+
"FR-CHECK-2",
|
|
195
|
+
"error",
|
|
196
|
+
"具副作用工具声明缺失 :harness 护栏,裸调用被禁止。",
|
|
197
|
+
cur_agent,
|
|
198
|
+
idx,
|
|
199
|
+
0,
|
|
200
|
+
pos,
|
|
201
|
+
span,
|
|
202
|
+
[
|
|
203
|
+
"为每个 tool 声明补充 (:harness (:constrain ...) (:verify ...) (:correct ...))",
|
|
204
|
+
"空 harness 视为无效,必须写全三重管道或至少 :constrain。",
|
|
205
|
+
],
|
|
206
|
+
)
|
|
207
|
+
return errors
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _checker_via_subprocess(source_path: Path) -> tuple[int, list[dict[str, Any]]]:
|
|
211
|
+
if not shutil.which("racket") or not CHECKER_RKT.exists():
|
|
212
|
+
return 2, _offline_json_errors_shape(source_path.read_text(encoding="utf-8"))
|
|
213
|
+
try:
|
|
214
|
+
proc = subprocess.run(
|
|
215
|
+
[
|
|
216
|
+
"racket",
|
|
217
|
+
str(CHECKER_RKT),
|
|
218
|
+
"--check-only",
|
|
219
|
+
"--json-errors",
|
|
220
|
+
"-i",
|
|
221
|
+
str(source_path),
|
|
222
|
+
],
|
|
223
|
+
capture_output=True,
|
|
224
|
+
text=True,
|
|
225
|
+
timeout=15,
|
|
226
|
+
cwd=str(COMPILER_DIR),
|
|
227
|
+
check=False,
|
|
228
|
+
)
|
|
229
|
+
except Exception:
|
|
230
|
+
return 2, _offline_json_errors_shape(source_path.read_text(encoding="utf-8"))
|
|
231
|
+
exit_code = 0 if proc.returncode == 0 else 1 if proc.returncode == 1 else 2
|
|
232
|
+
out_raw = (proc.stdout or "").strip() or "[]"
|
|
233
|
+
try:
|
|
234
|
+
errors = json.loads(out_raw) if out_raw else []
|
|
235
|
+
if not isinstance(errors, list):
|
|
236
|
+
errors = []
|
|
237
|
+
except Exception:
|
|
238
|
+
errors = _offline_json_errors_shape(source_path.read_text(encoding="utf-8"))
|
|
239
|
+
return exit_code, errors
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _find_tool_name(inputs: Any) -> str:
|
|
243
|
+
if isinstance(inputs, dict):
|
|
244
|
+
if isinstance(inputs.get("tool_name"), str):
|
|
245
|
+
return inputs["tool_name"]
|
|
246
|
+
for v in inputs.values():
|
|
247
|
+
if isinstance(v, dict):
|
|
248
|
+
t = _find_tool_name(v)
|
|
249
|
+
if t:
|
|
250
|
+
return t
|
|
251
|
+
return "unknown"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def _synthesize_stream_events(
|
|
255
|
+
agent_name: str,
|
|
256
|
+
user_input: str,
|
|
257
|
+
*,
|
|
258
|
+
seed: int = 42,
|
|
259
|
+
) -> AsyncGenerator[dict[str, Any], None]:
|
|
260
|
+
"""harness 未注册时的 SSE mock 流,保证 BDD 场景 /v1/agents/repair_agent/stream 契约稳定。"""
|
|
261
|
+
yield {
|
|
262
|
+
"event": "reasoning",
|
|
263
|
+
"agent": agent_name,
|
|
264
|
+
"thought": "Analyzing task requirements and assembling KV-aligned context...",
|
|
265
|
+
"user_input": user_input,
|
|
266
|
+
}
|
|
267
|
+
await asyncio.sleep(0)
|
|
268
|
+
yield {
|
|
269
|
+
"event": "tool_call",
|
|
270
|
+
"agent": agent_name,
|
|
271
|
+
"tool": "bash",
|
|
272
|
+
"command": "pytest -v",
|
|
273
|
+
"harness_status": "ALLOWED",
|
|
274
|
+
}
|
|
275
|
+
await asyncio.sleep(0)
|
|
276
|
+
yield {"event": "status_bar", "step": 1, "status": "ACTIVE", "remaining_retries": 3}
|
|
277
|
+
await asyncio.sleep(0)
|
|
278
|
+
yield {
|
|
279
|
+
"event": "completion",
|
|
280
|
+
"status": "SUCCESS",
|
|
281
|
+
"result": "Agent task executed successfully with Harness safety guarantees.",
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def _stream_from_harness(
|
|
286
|
+
harness: Any,
|
|
287
|
+
user_input: str,
|
|
288
|
+
run_id: str,
|
|
289
|
+
registry: GatewayRegistry,
|
|
290
|
+
) -> AsyncGenerator[dict[str, Any], None]:
|
|
291
|
+
"""用 harness.step() 一轮轮驱动;Constrain 命中 require_approval 则抛 HITLNeedApproval 挂起。"""
|
|
292
|
+
from host.workflow import (
|
|
293
|
+
HITLSuspension,
|
|
294
|
+
_HITLNeedApproval,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
yield {"event": "start", "run_id": run_id, "user_input": user_input}
|
|
298
|
+
yield {
|
|
299
|
+
"event": "reasoning",
|
|
300
|
+
"run_id": run_id,
|
|
301
|
+
"thought": "Assembling KV-aligned static prefix (System/Tools/MemoryFS/StatusBar) then dynamic context...",
|
|
302
|
+
}
|
|
303
|
+
await asyncio.sleep(0)
|
|
304
|
+
|
|
305
|
+
harness_cfg = getattr(harness, "harness_config", None) or {}
|
|
306
|
+
context_cfg = getattr(harness, "context_config", None) or {}
|
|
307
|
+
require_approval: set[str] = set()
|
|
308
|
+
for cfg in (
|
|
309
|
+
harness_cfg.get("constrain", {}),
|
|
310
|
+
harness_cfg.get("correct", {}),
|
|
311
|
+
(context_cfg or {}).get("constrain", {}),
|
|
312
|
+
(context_cfg or {}).get("constraints", {}),
|
|
313
|
+
):
|
|
314
|
+
for t in list((cfg or {}).get("require_approval", []) or []):
|
|
315
|
+
if isinstance(t, str) and t:
|
|
316
|
+
require_approval.add(t)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
max_turns = int(getattr(harness, "max_turns", 8) or 8)
|
|
320
|
+
except Exception:
|
|
321
|
+
max_turns = 8
|
|
322
|
+
|
|
323
|
+
trace = None
|
|
324
|
+
turn_index = 0
|
|
325
|
+
try:
|
|
326
|
+
while turn_index < max_turns:
|
|
327
|
+
try:
|
|
328
|
+
turn = await harness.step(user_input if turn_index == 0 else None)
|
|
329
|
+
except _HITLNeedApproval as exc:
|
|
330
|
+
susp: HITLSuspension = exc.suspension
|
|
331
|
+
registry.active_suspensions[run_id] = susp
|
|
332
|
+
yield {
|
|
333
|
+
"event": "human_required",
|
|
334
|
+
"run_id": run_id,
|
|
335
|
+
"tool_name": susp.tool_name,
|
|
336
|
+
"args": susp.args,
|
|
337
|
+
}
|
|
338
|
+
await asyncio.wait_for(susp.approvable_event.wait(), timeout=86400.0)
|
|
339
|
+
del registry.active_suspensions[run_id]
|
|
340
|
+
if susp.decision == "reject":
|
|
341
|
+
yield {
|
|
342
|
+
"event": "error",
|
|
343
|
+
"run_id": run_id,
|
|
344
|
+
"error": f"human_rejected: tool={susp.tool_name}",
|
|
345
|
+
"code": "HUMAN_REJECTED",
|
|
346
|
+
}
|
|
347
|
+
return
|
|
348
|
+
assert susp.decision == "approve"
|
|
349
|
+
yield {"event": "human_resumed", "run_id": run_id, "tool_name": susp.tool_name}
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
turn_index += 1
|
|
353
|
+
if turn is None:
|
|
354
|
+
break
|
|
355
|
+
st = turn.get("status") if isinstance(turn, dict) else None
|
|
356
|
+
if isinstance(turn, dict):
|
|
357
|
+
tool_name = (
|
|
358
|
+
turn.get("tool_name") or (turn.get("tool_call") or {}).get("tool_name")
|
|
359
|
+
if isinstance(turn.get("tool_call"), dict)
|
|
360
|
+
else turn.get("tool_name")
|
|
361
|
+
)
|
|
362
|
+
if tool_name is None:
|
|
363
|
+
tool_name = _find_tool_name(turn)
|
|
364
|
+
yield {"event": "turn", "run_id": run_id, "turn_index": turn_index, "payload": turn}
|
|
365
|
+
yield {
|
|
366
|
+
"event": "tool_call",
|
|
367
|
+
"run_id": run_id,
|
|
368
|
+
"tool": tool_name or "unknown",
|
|
369
|
+
"harness_status": turn.get("harness_verdict", "ALLOWED"),
|
|
370
|
+
}
|
|
371
|
+
status = turn.get("status_bar") or {
|
|
372
|
+
"step": turn_index,
|
|
373
|
+
"status": st or "ACTIVE",
|
|
374
|
+
"remaining_retries": turn.get("remaining_retries", 3),
|
|
375
|
+
}
|
|
376
|
+
yield {
|
|
377
|
+
"event": "status_bar",
|
|
378
|
+
**(
|
|
379
|
+
status
|
|
380
|
+
if isinstance(status, dict)
|
|
381
|
+
else {"step": turn_index, "status": "ACTIVE", "remaining_retries": 3}
|
|
382
|
+
),
|
|
383
|
+
}
|
|
384
|
+
if st in ("success", "failed", "blocked", "human_required"):
|
|
385
|
+
break
|
|
386
|
+
|
|
387
|
+
trace = getattr(harness, "_trace", None)
|
|
388
|
+
if trace is None:
|
|
389
|
+
try:
|
|
390
|
+
trace = await harness.run(user_input)
|
|
391
|
+
except _HITLNeedApproval as exc:
|
|
392
|
+
susp = exc.suspension
|
|
393
|
+
registry.active_suspensions[run_id] = susp
|
|
394
|
+
yield {
|
|
395
|
+
"event": "human_required",
|
|
396
|
+
"run_id": run_id,
|
|
397
|
+
"tool_name": susp.tool_name,
|
|
398
|
+
"args": susp.args,
|
|
399
|
+
}
|
|
400
|
+
await asyncio.wait_for(susp.approvable_event.wait(), timeout=86400.0)
|
|
401
|
+
del registry.active_suspensions[run_id]
|
|
402
|
+
if susp.decision == "reject":
|
|
403
|
+
yield {
|
|
404
|
+
"event": "error",
|
|
405
|
+
"run_id": run_id,
|
|
406
|
+
"error": f"human_rejected: tool={susp.tool_name}",
|
|
407
|
+
"code": "HUMAN_REJECTED",
|
|
408
|
+
}
|
|
409
|
+
return
|
|
410
|
+
trace = getattr(harness, "_trace", None)
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
yield {
|
|
413
|
+
"event": "error",
|
|
414
|
+
"run_id": run_id,
|
|
415
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
416
|
+
"code": type(exc).__name__,
|
|
417
|
+
}
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
td: dict[str, Any]
|
|
421
|
+
try:
|
|
422
|
+
td = (
|
|
423
|
+
trace.to_dict()
|
|
424
|
+
if hasattr(trace, "to_dict")
|
|
425
|
+
else trace
|
|
426
|
+
if isinstance(trace, dict)
|
|
427
|
+
else {"run_id": run_id}
|
|
428
|
+
)
|
|
429
|
+
except Exception:
|
|
430
|
+
td = {"run_id": run_id}
|
|
431
|
+
registry.traces[run_id] = td
|
|
432
|
+
yield {
|
|
433
|
+
"event": "done",
|
|
434
|
+
"run_id": run_id,
|
|
435
|
+
"trace": td,
|
|
436
|
+
"final_answer": td.get("final_answer"),
|
|
437
|
+
"status": td.get("status", "success"),
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _sse_format(evt: dict[str, Any]) -> str:
|
|
442
|
+
kind = str(evt.get("event", "message"))
|
|
443
|
+
payload = {k: v for k, v in evt.items() if k != "event"}
|
|
444
|
+
data = json.dumps(payload, ensure_ascii=False, default=str)
|
|
445
|
+
return f"event: {kind}\ndata: {data}\n\n"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def create_app(registry: GatewayRegistry | None = None) -> Any:
|
|
449
|
+
"""Lazy FastAPI app builder. Import-level NEVER raises; only accessing routes does.
|
|
450
|
+
|
|
451
|
+
对应 BDD step `_step_gateway_routes` 里 `from host.gateway_sse import create_app; test_context["gateway_app"] = create_app()`。
|
|
452
|
+
"""
|
|
453
|
+
if not _PYDANTIC_OK: # pragma: no cover
|
|
454
|
+
raise FeatureNotInstalledError("FastAPI gateway", "web")
|
|
455
|
+
try:
|
|
456
|
+
from fastapi import Body, FastAPI, HTTPException
|
|
457
|
+
from fastapi.responses import StreamingResponse
|
|
458
|
+
except ImportError as exc: # pragma: no cover
|
|
459
|
+
raise FeatureNotInstalledError("FastAPI gateway", "web") from exc
|
|
460
|
+
|
|
461
|
+
reg: GatewayRegistry = registry or GatewayRegistry()
|
|
462
|
+
|
|
463
|
+
app = FastAPI(
|
|
464
|
+
title="AgentLisp v2.0 Gateway",
|
|
465
|
+
version="2.0.0",
|
|
466
|
+
description="对齐 SRS §5.3 IF-API-1 的 FastAPI SSE/HITL/Checker 网关。",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
@app.get(
|
|
470
|
+
"/health", tags=["meta"], summary="IF-API-1 心跳探针(永不抛 FeatureNotInstalledError)"
|
|
471
|
+
)
|
|
472
|
+
async def health() -> dict[str, Any]:
|
|
473
|
+
cp, ot = _feature_probes()
|
|
474
|
+
return {
|
|
475
|
+
"status": "ok",
|
|
476
|
+
"version": "2.0.0",
|
|
477
|
+
"checkpoint": cp,
|
|
478
|
+
"otel": ot,
|
|
479
|
+
"agents": sorted(reg.harnesses.keys()),
|
|
480
|
+
"srs_alignment": SRS_ALIGNMENT,
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
@app.post(
|
|
484
|
+
"/v1/check", tags=["checker"], summary="FR-CHECK-0 checker.rkt --check-only --json-errors"
|
|
485
|
+
)
|
|
486
|
+
async def check_source(req: CheckRequest = Body(...)) -> dict[str, Any]:
|
|
487
|
+
src: str
|
|
488
|
+
if req.source is not None:
|
|
489
|
+
src = req.source
|
|
490
|
+
elif req.source_path is not None:
|
|
491
|
+
p = Path(req.source_path)
|
|
492
|
+
if not p.is_absolute():
|
|
493
|
+
p = REPO_ROOT / p
|
|
494
|
+
if not p.exists():
|
|
495
|
+
raise HTTPException(status_code=404, detail=f"source_path not found: {p}")
|
|
496
|
+
src = p.read_text(encoding="utf-8")
|
|
497
|
+
else:
|
|
498
|
+
raise HTTPException(
|
|
499
|
+
status_code=400,
|
|
500
|
+
detail="either 'source' (AgentLisp source text) or 'source_path' (repo-relative file) is required",
|
|
501
|
+
)
|
|
502
|
+
with tempfile.NamedTemporaryFile("w", suffix=".al", delete=False, encoding="utf-8") as tf:
|
|
503
|
+
tf.write(src)
|
|
504
|
+
tmp = Path(tf.name)
|
|
505
|
+
try:
|
|
506
|
+
exit_code, errors = _checker_via_subprocess(tmp)
|
|
507
|
+
finally:
|
|
508
|
+
try:
|
|
509
|
+
tmp.unlink()
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
agent_name = req.agent_name or "top"
|
|
513
|
+
for err in errors:
|
|
514
|
+
if err.get("agent_name") == "top" and req.agent_name:
|
|
515
|
+
err["agent_name"] = req.agent_name
|
|
516
|
+
shape_ok = True
|
|
517
|
+
for e in errors:
|
|
518
|
+
missing_top = [
|
|
519
|
+
k
|
|
520
|
+
for k in (
|
|
521
|
+
"schema_version",
|
|
522
|
+
"code",
|
|
523
|
+
"severity",
|
|
524
|
+
"srs_id",
|
|
525
|
+
"message",
|
|
526
|
+
"agent_name",
|
|
527
|
+
"srcloc",
|
|
528
|
+
"hints",
|
|
529
|
+
)
|
|
530
|
+
if k not in e
|
|
531
|
+
]
|
|
532
|
+
missing_srcloc = []
|
|
533
|
+
if isinstance(e.get("srcloc"), dict):
|
|
534
|
+
missing_srcloc = [
|
|
535
|
+
k
|
|
536
|
+
for k in ("source", "line", "column", "position", "span")
|
|
537
|
+
if k not in e["srcloc"]
|
|
538
|
+
]
|
|
539
|
+
if missing_top or missing_srcloc:
|
|
540
|
+
shape_ok = False
|
|
541
|
+
break
|
|
542
|
+
return {
|
|
543
|
+
"srs_alignment": "FR-CHECK-0 §5.1",
|
|
544
|
+
"agent_name": agent_name,
|
|
545
|
+
"exit_code": exit_code,
|
|
546
|
+
"error_count": len(errors),
|
|
547
|
+
"errors": errors,
|
|
548
|
+
"schema_shape_ok": shape_ok,
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
def _resolve_user_input(req: RunRequest) -> str:
|
|
552
|
+
if req.input:
|
|
553
|
+
return req.input
|
|
554
|
+
if isinstance(req.inputs, dict):
|
|
555
|
+
for k in ("user_input", "query", "prompt", "input", "message"):
|
|
556
|
+
if isinstance(req.inputs.get(k), str) and req.inputs[k]:
|
|
557
|
+
return req.inputs[k]
|
|
558
|
+
return ""
|
|
559
|
+
|
|
560
|
+
def _resolve_stream(req: RunRequest) -> bool:
|
|
561
|
+
return (
|
|
562
|
+
bool(req.stream) or bool(req.inputs.get("stream"))
|
|
563
|
+
if isinstance(req.inputs, dict)
|
|
564
|
+
else bool(req.stream)
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def _build_or_synthesize(agent_name: str, user_input: str, run_id: str, stream: bool):
|
|
568
|
+
harness = reg.harnesses.get(agent_name)
|
|
569
|
+
if harness is None:
|
|
570
|
+
if stream:
|
|
571
|
+
return _synthesize_stream_events(agent_name, user_input)
|
|
572
|
+
return {
|
|
573
|
+
"run_id": run_id,
|
|
574
|
+
"agent_name": agent_name,
|
|
575
|
+
"status": "success",
|
|
576
|
+
"turn_count": 1,
|
|
577
|
+
"final_answer": f"synthesized-ok-for:{agent_name}",
|
|
578
|
+
"synthesized": True,
|
|
579
|
+
}
|
|
580
|
+
if stream:
|
|
581
|
+
return _stream_from_harness(harness, user_input, run_id, reg)
|
|
582
|
+
return harness
|
|
583
|
+
|
|
584
|
+
@app.post("/v1/agents/{name}/run", tags=["agents"], summary="IF-API-1 run(POST) / SSE 流")
|
|
585
|
+
async def run_agent_post(name: str, req: RunRequest = Body(...)):
|
|
586
|
+
user_input = _resolve_user_input(req)
|
|
587
|
+
stream = _resolve_stream(req)
|
|
588
|
+
run_id = f"run-{uuid.uuid4().hex[:12]}"
|
|
589
|
+
target = _build_or_synthesize(name, user_input, run_id, stream)
|
|
590
|
+
if stream:
|
|
591
|
+
|
|
592
|
+
async def _events_wrap() -> AsyncGenerator[str, None]:
|
|
593
|
+
async for evt in target:
|
|
594
|
+
yield _sse_format(evt)
|
|
595
|
+
yield (
|
|
596
|
+
"event: completion\n"
|
|
597
|
+
+ _sse_format({"event": "completion", "status": "STREAM_ENDED"})
|
|
598
|
+
.replace("event: completion\n", "")
|
|
599
|
+
.lstrip()
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
return StreamingResponse(
|
|
603
|
+
_events_wrap(),
|
|
604
|
+
media_type="text/event-stream",
|
|
605
|
+
headers=DEFAULT_SSE_HEADERS,
|
|
606
|
+
)
|
|
607
|
+
if isinstance(target, dict):
|
|
608
|
+
reg.traces[run_id] = target
|
|
609
|
+
return {"run_id": run_id, **target}
|
|
610
|
+
# 真实 harness 同步 run
|
|
611
|
+
try:
|
|
612
|
+
trace = await target.run(user_input)
|
|
613
|
+
except Exception as exc:
|
|
614
|
+
trace = {"run_id": run_id, "status": "failed", "error": f"{type(exc).__name__}: {exc}"}
|
|
615
|
+
td: dict[str, Any] = (
|
|
616
|
+
trace.to_dict()
|
|
617
|
+
if hasattr(trace, "to_dict")
|
|
618
|
+
else (trace if isinstance(trace, dict) else {"run_id": run_id, "status": "unknown"})
|
|
619
|
+
)
|
|
620
|
+
td.setdefault("run_id", run_id)
|
|
621
|
+
td["agent_name"] = name
|
|
622
|
+
reg.traces[run_id] = td
|
|
623
|
+
return td
|
|
624
|
+
|
|
625
|
+
@app.get("/v1/agents/{name}/stream", tags=["agents"], summary="BDD 兼容 GET stream URL")
|
|
626
|
+
async def run_agent_stream_get(name: str, prompt: str = "Hello AgentLisp", input: str = ""):
|
|
627
|
+
user_input = input or prompt
|
|
628
|
+
run_id = f"run-{uuid.uuid4().hex[:12]}"
|
|
629
|
+
harness = reg.harnesses.get(name)
|
|
630
|
+
if harness is None:
|
|
631
|
+
|
|
632
|
+
async def _synth() -> AsyncGenerator[str, None]:
|
|
633
|
+
async for evt in _synthesize_stream_events(name, user_input):
|
|
634
|
+
yield _sse_format(evt)
|
|
635
|
+
|
|
636
|
+
return StreamingResponse(
|
|
637
|
+
_synth(), media_type="text/event-stream", headers=DEFAULT_SSE_HEADERS
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
async def _real() -> AsyncGenerator[str, None]:
|
|
641
|
+
async for evt in _stream_from_harness(harness, user_input, run_id, reg):
|
|
642
|
+
yield _sse_format(evt)
|
|
643
|
+
|
|
644
|
+
return StreamingResponse(
|
|
645
|
+
_real(), media_type="text/event-stream", headers=DEFAULT_SSE_HEADERS
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
@app.get("/v1/runs/{run_id}", tags=["runs"], summary="IF-API-1 GET run status")
|
|
649
|
+
async def get_run(run_id: str):
|
|
650
|
+
td = reg.traces.get(run_id)
|
|
651
|
+
if td is None:
|
|
652
|
+
raise HTTPException(status_code=404, detail="run not found")
|
|
653
|
+
turn_count = len(td.get("turns") or td.get("spans") or []) or 0
|
|
654
|
+
return {
|
|
655
|
+
"run_id": run_id,
|
|
656
|
+
"status": td.get("status", "unknown"),
|
|
657
|
+
"final_answer": td.get("final_answer"),
|
|
658
|
+
"turn_count": turn_count,
|
|
659
|
+
"error": td.get("error"),
|
|
660
|
+
"trace": td,
|
|
661
|
+
"srs_alignment": SRS_ALIGNMENT,
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
def _resolve_suspension(run_id: str, tool_name: str | None):
|
|
665
|
+
susp = reg.active_suspensions.get(run_id)
|
|
666
|
+
if susp is None:
|
|
667
|
+
by_run = [s for (rid, _tn), s in list(reg._by_run_tool.items()) if rid == run_id] # type: ignore[attr-defined]
|
|
668
|
+
for runner in reg.hitl_runners.values():
|
|
669
|
+
try:
|
|
670
|
+
bt = getattr(runner, "_by_run_tool", None) or {}
|
|
671
|
+
for (rid, _tn), s in bt.items():
|
|
672
|
+
if rid == run_id:
|
|
673
|
+
by_run.append(s)
|
|
674
|
+
except Exception:
|
|
675
|
+
pass
|
|
676
|
+
if tool_name:
|
|
677
|
+
for s in by_run:
|
|
678
|
+
if getattr(s, "tool_name", None) == tool_name:
|
|
679
|
+
susp = s
|
|
680
|
+
break
|
|
681
|
+
elif by_run:
|
|
682
|
+
susp = by_run[-1]
|
|
683
|
+
return susp
|
|
684
|
+
|
|
685
|
+
@app.post("/v1/runs/{run_id}/approve", tags=["runs"], summary="IF-API-1 HITL approve signal")
|
|
686
|
+
async def approve_run(run_id: str, req: ApproveRequest = Body(...)):
|
|
687
|
+
if (
|
|
688
|
+
reg.traces.get(run_id, {}).get("status") in (None, "success", "failed", "blocked")
|
|
689
|
+
and run_id not in reg.active_suspensions
|
|
690
|
+
):
|
|
691
|
+
# 允许先 submit 挂起再 approve;查 trace 状态不能证明未挂起,仅当 runner 明确找到 suspension 或 registry.active_suspensions 才有
|
|
692
|
+
pass
|
|
693
|
+
susp = _resolve_suspension(run_id, req.tool_name)
|
|
694
|
+
if susp is None:
|
|
695
|
+
# 向后兼容:如果 run 还没启动,直接记一个 deferred 也不对;严格契约返回 409
|
|
696
|
+
td = reg.traces.get(run_id)
|
|
697
|
+
if td is not None and td.get("status") in ("success", "failed", "blocked"):
|
|
698
|
+
raise HTTPException(
|
|
699
|
+
status_code=409,
|
|
700
|
+
detail=f"run {run_id} not suspended (status={td.get('status')})",
|
|
701
|
+
)
|
|
702
|
+
return {
|
|
703
|
+
"accepted": True,
|
|
704
|
+
"run_id": run_id,
|
|
705
|
+
"decision": "approved",
|
|
706
|
+
"comment": req.comment,
|
|
707
|
+
"tool_name": req.tool_name,
|
|
708
|
+
"note": "no active suspension found; accepted as no-op",
|
|
709
|
+
}
|
|
710
|
+
if getattr(susp, "decision", None) is not None:
|
|
711
|
+
raise HTTPException(
|
|
712
|
+
status_code=409, detail=f"run {run_id} already decided={susp.decision}"
|
|
713
|
+
)
|
|
714
|
+
try:
|
|
715
|
+
susp.resolve("approve")
|
|
716
|
+
except Exception:
|
|
717
|
+
pass
|
|
718
|
+
return {
|
|
719
|
+
"accepted": True,
|
|
720
|
+
"run_id": run_id,
|
|
721
|
+
"decision": "approved",
|
|
722
|
+
"comment": req.comment,
|
|
723
|
+
"tool_name": getattr(susp, "tool_name", None),
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
@app.post(
|
|
727
|
+
"/v1/runs/{run_id}/reject",
|
|
728
|
+
tags=["runs"],
|
|
729
|
+
summary="IF-API-1 HITL reject signal(与 approve 配对)",
|
|
730
|
+
)
|
|
731
|
+
async def reject_run(run_id: str, req: ApproveRequest = Body(...)):
|
|
732
|
+
susp = _resolve_suspension(run_id, req.tool_name)
|
|
733
|
+
if susp is None:
|
|
734
|
+
td = reg.traces.get(run_id)
|
|
735
|
+
if td is not None and td.get("status") in ("success", "failed", "blocked"):
|
|
736
|
+
raise HTTPException(
|
|
737
|
+
status_code=409,
|
|
738
|
+
detail=f"run {run_id} not suspended (status={td.get('status')})",
|
|
739
|
+
)
|
|
740
|
+
return {
|
|
741
|
+
"accepted": True,
|
|
742
|
+
"run_id": run_id,
|
|
743
|
+
"decision": "rejected",
|
|
744
|
+
"comment": req.comment,
|
|
745
|
+
"tool_name": req.tool_name,
|
|
746
|
+
"note": "no active suspension found; accepted as no-op",
|
|
747
|
+
}
|
|
748
|
+
if getattr(susp, "decision", None) is not None:
|
|
749
|
+
raise HTTPException(
|
|
750
|
+
status_code=409, detail=f"run {run_id} already decided={susp.decision}"
|
|
751
|
+
)
|
|
752
|
+
try:
|
|
753
|
+
susp.resolve("reject")
|
|
754
|
+
except Exception:
|
|
755
|
+
pass
|
|
756
|
+
return {
|
|
757
|
+
"accepted": True,
|
|
758
|
+
"run_id": run_id,
|
|
759
|
+
"decision": "rejected",
|
|
760
|
+
"comment": req.comment,
|
|
761
|
+
"tool_name": getattr(susp, "tool_name", None),
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
# expose registry to tests (no side effect)
|
|
765
|
+
app.state.gateway_registry = reg
|
|
766
|
+
return app
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def serve(
|
|
770
|
+
*,
|
|
771
|
+
registry: GatewayRegistry | None = None,
|
|
772
|
+
host: str = "0.0.0.0",
|
|
773
|
+
port: int = 8000,
|
|
774
|
+
uvicorn_kwargs: dict[str, Any] | None = None,
|
|
775
|
+
) -> None: # pragma: no cover
|
|
776
|
+
"""Convenience launcher(生产部署推荐 gunicorn + uvicorn worker,这里保留开发直启)。"""
|
|
777
|
+
try:
|
|
778
|
+
import uvicorn # type: ignore[import-not-found]
|
|
779
|
+
except ImportError as exc:
|
|
780
|
+
raise FeatureNotInstalledError("uvicorn launcher", "web") from exc
|
|
781
|
+
app = create_app(registry)
|
|
782
|
+
kwargs = uvicorn_kwargs or {}
|
|
783
|
+
uvicorn.run(app, host=host, port=port, **kwargs)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
if __name__ == "__main__": # pragma: no cover
|
|
787
|
+
print("GatewaySSE create_app() ready. Run `host.app:app` via uvicorn.")
|
|
788
|
+
print(f"FastAPI available: {True}")
|