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/workflow.py ADDED
@@ -0,0 +1,803 @@
1
+ """Workflow runner: DirectRunner (always available) + TemporalRunner (optional dep).
2
+
3
+ 额外提供 HITL 人在回路的内存版 InMemoryHITLRunner(NFR-SEC-1b):
4
+ - 若约束里 require_approval 的工具被调用 → status=human_required 且停在 require_approval_queue;
5
+ - 调用 approve(run_id, tool_name) / reject(run_id, tool_name) 后 workflow 继续(或失败)。
6
+
7
+ 新增 CR-15:§3 / §6.2 AC-2 RepairAgentPipeline(4 阶段 Submission→Constrain→Execute→Verify→Correct Loop→Final):
8
+ - on_failure ∈ {ask-human, fallback-model, abort}(对齐 FR-PARSER-5 枚举,自动兼容 ask_human 下划线)
9
+ - ExecutionTrace 落盘,trace.error 精确写明 strategy=on_failure(对齐 FR-RUN-4 Correct 熔断)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import contextlib
16
+ import hashlib
17
+ import os
18
+ import uuid
19
+ from abc import ABC, abstractmethod
20
+ from collections import deque
21
+ from dataclasses import asdict, dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from runtime.base_harness import BaseHarness, ExecutionTrace
26
+ from runtime.errors import FeatureNotInstalledError
27
+
28
+
29
+ @dataclass
30
+ class WorkflowRequest:
31
+ agent_name: str
32
+ harness: BaseHarness
33
+ inputs: dict[str, Any]
34
+ workflow: str | None = None
35
+ run_id: str | None = None
36
+
37
+
38
+ @dataclass
39
+ class HITLSuspension:
40
+ run_id: str
41
+ tool_name: str
42
+ args: dict[str, Any]
43
+ approvable_event: asyncio.Event = field(default_factory=asyncio.Event)
44
+ decision: str | None = None # "approve" | "reject"
45
+
46
+ def resolve(self, decision: str) -> None:
47
+ self.decision = decision
48
+ self.approvable_event.set()
49
+
50
+
51
+ class WorkflowRunner(ABC):
52
+ @abstractmethod
53
+ async def submit(self, req: WorkflowRequest) -> str: ...
54
+ @abstractmethod
55
+ async def wait(self, run_id: str, timeout: float = 300.0) -> ExecutionTrace: ...
56
+
57
+
58
+ class DirectRunner(WorkflowRunner):
59
+ """In-process async runner. Always available — zero external dependencies."""
60
+
61
+ def __init__(self) -> None:
62
+ self.traces: dict[str, ExecutionTrace] = {}
63
+ self._tasks: dict[str, asyncio.Task[ExecutionTrace]] = {}
64
+
65
+ async def submit(self, req: WorkflowRequest) -> str:
66
+ run_id = req.run_id or os.urandom(8).hex()
67
+
68
+ async def _task() -> ExecutionTrace:
69
+ trace = await req.harness.run_async(inputs=req.inputs, workflow=req.workflow)
70
+ self.traces[run_id] = trace
71
+ return trace
72
+
73
+ self._tasks[run_id] = asyncio.create_task(_task())
74
+ return run_id
75
+
76
+ async def wait(self, run_id: str, timeout: float = 300.0) -> ExecutionTrace:
77
+ if run_id in self.traces:
78
+ return self.traces[run_id]
79
+ task = self._tasks.get(run_id)
80
+ if task is None:
81
+ raise KeyError(f"no run_id: {run_id}")
82
+ trace = await asyncio.wait_for(task, timeout=timeout)
83
+ return trace
84
+
85
+
86
+ class InMemoryHITLRunner(WorkflowRunner):
87
+ """SRS NFR-SEC-1b:内存版 HITL Runner,不依赖 temporalio,可本地 pytest。
88
+
89
+ 工作流:
90
+ 1. submit(req) 启动一个长运行 task;
91
+ 2. task 里按 Harness 的顺序每步跑 step(1),
92
+ 在每次 Constrain → Execute → Verify → Correct 前,若 tool_name 在 harness_config/correct/require_approval 里
93
+ 或 constrain_config 的 require_approval 里 → 入队 suspend_queue → status_bar/human_required → 等待 approve();
94
+ 3. approve() → 放行 execute;reject() → 立即 failed,trace.status="failed" 且 error="human_rejected"。
95
+ """
96
+
97
+ def __init__(self) -> None:
98
+ self.traces: dict[str, Any] = {}
99
+ self._tasks: dict[str, asyncio.Task[Any]] = {}
100
+ self.suspend_queue: deque[HITLSuspension] = deque()
101
+ self._by_run_tool: dict[tuple[str, str], HITLSuspension] = {}
102
+ self.events: list[dict[str, Any]] = []
103
+
104
+ def _require_approval_tools(self, harness: Any) -> list[str]:
105
+ tools: list[str] = []
106
+ hcfg = getattr(harness, "harness_config", None) or {}
107
+ ccfg = getattr(harness, "context_config", None) or {}
108
+ correct_cfg = hcfg.get("correct", {}) or {}
109
+ tools.extend(list(correct_cfg.get("require_approval", []) or []))
110
+ constrain_cfg_harness = hcfg.get("constrain", {}) or {}
111
+ tools.extend(list(constrain_cfg_harness.get("require_approval", []) or []))
112
+ constrain_cfg_context = ccfg.get("constrain", {}) or ccfg.get("constraints", {}) or {}
113
+ tools.extend(list(constrain_cfg_context.get("require_approval", []) or []))
114
+ out: list[str] = []
115
+ for t in tools:
116
+ if isinstance(t, str) and t and t not in out:
117
+ out.append(t)
118
+ return out
119
+
120
+ async def submit(self, req: WorkflowRequest) -> str:
121
+ run_id = req.run_id or os.urandom(8).hex()
122
+ harness = req.harness
123
+ user_input = (req.inputs or {}).get("user_input") or (req.inputs or {}).get("query") or ""
124
+
125
+ async def _task() -> Any:
126
+ require_approval_set = set(self._require_approval_tools(harness))
127
+ trace = harness._trace
128
+ # v2 版 Harness 初始化 _trace
129
+ if trace is None and hasattr(harness, "run"):
130
+ trace = await harness.run(user_input)
131
+ self.traces[run_id] = trace
132
+ return trace
133
+ max_turns = int(getattr(harness, "max_turns", 3) or 3)
134
+ for _ in range(max_turns):
135
+ # 在 _react_step 之前:用 v2 接口先看 turn.action 是否命中 require_approval
136
+ # 直接把 Harness 的 _react_step 包一层:注册 approve/reject 钩子
137
+ if require_approval_set:
138
+
139
+ async def _wrapped_react_step(orig_step_fn):
140
+ # 先把 action 从下一个要调用的 turn 里预读?更稳的方法是在 constrain 之后 execute 前:
141
+ # 这里简化:用 status_bar events 反查(但 status_bar 是可观测,不提供 action pre-hook)
142
+ # 最终方法:把 harness.constrain 包一层 Wrapper
143
+ return await orig_step_fn()
144
+
145
+ _ = _wrapped_react_step # 占位;真实路径用另一种:直接跑 run(),在 constrain 里通过 side channel 抛 HITLNeedApproval
146
+ result = await harness._react_step() if hasattr(harness, "_react_step") else None
147
+ if result is None:
148
+ break
149
+ st = result.get("status")
150
+ if st in ("success", "failed", "blocked", "human_required"):
151
+ break
152
+ self.traces[run_id] = harness._trace
153
+ return harness._trace
154
+
155
+ async def _task_via_run() -> Any:
156
+ """为了不依赖 turn.action 的先验,直接在 Constrain 阶段注入 Hook:
157
+ monkeypatch harness.constrain → 命中 require_approval 时放 suspension。"""
158
+ require_approval_set = set(self._require_approval_tools(harness))
159
+ orig_constrain = harness.constrain
160
+ run_state: dict[str, Any] = {"active_suspension": None}
161
+
162
+ def _emit_event(kind: str, **extra: Any) -> None:
163
+ self.events.append({"run_id": run_id, "kind": kind, **extra})
164
+ try:
165
+ sb = getattr(harness, "status_bar", None)
166
+ if sb is not None and hasattr(sb, "on_step"):
167
+ sb.on_step(
168
+ run_id,
169
+ len(getattr(harness, "_trace", None).turns if harness._trace else []),
170
+ kind,
171
+ str(extra),
172
+ )
173
+ except Exception:
174
+ pass
175
+
176
+ def _hooked_constrain(tool_call):
177
+ nonlocal run_state
178
+ allowed, reason = orig_constrain(tool_call)
179
+ tool_name = tool_call.get("tool_name", "") if isinstance(tool_call, dict) else ""
180
+ if not require_approval_set:
181
+ return allowed, reason
182
+ if tool_name in require_approval_set and allowed:
183
+ args = tool_call.get("args", {}) if isinstance(tool_call, dict) else {}
184
+ susp = HITLSuspension(run_id=run_id, tool_name=tool_name, args=args)
185
+ self.suspend_queue.append(susp)
186
+ self._by_run_tool[(run_id, tool_name)] = susp
187
+ run_state["active_suspension"] = susp
188
+ _emit_event("human_required", tool_name=tool_name, args=args)
189
+ # 同步等待审批(在 asyncio 事件循环里:必须改为 yield)。简化:抛 HITLNeedApproval 并在 catch 里 await。
190
+ raise _HITLNeedApproval(susp)
191
+ return allowed, reason
192
+
193
+ harness.constrain = _hooked_constrain # type: ignore[method-assign]
194
+ try:
195
+ while True:
196
+ try:
197
+ trace = await harness.run(user_input)
198
+ self.traces[run_id] = trace
199
+ return trace
200
+ except _HITLNeedApproval as hitl_exc:
201
+ susp = hitl_exc.suspension
202
+ # 等待 approve/reject
203
+ await asyncio.wait_for(susp.approvable_event.wait(), timeout=86_400.0)
204
+ if susp.decision == "reject":
205
+ self.events.append(
206
+ {
207
+ "run_id": run_id,
208
+ "kind": "human_rejected",
209
+ "tool_name": susp.tool_name,
210
+ }
211
+ )
212
+ try:
213
+ harness.status_bar.on_step(
214
+ run_id,
215
+ len(harness._trace.turns) if harness._trace else 0,
216
+ "rejected",
217
+ susp.tool_name,
218
+ )
219
+ except Exception:
220
+ pass
221
+ # 将 trace.status 置 failed + error=human_rejected
222
+ if harness._trace is not None:
223
+ harness._trace.status = "failed"
224
+ harness._trace.error = f"human_rejected: tool={susp.tool_name}"
225
+ return harness._trace
226
+ assert susp.decision == "approve"
227
+ self.events.append(
228
+ {
229
+ "run_id": run_id,
230
+ "kind": "human_approved",
231
+ "tool_name": susp.tool_name,
232
+ }
233
+ )
234
+ # 继续下一循环,此时再次调用 constrain → 同一个 tool_name 会再次命中 require_approval,需要放行一次:
235
+ # 使用 allow_once set
236
+ require_approval_set.discard(susp.tool_name)
237
+ continue
238
+ finally:
239
+ harness.constrain = orig_constrain # type: ignore[method-assign]
240
+
241
+ self._tasks[run_id] = asyncio.create_task(_task_via_run())
242
+ return run_id
243
+
244
+ async def wait(self, run_id: str, timeout: float = 300.0) -> Any:
245
+ if run_id in self.traces:
246
+ return self.traces[run_id]
247
+ task = self._tasks.get(run_id)
248
+ if task is None:
249
+ raise KeyError(f"no run_id: {run_id}")
250
+ trace = await asyncio.wait_for(task, timeout=timeout)
251
+ return trace
252
+
253
+ @contextlib.contextmanager
254
+ def patch_harness_constrain(self, harness: Any) -> Any:
255
+ """用于 BDD / pytest 单测:
256
+ 模拟 `submit(user_input, harness)` 的 Constrain Hook(不真正创建 async task,
257
+ 同步地在 harness.constrain 里注入 Suspension + human_required 事件)。
258
+ 用法:
259
+ runner = InMemoryHITLRunner()
260
+ with runner.patch_harness_constrain(harness):
261
+ harness.constrain({"tool_name": "git-push", ...}) # 抛 _HITLNeedApproval
262
+ """
263
+ require_approval_set = set(self._require_approval_tools(harness))
264
+ orig_constrain = harness.constrain
265
+ run_state: dict[str, Any] = {"active_suspension": None}
266
+ run_id = f"patch-{uuid.uuid4().hex[:8]}"
267
+
268
+ def _hooked(tool_call):
269
+ nonlocal run_state
270
+ allowed, reason = orig_constrain(tool_call)
271
+ tool_name = tool_call.get("tool_name", "") if isinstance(tool_call, dict) else ""
272
+ if not require_approval_set:
273
+ return allowed, reason
274
+ if tool_name in require_approval_set and allowed:
275
+ args = tool_call.get("args", {}) if isinstance(tool_call, dict) else {}
276
+ susp = HITLSuspension(run_id=run_id, tool_name=tool_name, args=args)
277
+ self.suspend_queue.append(susp)
278
+ self._by_run_tool[(run_id, tool_name)] = susp
279
+ run_state["active_suspension"] = susp
280
+ self.events.append(
281
+ {
282
+ "run_id": run_id,
283
+ "kind": "human_required",
284
+ "tool_name": tool_name,
285
+ "args": args,
286
+ }
287
+ )
288
+ raise _HITLNeedApproval(susp)
289
+ return allowed, reason
290
+
291
+ try:
292
+ harness.constrain = _hooked # type: ignore[method-assign]
293
+ yield self
294
+ finally:
295
+ harness.constrain = orig_constrain # type: ignore[method-assign]
296
+
297
+ async def run_approve(
298
+ self,
299
+ harness: Any,
300
+ *,
301
+ user_input: str,
302
+ llm: Any,
303
+ tool_name: str,
304
+ ) -> dict[str, Any]:
305
+ """BDD 便利方法:等价于 submit → (在 HITL 挂起时立刻 approve 一次) → wait。
306
+
307
+ 实现说明:submit 返回 run_id 后立即 asyncio.sleep(0) 让任务跑到第一次 Constrain,
308
+ 接着 approve 一次 suspension,再 wait。返回 trace dict。
309
+ """
310
+ rid = await self.submit(
311
+ WorkflowRequest(
312
+ agent_name=getattr(harness, "agent_name", "bdd-harness"),
313
+ harness=harness,
314
+ inputs={"user_input": user_input, "llm": llm},
315
+ )
316
+ )
317
+ # 最多等 5s 让 suspension 入队(harness 要先跑 constrain hook)
318
+ deadline = asyncio.get_running_loop().time() + 5.0
319
+ while asyncio.get_running_loop().time() < deadline:
320
+ if (rid, tool_name) in self._by_run_tool:
321
+ break
322
+ await asyncio.sleep(0.02)
323
+ await self.approve(rid, tool_name)
324
+ trace = await self.wait(rid, timeout=30.0)
325
+ try:
326
+ td = trace.to_dict() if hasattr(trace, "to_dict") else trace
327
+ return {"status": td.get("status", "unknown"), "trace": td, "run_id": rid}
328
+ except Exception:
329
+ return {"status": getattr(trace, "status", "unknown"), "trace": trace, "run_id": rid}
330
+
331
+ async def approve(self, run_id: str, tool_name: str) -> None:
332
+ key = (run_id, tool_name)
333
+ susp = self._by_run_tool.get(key)
334
+ if susp is None:
335
+ # 兼容:等一会儿让事件产生(HITL suspend 是异步入队的)
336
+ deadline = asyncio.get_running_loop().time() + 5.0
337
+ while asyncio.get_running_loop().time() < deadline:
338
+ susp = self._by_run_tool.get(key)
339
+ if susp is not None:
340
+ break
341
+ await asyncio.sleep(0.05)
342
+ if susp is None:
343
+ raise KeyError(f"no suspension for run_id={run_id} tool={tool_name}")
344
+ susp.resolve("approve")
345
+
346
+ async def reject(self, run_id: str, tool_name: str) -> None:
347
+ key = (run_id, tool_name)
348
+ susp = self._by_run_tool.get(key)
349
+ if susp is None:
350
+ deadline = asyncio.get_running_loop().time() + 5.0
351
+ while asyncio.get_running_loop().time() < deadline:
352
+ susp = self._by_run_tool.get(key)
353
+ if susp is not None:
354
+ break
355
+ await asyncio.sleep(0.05)
356
+ if susp is None:
357
+ raise KeyError(f"no suspension for run_id={run_id} tool={tool_name}")
358
+ susp.resolve("reject")
359
+
360
+
361
+ class _HITLNeedApproval(Exception): # noqa: N818
362
+ def __init__(self, suspension: HITLSuspension) -> None:
363
+ super().__init__(f"HITL approve needed: tool={suspension.tool_name}")
364
+ self.suspension = suspension
365
+
366
+
367
+ class TemporalRunner(WorkflowRunner):
368
+ """Durable execution via Temporal. Requires `uv pip install 'agentlisp[durable]'`."""
369
+
370
+ def __init__(
371
+ self,
372
+ *,
373
+ temporal_client: Any | None = None,
374
+ task_queue: str = "agentlisp",
375
+ namespace: str = "default",
376
+ host_port: str = "localhost:7233",
377
+ ) -> None:
378
+ try:
379
+ import temporalio # noqa: F401 # type: ignore[import-not-found]
380
+ except ImportError as exc:
381
+ raise FeatureNotInstalledError("Temporal runner", "durable") from exc
382
+ self._client = temporal_client
383
+ self.task_queue = task_queue
384
+ self.namespace = namespace
385
+ self.host_port = host_port
386
+
387
+ async def _get_client(self) -> Any: # pragma: no cover - temporal not in CI env
388
+ if self._client is None:
389
+ from temporalio.client import Client # type: ignore[import-not-found]
390
+
391
+ self._client = await Client.connect(target_url=self.host_port, namespace=self.namespace)
392
+ return self._client
393
+
394
+ async def submit(self, req: WorkflowRequest) -> str: # pragma: no cover
395
+ raise NotImplementedError(
396
+ "TemporalRunner.submit skeleton: wire Harness.run_async into a Temporal workflow "
397
+ "once your durable workflow/activities are defined."
398
+ )
399
+
400
+ async def wait(self, run_id: str, timeout: float = 300.0) -> ExecutionTrace: # pragma: no cover
401
+ raise NotImplementedError("TemporalRunner.wait skeleton")
402
+
403
+
404
+ def default_runner(prefer_temporal: bool | None = None) -> WorkflowRunner:
405
+ want = (
406
+ prefer_temporal
407
+ if prefer_temporal is not None
408
+ else (os.getenv("AGENTLISP_RUNNER", "direct").lower() == "temporal")
409
+ )
410
+ if want:
411
+ try:
412
+ return TemporalRunner()
413
+ except FeatureNotInstalledError:
414
+ pass
415
+ return DirectRunner()
416
+
417
+
418
+ # =============================================================================
419
+ # CR-15 §6.2 AC-2:repair_agent 4 阶段 Pipeline 真跑
420
+ # =============================================================================
421
+
422
+ CORRECT_ON_FAILURE_ENUM: set[str] = {
423
+ "ask-human",
424
+ "fallback-model",
425
+ "abort",
426
+ "ask_human",
427
+ "fallback_model",
428
+ }
429
+
430
+
431
+ def _normalize_on_failure(raw: str | None) -> str:
432
+ """FR-PARSER-5 三枚举对齐;ask_human 下划线兼容 ask-human 连字符(SRS 写连字符,Python 写点号易混)。
433
+
434
+ 规范化:ask-human / fallback-model / abort。
435
+ """
436
+ r = (raw or "ask-human").strip().lower().replace("_", "-")
437
+ if r not in CORRECT_ON_FAILURE_ENUM:
438
+ raise ValueError(
439
+ f"correct.on_failure={raw!r} 不在 FR-PARSER-5 枚举 {{ask-human, fallback-model, abort}}"
440
+ )
441
+ return r
442
+
443
+
444
+ @dataclass
445
+ class RepairSubmission:
446
+ """4 阶段第 1 步:Submission,SRS §6.2 repair_agent task。"""
447
+
448
+ target_file: str
449
+ buggy_source: str
450
+ failing_pytest_output: str
451
+ expected_patch_hint: str = ""
452
+ require_approval_tools: list[str] = field(
453
+ default_factory=lambda: ["write-md", "git-push", "apply-patch-builtin"]
454
+ )
455
+ workspace_root: str = "/app"
456
+ rubric_target: float = 0.8
457
+ correct_max_retries: int = 3
458
+ correct_circuit_breaker: int = 5
459
+ on_failure: str = "ask-human" # FR-PARSER-5
460
+ dataset_tag: str = "" # 对齐 τ²-bench v1.0 标识(run_t2_bench 写 "τ²-bench-v1.0")
461
+ sample_id: str = "" # 对应 t2-bench sample_id(pytest 断言用)
462
+
463
+ def fingerprint_sha256(self) -> str:
464
+ payload = f"{self.target_file}|{self.buggy_source}|{self.failing_pytest_output}|{self.expected_patch_hint}"
465
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
466
+
467
+ def to_dict(self) -> dict[str, Any]:
468
+ d = asdict(self)
469
+ d["fingerprint_sha256"] = self.fingerprint_sha256()
470
+ return d
471
+
472
+
473
+ class RepairAgentPipeline:
474
+ """SRS §3 FR-RUN-1 4 阶段严格顺序(Harness 内部 Constrain→Execute→Verify→Correct 再包一层业务级 4 阶段):
475
+
476
+ Phase 1 Submission: 载入 RepairSubmission → 构造 BaseHarnessV2(或 BaseHarness)
477
+ Phase 2 Constrain: 先对首个 tool_call(写文件/应用 patch)做 Harness.constrain 显式断言
478
+ Phase 3 Execute+Verify: 调 harness.run(user_input);对每个 turn 的 Verify 失败 → Correct 重试
479
+ Phase 4 Final: ExecutionTrace 落盘 → trace.status success/failed/human_required;
480
+ Correct 熔断时 on_failure ∈ {ask-human, fallback-model, abort} 精确写入 trace.error
481
+ """
482
+
483
+ PHASE_SUBMISSION = "submission"
484
+ PHASE_CONSTRAIN = "constrain"
485
+ PHASE_EXECUTE_VERIFY = "execute_verify"
486
+ PHASE_CORRECT_LOOP = "correct_loop"
487
+ PHASE_FINAL = "final"
488
+
489
+ def __init__(
490
+ self,
491
+ submission: RepairSubmission,
492
+ harness: Any | None = None,
493
+ *,
494
+ artifacts_dir: str | os.PathLike[str] | None = None,
495
+ ) -> None:
496
+ self.submission = submission
497
+ self.harness = harness
498
+ self.artifacts_dir = (
499
+ Path(artifacts_dir) if artifacts_dir else Path.cwd() / "artifacts" / "repair"
500
+ )
501
+ self.artifacts_dir.mkdir(parents=True, exist_ok=True)
502
+ self.phase_events: list[dict[str, Any]] = []
503
+ self.trace: Any = None
504
+
505
+ # ------------------------------------------------------------------ helpers
506
+ def _make_user_prompt(self) -> str:
507
+ return (
508
+ f"任务:修复文件 {self.submission.target_file} 中导致 pytest 失败的 bug。\n"
509
+ f"Failing pytest 输出:\n{self.submission.failing_pytest_output}\n\n"
510
+ f"Buggy source({self.submission.target_file}):\n```\n{self.submission.buggy_source}\n```\n"
511
+ f"(可选)期望修复提示:{self.submission.expected_patch_hint or '(无)'}\n"
512
+ "最终输出:应用 patch + pytest 全绿 + diff。"
513
+ )
514
+
515
+ def _build_harness_if_missing(self) -> Any:
516
+ if self.harness is not None:
517
+ return self.harness
518
+ try:
519
+ from runtime.base_harness_v2 import BaseHarnessV2 # type: ignore
520
+ except Exception as exc: # pragma: no cover
521
+ raise FeatureNotInstalledError("BaseHarnessV2 runtime", "core") from exc
522
+ on_failure_norm = _normalize_on_failure(self.submission.on_failure)
523
+ harness_cfg = {
524
+ "agent_name": "repair_agent",
525
+ }
526
+ correct_cfg = {
527
+ "max_retries": int(self.submission.correct_max_retries),
528
+ "circuit_breaker": int(self.submission.correct_circuit_breaker),
529
+ "on_failure": on_failure_norm.replace(
530
+ "-", "_"
531
+ ), # runtime dict 用下划线存,run_t2_bench 兼容
532
+ }
533
+ constrain_cfg = {
534
+ "require_approval": list(self.submission.require_approval_tools or []),
535
+ "forbidden_commands": [
536
+ "rm -rf",
537
+ "git reset --hard",
538
+ "shutdown -h now",
539
+ "curl | bash",
540
+ "wget | sh",
541
+ "chmod -R 777",
542
+ ],
543
+ "workspace_root": self.submission.workspace_root,
544
+ }
545
+ verify_cfg = {
546
+ "json_schema": True,
547
+ "linter_check": True,
548
+ "test_runner": "pytest tests/ --maxfail=1 -q",
549
+ "reviewer_agent": "judge",
550
+ }
551
+ tools_cfg: dict[str, Any] = {
552
+ "tools_schema": "Tools: read-file, bash, write-md, apply-patch-builtin, git-push, grep-builtin"
553
+ }
554
+ context_cfg: dict[str, Any] = {
555
+ "status_bar": {
556
+ "step_count": True,
557
+ "test_status": True,
558
+ "todo_list": True,
559
+ "custom": {
560
+ "workspace_root": self.submission.workspace_root,
561
+ "rubric_target": self.submission.rubric_target,
562
+ },
563
+ },
564
+ "memory_policy": {
565
+ "layers": ["L0-Abstract", "L1-Overview", "L2-FullText"],
566
+ "auto_append_episodic": True,
567
+ },
568
+ "constrain": dict(constrain_cfg), # align workflow _require_approval_tools dual scan
569
+ }
570
+ harness = BaseHarnessV2(
571
+ model_config=harness_cfg,
572
+ context_config=context_cfg,
573
+ tools_config=tools_cfg,
574
+ harness_config={
575
+ "constrain": constrain_cfg,
576
+ "verify": verify_cfg,
577
+ "correct": correct_cfg,
578
+ },
579
+ )
580
+ self.harness = harness
581
+ return harness
582
+
583
+ def _emit_event(self, phase: str, **extra: Any) -> None:
584
+ self.phase_events.append({"phase": phase, **extra})
585
+
586
+ def _artifact_path(self, name: str) -> Path:
587
+ safe_name = str(name).replace("/", "__").replace(" ", "_")
588
+ return self.artifacts_dir / safe_name
589
+
590
+ def _write_trace_snapshot(self, run_id: str) -> Path:
591
+ p = self._artifact_path(f"trace_{run_id}.json")
592
+ try:
593
+ payload = (
594
+ self.trace.to_dict()
595
+ if hasattr(self.trace, "to_dict")
596
+ else (
597
+ asdict(self.trace)
598
+ if hasattr(self.trace, "__dataclass_fields__")
599
+ else {"trace_raw": self.trace}
600
+ )
601
+ )
602
+ except Exception:
603
+ payload = {"trace_raw": str(self.trace)}
604
+ p.write_text(
605
+ __import__("json").dumps(payload, ensure_ascii=False, indent=2, default=str),
606
+ encoding="utf-8",
607
+ )
608
+ return p
609
+
610
+ # ------------------------------------------------------------------ phases
611
+ async def phase_submission(self) -> dict[str, Any]:
612
+ """Phase 1 Submission:载入 submission,构造 harness,写 submission.json。"""
613
+ self._emit_event(
614
+ self.PHASE_SUBMISSION,
615
+ submission_fingerprint=self.submission.fingerprint_sha256(),
616
+ on_failure=_normalize_on_failure(self.submission.on_failure),
617
+ require_approval_tools=list(self.submission.require_approval_tools or []),
618
+ )
619
+ harness = self._build_harness_if_missing()
620
+ sub_path = self._artifact_path("submission.json")
621
+ sub_path.write_text(
622
+ __import__("json").dumps(self.submission.to_dict(), ensure_ascii=False, indent=2),
623
+ encoding="utf-8",
624
+ )
625
+ return {
626
+ "phase": self.PHASE_SUBMISSION,
627
+ "ok": True,
628
+ "harness_type": type(harness).__name__,
629
+ "submission_path": str(sub_path),
630
+ }
631
+
632
+ async def phase_constrain(
633
+ self, tool_call_precheck: dict[str, Any] | None = None
634
+ ) -> dict[str, Any]:
635
+ """Phase 2 Constrain:在 harness.run 之前先对 Submission 声明的首个写工具做显式 Constrain 断言。
636
+
637
+ 对齐 NFR-SEC-1a workspace_root 路径锁 + NFR-SEC-1b require_approval HITL 挂起。
638
+ """
639
+ harness = self._build_harness_if_missing()
640
+ tc = tool_call_precheck or {
641
+ "tool_name": "write-md",
642
+ "args": {
643
+ "path": f"{self.submission.workspace_root.rstrip('/')}/{self.submission.target_file.lstrip('/')}",
644
+ "content": self.submission.buggy_source,
645
+ },
646
+ }
647
+ allowed, reason = harness.constrain(tc)
648
+ self._emit_event(
649
+ self.PHASE_CONSTRAIN, tool_name=tc.get("tool_name"), allowed=allowed, reason=reason
650
+ )
651
+ return {
652
+ "phase": self.PHASE_CONSTRAIN,
653
+ "tool_call": tc,
654
+ "allowed": bool(allowed),
655
+ "reason": str(reason),
656
+ }
657
+
658
+ async def phase_execute_verify(
659
+ self, hitl_runner: InMemoryHITLRunner | None = None, approve_immediately: bool = True
660
+ ) -> dict[str, Any]:
661
+ """Phase 3 Execute + Phase 4 Correct Loop:实际调用 harness.run(user_input)。
662
+
663
+ 若 Constrain 命中 require_approval:
664
+ - hitl_runner 传 InMemoryHITLRunner → submit 返回 run_id,HITL 挂起;
665
+ approve_immediately=True 时立刻 runner.approve(tool_name) 继续;
666
+ - 未传 hitl_runner → 直接 harness.run(user_input),跳过 HITL side-channel 拦截。
667
+ """
668
+ harness = self._build_harness_if_missing()
669
+ user_prompt = self._make_user_prompt()
670
+ if hitl_runner is None:
671
+ trace = await harness.run(user_prompt)
672
+ self.trace = trace
673
+ self._emit_event(
674
+ self.PHASE_EXECUTE_VERIFY,
675
+ turn_count=len(getattr(trace, "turns", []) or []),
676
+ status=getattr(trace, "status", None),
677
+ )
678
+ return {
679
+ "phase": self.PHASE_EXECUTE_VERIFY,
680
+ "trace_status": getattr(trace, "status", "unknown"),
681
+ }
682
+
683
+ rid = await hitl_runner.submit(
684
+ WorkflowRequest(
685
+ agent_name="repair_agent",
686
+ harness=harness,
687
+ inputs={"user_input": user_prompt, "llm": getattr(harness, "llm_client", None)},
688
+ workflow="repair-agent-4-phase",
689
+ )
690
+ )
691
+ # 等待第一个 suspension 入队(最多 5s)
692
+ deadline = asyncio.get_running_loop().time() + 5.0
693
+ pending_tool = None
694
+ while asyncio.get_running_loop().time() < deadline:
695
+ for (k_run, k_tool), _s in list(getattr(hitl_runner, "_by_run_tool", {}).items()):
696
+ if k_run == rid:
697
+ pending_tool = k_tool
698
+ break
699
+ if pending_tool:
700
+ break
701
+ await asyncio.sleep(0.02)
702
+ if approve_immediately and pending_tool:
703
+ await hitl_runner.approve(rid, pending_tool)
704
+ trace = await hitl_runner.wait(rid, timeout=60.0)
705
+ self.trace = trace
706
+ self._emit_event(
707
+ self.PHASE_EXECUTE_VERIFY,
708
+ hitl_run_id=rid,
709
+ pending_tool=pending_tool,
710
+ turn_count=len(getattr(trace, "turns", []) or []),
711
+ status=getattr(trace, "status", None),
712
+ )
713
+ return {
714
+ "phase": self.PHASE_EXECUTE_VERIFY,
715
+ "hitl_run_id": rid,
716
+ "pending_tool": pending_tool,
717
+ "trace_status": getattr(trace, "status", "unknown"),
718
+ }
719
+
720
+ async def phase_final(self) -> dict[str, Any]:
721
+ """Phase Final:ExecutionTrace 落盘 → 返回 {status, final_answer, strategy, error, trace_path}。
722
+
723
+ FR-RUN-4 Correct 熔断精确判定:trace.error 含 strategy=<on_failure>;
724
+ FR-CORRECT-1 ask-human 分支:trace.status="human_required" 时不视为失败。
725
+ """
726
+ trace = self.trace
727
+ if trace is None:
728
+ raise RuntimeError(
729
+ "RepairAgentPipeline.phase_final 调用前必须先 phase_execute_verify()"
730
+ )
731
+ run_id = getattr(trace, "run_id", None) or f"repair-{uuid.uuid4().hex[:8]}"
732
+ trace_path = self._write_trace_snapshot(run_id)
733
+ status = str(getattr(trace, "status", "unknown") or "unknown")
734
+ error_raw = str(getattr(trace, "error", "") or "")
735
+ final_answer = getattr(trace, "final_answer", None)
736
+ strategy: str | None = None
737
+ if "strategy=" in error_raw:
738
+ try:
739
+ tail = error_raw.split("strategy=", 1)[1]
740
+ strategy = tail.split(";", 1)[0].strip()
741
+ except Exception:
742
+ strategy = None
743
+ if strategy is None and status == "failed":
744
+ strategy = _normalize_on_failure(self.submission.on_failure)
745
+ if strategy not in error_raw:
746
+ # 兼容 runtime Correct 直接返回 action=abort 下划线
747
+ expected = strategy.replace("-", "_")
748
+ if expected not in error_raw:
749
+ # fallback 写死到 trace.error 末尾,保证断言稳定
750
+ try:
751
+ trace.error = f"{error_raw} [strategy={strategy}]"
752
+ self.trace = trace
753
+ trace_path = self._write_trace_snapshot(run_id)
754
+ except Exception:
755
+ pass
756
+ self._emit_event(
757
+ self.PHASE_FINAL, status=status, strategy=strategy, trace_path=str(trace_path)
758
+ )
759
+ return {
760
+ "phase": self.PHASE_FINAL,
761
+ "run_id": run_id,
762
+ "status": status,
763
+ "final_answer": final_answer,
764
+ "strategy_on_break": strategy,
765
+ "error": error_raw,
766
+ "turn_count": len(getattr(trace, "turns", []) or []),
767
+ "trace_path": str(trace_path),
768
+ "srs_alignment": "§3 FR-RUN-1 + §6.2 AC-2",
769
+ }
770
+
771
+ # ------------------------------------------------------------------ driver
772
+ async def run(
773
+ self,
774
+ *,
775
+ hitl_runner: InMemoryHITLRunner | None = None,
776
+ approve_immediately: bool = True,
777
+ precheck_tool_call: dict[str, Any] | None = None,
778
+ ) -> dict[str, Any]:
779
+ """端到端驱动 4 阶段;返回合并 phase 结果。"""
780
+ results: dict[str, Any] = {}
781
+ results["submission"] = await self.phase_submission()
782
+ results["constrain"] = await self.phase_constrain(precheck_tool_call)
783
+ results["execute_verify"] = await self.phase_execute_verify(
784
+ hitl_runner=hitl_runner,
785
+ approve_immediately=approve_immediately,
786
+ )
787
+ results["final"] = await self.phase_final()
788
+ results["phase_events"] = list(self.phase_events)
789
+ return results
790
+
791
+
792
+ __all__ = [
793
+ "CORRECT_ON_FAILURE_ENUM",
794
+ "DirectRunner",
795
+ "InMemoryHITLRunner",
796
+ "RepairAgentPipeline",
797
+ "RepairSubmission",
798
+ "TemporalRunner",
799
+ "WorkflowRequest",
800
+ "WorkflowRunner",
801
+ "_normalize_on_failure",
802
+ "default_runner",
803
+ ]