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.
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}")