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.
@@ -0,0 +1,1379 @@
1
+ """
2
+ AgentLisp v2.0 合并版 Harness 基类(runtime/base_harness_v2.py)
3
+
4
+ 修复对象:
5
+ A = runtime/base_harness.py (通用 ReAct + Evidence 链 + Checkpoint)
6
+ B = runtime/base_agent_harness.py (四参 constructor + KV Cache 四段 + 三重管道)
7
+
8
+ 修复问题(Code Review Issue 1-5,全部 high confidence):
9
+ 1. step 硬编码 mock tool/observation → 改为 llm_client Protocol + tool_registry 驱动
10
+ 2. 缺证据链 → 引入 ExecutionTrace / ReActTurn.duration_ms / final_answer / status
11
+ 3. Status Bar 用 user 角色污染对话 → 改为独立 system 角色的 <agent_status> 段
12
+ 4. constrain substring 误杀 → 正则词边界 \b + shlex token 精确匹配
13
+ 5. 缺 Checkpoint / StatusBar Hook → 把 MemoryCheckpointStore + StatusBar Protocol 注入 step() 的 on_start / on_step / on_done
14
+
15
+ 对外稳定接口(Emitter 生成产物继承此类):
16
+ class BaseHarnessV2(BaseAgentHarnessProtocol, StatusBarReceiverProtocol, CheckpointAwareProtocol):
17
+ def __init__(self,
18
+ model_config: Dict[str, Any],
19
+ context_config: Dict[str, Any],
20
+ tools_config: Dict[str, Any],
21
+ harness_config: Dict[str, Any],
22
+ *,
23
+ llm_client: Optional["LLMClientProtocol"] = None,
24
+ tool_registry: Optional["ToolRegistryProtocol"] = None,
25
+ checkpoint_store: Optional["CheckpointStoreProtocol"] = None,
26
+ status_bar: Optional["StatusBarProtocol"] = None,
27
+ max_turns: int = 30,
28
+ react_mode: bool = True)
29
+ async def run(self, user_input: str) -> "ExecutionTrace"
30
+ async def step(self, user_input: Optional[str] = None) -> Dict[str, Any]
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import contextlib
37
+ import gc
38
+ import logging
39
+ import pathlib
40
+ import re
41
+ import shlex
42
+ import time
43
+ import uuid
44
+ from collections.abc import AsyncIterator, Iterable
45
+ from dataclasses import asdict, dataclass, field
46
+ from datetime import UTC, datetime
47
+ from typing import (
48
+ Any,
49
+ Protocol,
50
+ runtime_checkable,
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ # Python-side SSOT mirror of compiler/checker.rkt SIDEEFFECT-BUILTIN-TOOLS (L87).
56
+ # BaseHarnessV2.constrain() uses this set to enforce FR-CHECK-2 invariant
57
+ # at runtime even when the compiled emit bypasses the Racket static checker.
58
+ try:
59
+ from runtime.checker import SIDEEFFECT_BUILTIN_TOOLS
60
+ except Exception: # pragma: no cover - extremely rare (broken package)
61
+ SIDEEFFECT_BUILTIN_TOOLS = frozenset( # type: ignore[assignment]
62
+ {"bash", "git-push", "wget", "curl", "scp", "dd", "chmod", "sudo"}
63
+ )
64
+
65
+ # ==============================================================================
66
+ # 证据链数据结构 (对应 Code Review Issue #2)
67
+ # ==============================================================================
68
+
69
+
70
+ @dataclass
71
+ class ReActTurnV2:
72
+ index: int
73
+ thought: str | None = None
74
+ action: str | None = None
75
+ action_input: dict[str, Any] | None = None
76
+ observation: Any | None = None
77
+ answer: str | None = None
78
+ status: str = "pending" # pending / success / failed / blocked / retry
79
+ started_at: float = field(default_factory=time.monotonic)
80
+ finished_at: float | None = None
81
+
82
+ @property
83
+ def duration_ms(self) -> int:
84
+ if self.finished_at is None:
85
+ return 0
86
+ return int((self.finished_at - self.started_at) * 1000)
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ d = asdict(self)
90
+ d["duration_ms"] = self.duration_ms
91
+ return d
92
+
93
+
94
+ @dataclass
95
+ class ExecutionTraceV2:
96
+ run_id: str
97
+ agent_name: str | None
98
+ started_at: datetime
99
+ finished_at: datetime | None = None
100
+ turns: list[ReActTurnV2] = field(default_factory=list)
101
+ final_answer: str | None = None
102
+ status: str = "pending" # pending / running / success / failed / blocked / human_required
103
+ error: str | None = None
104
+
105
+ @property
106
+ def duration_ms(self) -> int:
107
+ if self.finished_at is None:
108
+ return 0
109
+ return int((self.finished_at - self.started_at).total_seconds() * 1000)
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ return {
113
+ "run_id": self.run_id,
114
+ "agent_name": self.agent_name,
115
+ "status": self.status,
116
+ "started_at": self.started_at.isoformat(),
117
+ "finished_at": self.finished_at.isoformat() if self.finished_at else None,
118
+ "duration_ms": self.duration_ms,
119
+ "turns": [t.to_dict() for t in self.turns],
120
+ "final_answer": self.final_answer,
121
+ "error": self.error,
122
+ }
123
+
124
+
125
+ # ==============================================================================
126
+ # 外部依赖 Protocol(避免 import 时硬绑定可选依赖)
127
+ # ==============================================================================
128
+
129
+
130
+ @runtime_checkable
131
+ class LLMClientProtocol(Protocol):
132
+ async def achat(self, messages: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]: ...
133
+
134
+
135
+ @runtime_checkable
136
+ class ToolRegistryProtocol(Protocol):
137
+ async def acall(self, name: str, **kwargs: Any) -> Any: ...
138
+ def names(self) -> list[str]: ...
139
+ def to_openai_schema(self) -> list[dict[str, Any]]: ...
140
+
141
+
142
+ @runtime_checkable
143
+ class CheckpointStoreProtocol(Protocol):
144
+ async def save(self, key: str, payload: dict[str, Any]) -> None: ...
145
+ async def load(self, key: str) -> dict[str, Any] | None: ...
146
+
147
+
148
+ @runtime_checkable
149
+ class StatusBarProtocol(Protocol):
150
+ def on_start(self, run_id: str, agent_name: str | None) -> None: ...
151
+ def on_step(
152
+ self, run_id: str, turn_index: int, event: str, detail: Any | None = None
153
+ ) -> None: ...
154
+ def on_done(
155
+ self, run_id: str, status: str, final_answer: str | None, error: str | None
156
+ ) -> None: ...
157
+
158
+
159
+ # ==============================================================================
160
+ # Memory 版默认实现(零依赖,对应 runtime/checkpoint.py / status_bar.py 子集)
161
+ # ==============================================================================
162
+
163
+
164
+ class _MemoryCheckpointStore:
165
+ """仅在外部未注入 checkpoint_store 时使用,对应 runtime.checkpoint.MemoryCheckpointStore 的最小形态。"""
166
+
167
+ def __init__(self) -> None:
168
+ self._data: dict[str, dict[str, Any]] = {}
169
+
170
+ async def save(self, key: str, payload: dict[str, Any]) -> None:
171
+ self._data[key] = payload
172
+
173
+ async def load(self, key: str) -> dict[str, Any] | None:
174
+ return self._data.get(key)
175
+
176
+
177
+ class _PrintingStatusBar:
178
+ """仅在外部未注入 status_bar 时使用。"""
179
+
180
+ def on_start(self, run_id: str, agent_name: str | None) -> None:
181
+ logger.info("[StatusBar] on_start run_id=%s agent=%s", run_id, agent_name)
182
+
183
+ def on_step(self, run_id: str, turn_index: int, event: str, detail: Any | None = None) -> None:
184
+ logger.info("[StatusBar] on_step run_id=%s turn=%s event=%s", run_id, turn_index, event)
185
+
186
+ def on_done(
187
+ self, run_id: str, status: str, final_answer: str | None, error: str | None
188
+ ) -> None:
189
+ logger.info(
190
+ "[StatusBar] on_done run_id=%s status=%s final_answer=%s error=%s",
191
+ run_id,
192
+ status,
193
+ final_answer,
194
+ error,
195
+ )
196
+
197
+
198
+ # ==============================================================================
199
+ # Constrain 词边界匹配 (对应 Code Review Issue #4)
200
+ # ==============================================================================
201
+
202
+
203
+ _FORBIDDEN_WORD_BOUNDARY_RE = re.compile(
204
+ r"(?<!\w)rm(?!\w)"
205
+ ) # placeholder(实际由 _forbidden_regex 动态生成)
206
+
207
+
208
+ def _forbidden_regex(pattern: str) -> re.Pattern[str]:
209
+ """把 forbidden 字符串编译为词边界精确匹配(避免 rm 匹配 rmtree / cat 匹配 category)。"""
210
+ escaped = re.escape(pattern.strip())
211
+ return re.compile(rf"(?<![A-Za-z0-9_./-]){escaped}(?![A-Za-z0-9_./-])")
212
+
213
+
214
+ def _contains_forbidden_token(command: str, forbidden: str) -> bool:
215
+ """优先 shlex token 精确匹配(bash 场景),fallback 正则词边界。"""
216
+ if not command or not forbidden:
217
+ return False
218
+ # token 级匹配(优先)
219
+ try:
220
+ tokens = shlex.split(command, comments=True, posix=True)
221
+ except ValueError:
222
+ tokens = []
223
+ if tokens:
224
+ forbidden_tokens = forbidden.split()
225
+ n = len(forbidden_tokens)
226
+ if n == 1:
227
+ if forbidden_tokens[0] in tokens:
228
+ return True
229
+ else:
230
+ for i in range(len(tokens) - n + 1):
231
+ if tokens[i : i + n] == forbidden_tokens:
232
+ return True
233
+ # fallback:正则词边界
234
+ return bool(_forbidden_regex(forbidden).search(command))
235
+
236
+
237
+ def _normalize_pure_posix_parts(path: pathlib.PurePosixPath) -> pathlib.PurePosixPath:
238
+ """对 PurePosixPath 的 parts 手工规范化,去掉 `.` / `..`(纯字符串运算,不调用真实文件系统的 resolve())。"""
239
+ parts = list(path.parts)
240
+ stack: list[str] = []
241
+ for p in parts:
242
+ if p == ".":
243
+ continue
244
+ if p == "..":
245
+ # 非空且顶部不是根 "/" 才能弹;已是根的话保持根(不吞掉)
246
+ if stack and stack[-1] != "/":
247
+ stack.pop()
248
+ continue
249
+ stack.append(p)
250
+ if not stack:
251
+ return pathlib.PurePosixPath(".")
252
+ if len(stack) == 1 and stack[0] == "/":
253
+ return pathlib.PurePosixPath("/")
254
+ return pathlib.PurePosixPath(*stack)
255
+
256
+
257
+ # ==============================================================================
258
+ # 上下文组装 (对应 Code Review Issue #3:Status Bar 改用 system 角色)
259
+ # ==============================================================================
260
+
261
+
262
+ def build_kv_aligned_context(
263
+ *,
264
+ system_prompt: str,
265
+ tools_schema_text: str | None = None,
266
+ tools_openai_schema: list[dict[str, Any]] | None = None,
267
+ trajectory: list[dict[str, Any]],
268
+ step_count: int,
269
+ status_bar_config: dict[str, Any],
270
+ terminated: bool = False,
271
+ extra_status_items: dict[str, Any] | None = None,
272
+ mounted_layers: list[str] | None = None,
273
+ ) -> list[dict[str, Any]]:
274
+ """
275
+ KV Cache 严格四段+1 memory 段 (对应 base_agent_harness.build_context 的规范固化版):
276
+
277
+ (1) Static System Prompt → role=system(稳定,命中供应商 Prefix Cache)
278
+ (1.5) [Memory] mounted layers 段 → role=system(SRS FR-MEM-1:有 markdown_fs 时追加)
279
+ (2) Static Tools Schema → role=system(稳定,命中供应商 Prefix Cache)
280
+ (3) Dynamic Trajectory → role=user/assistant/tool(每回合追加)
281
+ (4) Trailing Status Bar Hook → role=system(修正 Code Review #3:不再用 user 污染)
282
+
283
+ tools_schema_text 与 tools_openai_schema 至少给其一;二者都给时 system 消息合并呈现。
284
+ mounted_layers 非空时,在 system prompt 后追加 [Memory] mounted layers: x, y, z 段(SRS FR-MEM-1)。
285
+ """
286
+ messages: list[dict[str, Any]] = []
287
+
288
+ # (1) Static System Prompt
289
+ if not system_prompt:
290
+ system_prompt = "You are a helpful Agent."
291
+ messages.append({"role": "system", "content": system_prompt})
292
+
293
+ # (1.5) [Memory] mounted layers: SRS FR-MEM-1(emitter 在 markdown_fs 非空时自动传入)
294
+ if mounted_layers:
295
+ layers_text = ", ".join(str(layer) for layer in mounted_layers if str(layer).strip())
296
+ if layers_text:
297
+ messages.append(
298
+ {
299
+ "role": "system",
300
+ "content": f"[Memory] mounted layers: {layers_text}",
301
+ }
302
+ )
303
+
304
+ # (2) Static Tools Schema
305
+ tools_parts: list[str] = []
306
+ if tools_schema_text:
307
+ tools_parts.append(tools_schema_text.strip())
308
+ if tools_openai_schema:
309
+ import json as _json
310
+
311
+ tools_parts.append(
312
+ "OpenAI tools schema:\n"
313
+ + _json.dumps(tools_openai_schema, ensure_ascii=False, indent=2)
314
+ )
315
+ if tools_parts:
316
+ messages.append(
317
+ {
318
+ "role": "system",
319
+ "content": "<tools_definition>\n"
320
+ + "\n\n".join(tools_parts)
321
+ + "\n</tools_definition>",
322
+ }
323
+ )
324
+
325
+ # (3) Dynamic Trajectory
326
+ messages.extend(trajectory)
327
+
328
+ # (4) Status Bar (system 角色,修正 Issue #3)
329
+ sb = status_bar_config or {}
330
+ if sb:
331
+ items: list[str] = []
332
+ if sb.get("step_count", True):
333
+ items.append(f"Step: {step_count}")
334
+ items.append(f"Status: {'Terminated' if terminated else 'Active'}")
335
+ if extra_status_items:
336
+ for k, v in extra_status_items.items():
337
+ items.append(f"{k}: {v}")
338
+ if items:
339
+ messages.append(
340
+ {
341
+ "role": "system",
342
+ "content": "<agent_status>" + " | ".join(items) + "</agent_status>",
343
+ }
344
+ )
345
+
346
+ return messages
347
+
348
+
349
+ # ==============================================================================
350
+ # Mock LLM Client(默认零依赖,用于本地单测或未安装 llm 组场景)
351
+ # ==============================================================================
352
+
353
+
354
+ class MockLLMClient:
355
+ """按序返回 responses;若 responses 耗尽则返回终止回答。用于 Issue #1 的默认 LLM 注入。"""
356
+
357
+ def __init__(self, responses: Iterable[dict[str, Any]] | None = None) -> None:
358
+ import collections
359
+
360
+ self._queue: collections.deque[dict[str, Any]] = collections.deque(
361
+ responses
362
+ or [
363
+ {
364
+ "role": "assistant",
365
+ "content": None,
366
+ "tool_calls": [
367
+ {
368
+ "id": "call-mock",
369
+ "type": "function",
370
+ "function": {"name": "echo", "arguments": {"message": "hello"}},
371
+ }
372
+ ],
373
+ },
374
+ {"role": "assistant", "content": "OK, done."},
375
+ ]
376
+ )
377
+
378
+ async def achat(self, messages: list[dict[str, Any]], **_: Any) -> dict[str, Any]:
379
+ if self._queue:
380
+ return dict(self._queue.popleft())
381
+ return {"role": "assistant", "content": "[MockLLM depleted] Task finished."}
382
+
383
+
384
+ # ==============================================================================
385
+ # Harness 基类
386
+ # ==============================================================================
387
+
388
+
389
+ class BaseHarnessV2:
390
+ """
391
+ AgentLisp v2 合并版 Harness:
392
+ - 四参 constructor(B 版接口)+ 可选依赖注入(A 版能力)
393
+ - KV Cache 四段 (build_kv_aligned_context)
394
+ - 三重管道 Constrain / Verify / Correct
395
+ - 证据链 ExecutionTraceV2 + turns[].duration_ms
396
+ - Checkpoint / StatusBar Hook 全生命周期
397
+ """
398
+
399
+ def __init__(
400
+ self,
401
+ model_config: dict[str, Any],
402
+ context_config: dict[str, Any],
403
+ tools_config: dict[str, Any],
404
+ harness_config: dict[str, Any],
405
+ *,
406
+ llm_client: LLMClientProtocol | None = None,
407
+ tool_registry: ToolRegistryProtocol | None = None,
408
+ checkpoint_store: CheckpointStoreProtocol | None = None,
409
+ status_bar: StatusBarProtocol | None = None,
410
+ max_turns: int = 30,
411
+ react_mode: bool = True,
412
+ agent_name: str | None = None,
413
+ # CR-16 P1-5 Provider enum / CircuitBreaker(显式传入参数(CI可控):
414
+ llm_provider_orchestrator: Any | None = None,
415
+ llm_fallback_provider: Any | None = None,
416
+ llm_circuit_breaker: Any | None = None,
417
+ ) -> None:
418
+ self.model_config = dict(model_config or {})
419
+ self.context_config = dict(context_config or {})
420
+ self.tools_config = dict(tools_config or {})
421
+ self.harness_config = dict(harness_config or {})
422
+
423
+ # CR-16:Provider 层(FR-PARSER-2 + FR-CORRECT-2):
424
+ # - llm_provider_orchestrator 显式传入时直接用;
425
+ # - 否则在 Model Harness Builder 模式(默认)用 llm_client 做 achat。
426
+ self._llm_orchestrator = llm_provider_orchestrator
427
+ self._fallback_provider = llm_fallback_provider
428
+ self._circuit_breaker = llm_circuit_breaker
429
+
430
+ self.llm_client: LLMClientProtocol = llm_client or MockLLMClient() # Fix Issue #1
431
+ self.tool_registry: ToolRegistryProtocol | None = tool_registry
432
+ self.checkpoint_store: CheckpointStoreProtocol = (
433
+ checkpoint_store or _MemoryCheckpointStore()
434
+ ) # Fix Issue #5
435
+ self.status_bar: StatusBarProtocol = status_bar or _PrintingStatusBar() # Fix Issue #5
436
+
437
+ self.max_turns = int(max_turns)
438
+ self.react_mode = bool(react_mode)
439
+ self.agent_name = agent_name or self.model_config.get("agent_name")
440
+
441
+ # 运行时状态
442
+ self.trajectory: list[dict[str, Any]] = []
443
+ self.step_count: int = 0
444
+ self.is_terminated: bool = False
445
+ self._trace: ExecutionTraceV2 | None = None
446
+
447
+ # SRS FR-MEM-1:若 context_config 指定 markdown_fs 或 layers,则自动创建 self.memory_fs 并挂载三层占位
448
+ # emitter 生成代码时会进一步覆盖为具体 layers;测试端可手工 setattr 注入
449
+ self.memory_fs: Any | None = None
450
+ self._mounted_layers: list[str] = []
451
+ try:
452
+ from .memory_fs import MemoryFS
453
+ except Exception:
454
+ MemoryFS = None # type: ignore[assignment,misc]
455
+ _ccfg = self.context_config
456
+ _markdown_fs = (
457
+ _ccfg.get("markdown_fs") or _ccfg.get("memory_path") or _ccfg.get("memory_root")
458
+ )
459
+ _layers_raw = _ccfg.get("layers") or _ccfg.get("memory_layers") or []
460
+ _has_memory_hint = bool(_markdown_fs or _layers_raw)
461
+ if _has_memory_hint and MemoryFS is not None:
462
+ try:
463
+ self.memory_fs = MemoryFS(namespace=self.agent_name or "default")
464
+ except Exception:
465
+ self.memory_fs = None
466
+ # 规范化 layers:把字符串/符号格式统一成 [L0-Abstract, L1-Overview, L2-FullText] 顺序
467
+ if _layers_raw:
468
+ for layer in _layers_raw:
469
+ s = str(layer).strip()
470
+ if s and s not in self._mounted_layers:
471
+ self._mounted_layers.append(s)
472
+ # emitter 生成代码时会显式调用 self.memory_fs.put 三层挂载占位;这里保守只注册 _mounted_layers,不做异步
473
+
474
+ # SRS NFR-OBS-1:OTel tracer(observability 组有装时 create_tracer() 真用,否则 None 时跳过)
475
+ self.otel_tracer: Any = None
476
+
477
+ # ------------------------------------------------------------------
478
+ # 对外入口:run / step
479
+ # ------------------------------------------------------------------
480
+
481
+ async def run(self, user_input: str) -> ExecutionTraceV2:
482
+ run_id = str(uuid.uuid4())
483
+ self._trace = ExecutionTraceV2(
484
+ run_id=run_id,
485
+ agent_name=self.agent_name,
486
+ started_at=datetime.now(UTC),
487
+ status="running",
488
+ )
489
+ self.trajectory = [{"role": "user", "content": user_input}]
490
+ self.step_count = 0
491
+ self.is_terminated = False
492
+ self.status_bar.on_start(run_id, self.agent_name)
493
+ await self._save_checkpoint()
494
+
495
+ try:
496
+ loop_result = await self._react_loop(user_input)
497
+ if loop_result.get("status") == "blocked":
498
+ self._trace.status = "blocked"
499
+ self._trace.error = loop_result.get("reason")
500
+ self.is_terminated = True
501
+ return self._trace
502
+ if loop_result.get("status") == "failed":
503
+ self._trace.status = "failed"
504
+ self._trace.error = (
505
+ loop_result.get("error") or loop_result.get("feedback") or "failed"
506
+ )
507
+ self.is_terminated = True
508
+ return self._trace
509
+ if loop_result.get("status") == "human_required":
510
+ self._trace.status = "human_required"
511
+ self.is_terminated = True
512
+ return self._trace
513
+ self._trace.status = "success"
514
+ self._trace.final_answer = loop_result.get("final_answer")
515
+ self.is_terminated = True
516
+ return self._trace
517
+ except Exception as exc:
518
+ self._trace.status = "failed"
519
+ self._trace.error = f"{type(exc).__name__}: {exc}"
520
+ self.is_terminated = True
521
+ raise
522
+ finally:
523
+ self._trace.finished_at = datetime.now(UTC)
524
+ self.status_bar.on_done(
525
+ self._trace.run_id, self._trace.status, self._trace.final_answer, self._trace.error
526
+ )
527
+ await self._save_checkpoint()
528
+
529
+ async def step(self, user_input: str | None = None) -> dict[str, Any]:
530
+ """单步驱动(保留 B 版 API)。"""
531
+ if self._trace is None:
532
+ # lazy 初始化一次 trace,保证单步驱动也有证据链
533
+ run_id = str(uuid.uuid4())
534
+ self._trace = ExecutionTraceV2(
535
+ run_id=run_id,
536
+ agent_name=self.agent_name,
537
+ started_at=datetime.now(UTC),
538
+ status="running",
539
+ )
540
+ self.status_bar.on_start(run_id, self.agent_name)
541
+ if user_input:
542
+ self.trajectory.append({"role": "user", "content": user_input})
543
+ self.step_count += 1
544
+ return await self._react_step()
545
+
546
+ # ------------------------------------------------------------------
547
+ # 三重控制流管道(保留 B 版接口 + 强化实现)
548
+ # ------------------------------------------------------------------
549
+
550
+ def constrain(self, tool_call: dict[str, Any]) -> tuple[bool, str]:
551
+ """【护栏 1:Constrain】负面清单 + 词边界匹配 + workspace_root 路径门控(Fix Issue #4 + SRS FR-RUN-3)。
552
+
553
+ CR-22 P3-2 强化:FR-CHECK-2 runtime 侧镜像 ERR_UNGUARDED_TOOL_EXECUTION
554
+ 静态 invariant。当 tool_name ∈ SIDEEFFECT_BUILTIN_TOOLS(bash/git-push/wget/curl/scp/dd/chmod/sudo)
555
+ 时,**同时** 满足 (A ∧ B) ∨ C 三者之一:
556
+ A) constrain.require_human_approval 包含该工具名;
557
+ B) constrain.forbidden_commands 至少 1 条非空;
558
+ C) verify.{json_schema,linter_check,test_runner,reviewer_agent} 任一项 truthy。
559
+ 三都不满足 → 立即 Block,reason 开头 `Harness Blocked: FR-CHECK-2`(便于 CI 反推 SRS-ID)。
560
+ """
561
+ tool_name = str(tool_call.get("tool_name") or "")
562
+ args: dict[str, Any] = tool_call.get("args", {}) or {}
563
+ cmd: str = str(args.get("command", ""))
564
+ # (a0) FR-CHECK-2 runtime side-effect 护栏(CR-22 P3-2)
565
+ if tool_name and tool_name in SIDEEFFECT_BUILTIN_TOOLS:
566
+ constrain_cfg = (
567
+ (self.harness_config.get("constrain") or {})
568
+ if isinstance(self.harness_config, dict)
569
+ else {}
570
+ )
571
+ verify_cfg = (
572
+ (self.harness_config.get("verify") or {})
573
+ if isinstance(self.harness_config, dict)
574
+ else {}
575
+ )
576
+ approval_list = [
577
+ str(x)
578
+ for x in (
579
+ constrain_cfg.get("require_human_approval")
580
+ or constrain_cfg.get("require_approval")
581
+ or []
582
+ )
583
+ ]
584
+ forbidden_raw = list(constrain_cfg.get("forbidden_commands") or [])
585
+ forbidden_nonempty = any(True for x in forbidden_raw if str(x).strip() != "")
586
+ approved = tool_name in approval_list
587
+ verify_any = any(
588
+ bool(verify_cfg.get(k))
589
+ for k in ("json_schema", "linter_check", "test_runner", "reviewer_agent")
590
+ )
591
+ if not ((approved and forbidden_nonempty) or verify_any):
592
+ reason = (
593
+ "Harness Blocked: FR-CHECK-2(ERR_UNGUARDED_TOOL_EXECUTION) "
594
+ f"side-effect builtin {tool_name!r} requires guardrails: "
595
+ "either (require_human_approval lists tool AND forbidden_commands non-empty) "
596
+ "OR any verify.* flag is truthy. "
597
+ f"current require_human_approval={approval_list!r} "
598
+ f"forbidden_commands={forbidden_raw!r} verify={verify_cfg!r}."
599
+ )
600
+ logger.warning(
601
+ "[Constrain][FR-CHECK-2] tool=%r unguarded; reason=%s", tool_name, reason
602
+ )
603
+ return False, reason
604
+ # (a) forbidden_commands 词边界匹配(Issue #4)
605
+ forbidden_list = list(
606
+ self.harness_config.get("constrain", {}).get("forbidden_commands", []) or []
607
+ )
608
+ for forbidden in forbidden_list:
609
+ if _contains_forbidden_token(cmd, forbidden):
610
+ logger.warning("[Constrain] blocked forbidden=%r in cmd=%r", forbidden, cmd)
611
+ return False, f"Harness Blocked: Execution of {forbidden!r} is strictly forbidden."
612
+ # (b) workspace_root 路径逃逸门控(SRS FR-RUN-3):
613
+ # 不只是 bash 的 command 参数,还要递归遍历 args 中所有字符串值:
614
+ # - 含 "/" / "." / ".." 的候选 token;
615
+ # - 像路径的参数名(path/paths/cwd/file/files/src/dst/target/output/dir/directory/root);
616
+ # workspace_root 非空时,所有候选必须是 workspace_root 的后代。
617
+ #
618
+ # 判定策略(评审要求的"物理 resolve + 纯 posix 规范化双保险"):
619
+ # 1) Python 运行时真实路径存在 → 用 os.path.realpath / Path.resolve() 做物理规范化(解 symlink)后 is_relative_to;
620
+ # 2) 不存在或非本机路径(例如容器里的路径,本地单测不真的创建目录)→ 用 _normalize_pure_posix_parts 纯字符串规范化后 is_relative_to;
621
+ # 3) 任意一个命中逃逸 → 立即返回 WorkspaceEscape(不允许部分通过)。
622
+ workspace_root = (self.harness_config.get("constrain", {}) or {}).get("workspace_root")
623
+ if workspace_root:
624
+ root_raw = str(workspace_root)
625
+ path_candidates: list[str] = []
626
+ # (b.1) 从 bash 的 command 拆 shlex(老路径)
627
+ try:
628
+ tokens_cmd = shlex.split(cmd, comments=True, posix=True) if cmd else []
629
+ except ValueError:
630
+ tokens_cmd = cmd.split() if cmd else []
631
+ for token in tokens_cmd:
632
+ if not token or token in {
633
+ "|",
634
+ "&&",
635
+ ";",
636
+ "||",
637
+ "&",
638
+ ">",
639
+ "<",
640
+ ">>",
641
+ "2>",
642
+ "1>",
643
+ "&>",
644
+ }:
645
+ continue
646
+ if not ("/" in token or token == "." or token.startswith("..")):
647
+ continue
648
+ candidate = token.replace("file://", "")
649
+ if candidate:
650
+ path_candidates.append(candidate)
651
+ # (b.2) 递归 args dict 收集路径候选(带参数名语义)
652
+ PATH_HINT_KEYS = {
653
+ "path",
654
+ "paths",
655
+ "file",
656
+ "files",
657
+ "dir",
658
+ "dirs",
659
+ "directory",
660
+ "directories",
661
+ "target",
662
+ "dst",
663
+ "src",
664
+ "root",
665
+ "output",
666
+ "cwd",
667
+ "workspace",
668
+ "mount",
669
+ "source",
670
+ "destination",
671
+ "working_dir",
672
+ "working_directory",
673
+ "home",
674
+ }
675
+
676
+ def _walk(v: Any, key_hint: str | None) -> None:
677
+ if isinstance(v, str):
678
+ s = v.replace("file://", "")
679
+ if key_hint and key_hint in PATH_HINT_KEYS:
680
+ # 参数名明确是路径:即使不含 "/" 也加入(相对路径 "./foo" / "foo.txt")
681
+ path_candidates.append(s)
682
+ return
683
+ if "/" in s or s == "." or s.startswith(".."):
684
+ path_candidates.append(s)
685
+ return
686
+ if isinstance(v, dict):
687
+ for kk, vv in v.items():
688
+ _walk(vv, str(kk) if isinstance(kk, str) else None)
689
+ return
690
+ if isinstance(v, (list, tuple, set)):
691
+ for item in v:
692
+ _walk(item, key_hint)
693
+ return
694
+
695
+ for k, v in args.items():
696
+ _walk(v, str(k) if isinstance(k, str) else None)
697
+ # (b.3) 去重(保持顺序)
698
+ seen: set = set()
699
+ dedup: list[str] = []
700
+ for s in path_candidates:
701
+ if s in seen:
702
+ continue
703
+ seen.add(s)
704
+ dedup.append(s)
705
+ # (b.4) 对每个候选做双保险规范化 + is_relative_to 判定
706
+ root_pure = pathlib.PurePosixPath(root_raw)
707
+
708
+ def _fs_resolve_if_exists(cand_path: str) -> pathlib.Path | None:
709
+ """路径真实存在(或父目录存在可 resolve)→ 返回 Path.resolve(strict=False);否则返回 None。"""
710
+ try:
711
+ # 允许把非绝对的相对 root 先拼成绝对存在性检查
712
+ cp = pathlib.Path(cand_path)
713
+ if not cp.is_absolute():
714
+ cp = pathlib.Path(str(root_raw)) / cand_path
715
+ try:
716
+ if cp.exists() or cp.parent.exists(): # 存在 or 父存在 → 调真实 FS resolve
717
+ return cp.resolve(strict=False)
718
+ except (OSError, RuntimeError):
719
+ return None
720
+ except Exception:
721
+ return None
722
+ return None
723
+
724
+ for candidate in dedup:
725
+ try:
726
+ cand_pure = pathlib.PurePosixPath(candidate)
727
+ except Exception:
728
+ continue
729
+ if cand_pure.is_absolute():
730
+ resolved_pure = _normalize_pure_posix_parts(cand_pure)
731
+ else:
732
+ resolved_pure = _normalize_pure_posix_parts(root_pure / cand_pure)
733
+ escaped_pure = False
734
+ try:
735
+ escaped_pure = not resolved_pure.is_relative_to(root_pure)
736
+ except Exception:
737
+ escaped_pure = True
738
+ if escaped_pure:
739
+ logger.warning(
740
+ "[Constrain][WorkspaceEscape-Pure] root=%r candidate=%r resolved=%r",
741
+ str(root_pure),
742
+ candidate,
743
+ str(resolved_pure),
744
+ )
745
+ return False, (
746
+ f"Harness Blocked: WorkspaceEscape: target path candidate {candidate!r} "
747
+ f"resolves to {str(resolved_pure)!r} escapes workspace_root={str(root_pure)!r}"
748
+ )
749
+ # 物理 FS 层 resolve 再验一次(双保险):存在就解 symlink,解后必须仍在 root 下
750
+ fs_resolved = _fs_resolve_if_exists(candidate)
751
+ if fs_resolved is not None:
752
+ root_fs = pathlib.Path(str(root_raw)).resolve(strict=False)
753
+ try:
754
+ if not fs_resolved.is_relative_to(root_fs):
755
+ logger.warning(
756
+ "[Constrain][WorkspaceEscape-FS] root=%r candidate=%r resolved_fs=%r",
757
+ str(root_fs),
758
+ candidate,
759
+ str(fs_resolved),
760
+ )
761
+ return False, (
762
+ f"Harness Blocked: WorkspaceEscape: filesystem-resolved path {candidate!r} "
763
+ f"→ {str(fs_resolved)!r} (symlink/realpath) escapes workspace_root={str(root_fs)!r}"
764
+ )
765
+ except Exception:
766
+ # is_relative_to 抛异常视为逃逸(可能是 path 类型不同等)
767
+ logger.warning(
768
+ "[Constrain][WorkspaceEscape-FS-exc] root=%r candidate=%r",
769
+ str(root_raw),
770
+ candidate,
771
+ exc_info=True,
772
+ )
773
+ return False, (
774
+ f"Harness Blocked: WorkspaceEscape: path {candidate!r} failed filesystem "
775
+ f"is_relative_to check against workspace_root={str(root_raw)!r}"
776
+ )
777
+ # 再检查:真实 root 存在 + 候选绝对路径但 root 是 `/app/workspace`,候选是 `/app/workspacefoo` 时,PurePosix 仍可能误判?
778
+ # PurePosixPath("/app/workspacefoo").is_relative_to("/app/workspace") 返回 False,所以无需再补
779
+ return True, "OK"
780
+
781
+ def verify(self, observation: dict[str, Any]) -> tuple[bool, str]:
782
+ """【护栏 2:Verify】exit_code 静态断言;子类可 override 注入业务断言。"""
783
+ if not isinstance(observation, dict):
784
+ return True, "Verified (non-dict observation)"
785
+ if observation.get("exit_code", 0) != 0:
786
+ err_msg = str(
787
+ observation.get("stderr") or observation.get("error") or "Execution failed"
788
+ )
789
+ logger.warning(
790
+ "[Verify] failed exit_code=%s err=%s", observation.get("exit_code"), err_msg
791
+ )
792
+ return False, err_msg
793
+ return True, "Verified"
794
+
795
+ async def correct(
796
+ self,
797
+ tool_call: dict[str, Any],
798
+ error_msg: str,
799
+ retries: int,
800
+ ) -> dict[str, Any]:
801
+ """【护栏 3:Correct】局部重试与熔断策略。"""
802
+ correct_cfg = self.harness_config.get("correct", {}) or {}
803
+ max_retries = int(correct_cfg.get("max_retries", 3))
804
+ logger.info("[Correct] retries=%s/%s error=%s", retries, max_retries, error_msg)
805
+ if retries >= max_retries:
806
+ on_failure = correct_cfg.get("on_failure", "ask_human")
807
+ logger.error("[Correct] circuit-breaker on_failure=%s", on_failure)
808
+ return {
809
+ "status": "failed",
810
+ "action": on_failure,
811
+ "error": error_msg,
812
+ "feedback": f"Max retries ({max_retries}) reached. Strategy: {on_failure}. Original error: {error_msg}",
813
+ }
814
+ return {
815
+ "status": "retry",
816
+ "feedback": f"Previous execution failed with: {error_msg}. Please fix your parameters and retry.",
817
+ }
818
+
819
+ # ------------------------------------------------------------------
820
+ # KV Cache 对齐上下文
821
+ # ------------------------------------------------------------------
822
+
823
+ def build_context(self) -> list[dict[str, Any]]:
824
+ """公开 API:返回 KV Cache 四段 + [Memory] mount 段 messages(SRS FR-MEM-1)。"""
825
+ tools_openai_schema = None
826
+ if self.tool_registry is not None:
827
+ try:
828
+ tools_openai_schema = self.tool_registry.to_openai_schema()
829
+ except Exception:
830
+ tools_openai_schema = None
831
+ return build_kv_aligned_context(
832
+ system_prompt=str(self.model_config.get("system_prompt", "")),
833
+ tools_schema_text=self.tools_config.get("tools_schema"),
834
+ tools_openai_schema=tools_openai_schema,
835
+ trajectory=self.trajectory,
836
+ step_count=self.step_count,
837
+ status_bar_config=self.context_config.get("status_bar", {}) or {},
838
+ terminated=self.is_terminated,
839
+ mounted_layers=list(self._mounted_layers) or None,
840
+ )
841
+
842
+ # ------------------------------------------------------------------
843
+ # scoped_worker 上下文隔离管理器(SRS §4.3 FR-MAGT-1 Invariant #3)
844
+ # 语义:
845
+ # - 进入:snapshot 当前 trajectory / step_count
846
+ # - 内部:worker 可自由写入 trajectory / step_count(worker 私有)
847
+ # - 退出(正常或异常):自动 GC 恢复 snapshot,父级 trajectory 永远不可见 worker 内部中间步骤
848
+ # ------------------------------------------------------------------
849
+
850
+ @contextlib.asynccontextmanager
851
+ async def scoped_worker(
852
+ self,
853
+ worker_name: str,
854
+ *,
855
+ inherit_trajectory: bool = True,
856
+ ) -> AsyncIterator[BaseHarnessV2]:
857
+ """`async with harness.scoped_worker("w1") as w:` 得到 worker 子上下文,
858
+ 作用域退出后 w 内部 trajectory 自动截断回收,父级不泄漏(SRS FR-MAGT-1 + Invariant #3)。
859
+
860
+ 参数:
861
+ inherit_trajectory=True(默认):worker 启动前可看到父级当前 trajectory(只读);
862
+ inherit_trajectory=False:worker trajectory 从空列表启动(严格隔离)。
863
+ """
864
+ if inherit_trajectory:
865
+ child_trajectory: list[dict[str, Any]] = list(self.trajectory)
866
+ child_step_count: int = int(self.step_count)
867
+ child_terminated: bool = bool(self.is_terminated)
868
+ else:
869
+ child_trajectory = []
870
+ child_step_count = 0
871
+ child_terminated = False
872
+ # (a) 创建浅绑定 worker(同实例,但将 trajectory / step_count / is_terminated 指向局部作用域变量,
873
+ # 退出作用域时恢复父级)。
874
+ # 为实现简洁、不引入额外 copy,使用「栈式恢复」:
875
+ parent_trajectory_ref = self.trajectory
876
+ parent_step_count_ref = self.step_count
877
+ parent_terminated_ref = self.is_terminated
878
+ exc_info: tuple[Any, Any, Any] | None = None
879
+ try:
880
+ self.trajectory = child_trajectory
881
+ self.step_count = child_step_count
882
+ self.is_terminated = child_terminated
883
+ logger.debug(
884
+ "[scoped_worker:%s] ENTER trajectory.len=%s step_count=%s inherit=%s",
885
+ worker_name,
886
+ len(self.trajectory),
887
+ self.step_count,
888
+ inherit_trajectory,
889
+ )
890
+ yield self
891
+ except Exception as _exc:
892
+ import sys as _sys
893
+
894
+ exc_info = _sys.exc_info()
895
+ raise
896
+ finally:
897
+ # (b) 退出(正常/异常):
898
+ # - CRITICAL(硬约束):必须用 `del list[n:]` **本体截断** child_trajectory,
899
+ # 而不是重新赋值新列表 —— 外部对 child_trajectory 本体的持有引用也会看到截断结果;
900
+ # - inherit_trajectory=True 时,前 parent_len 条 dict 是父级 trajectory 的引用,
901
+ # 绝不允许修改这些 dict(历史 bug:清空 content 会污染父级 trajectory 字典);
902
+ # - 内容回收仅对**截断出来的切片副本**做置空(不影响 parent);
903
+ # - 最后显式 gc.collect() 双保险,防止 worker 大 LLM 响应字符串长生命。
904
+ truncated_slices: list[dict[str, Any]] = []
905
+ try:
906
+ parent_len = len(parent_trajectory_ref) if inherit_trajectory else 0
907
+ if len(child_trajectory) > parent_len:
908
+ # 保留截断出来的 worker 内部消息,便于我们后续 None-ize 内容(不碰 parent 对象)
909
+ truncated_slices = list(child_trajectory[parent_len:])
910
+ # ------------------------------------------------------------------
911
+ # SRS §4.3 FR-MAGT-1 Invariant #3 硬约束:必须使用 `del list[parent_len:]`
912
+ # 本体截断,而不是 child_trajectory = child_trajectory[:parent_len]
913
+ # 后者只改局部变量,外部引用不会观察到截断,内存泄漏 + 上下文泄漏。
914
+ # ------------------------------------------------------------------
915
+ del child_trajectory[parent_len:]
916
+ # 仅对 worker 内部的截断切片内容置空加速 GC(绝不修改 parent_len 之前的对象)
917
+ for m in truncated_slices:
918
+ if not isinstance(m, dict):
919
+ continue
920
+ if isinstance(m.get("content"), str):
921
+ m["content"] = ""
922
+ if isinstance(m.get("thought"), str):
923
+ m["thought"] = ""
924
+ # tool_calls 数组可能是大 JSON,显式清空
925
+ tcs = m.get("tool_calls")
926
+ if isinstance(tcs, list):
927
+ tcs.clear()
928
+ except Exception:
929
+ logger.debug(
930
+ "[scoped_worker:%s] child truncation non-fatal", worker_name, exc_info=True
931
+ )
932
+ finally:
933
+ truncated_slices.clear()
934
+ self.trajectory = parent_trajectory_ref
935
+ self.step_count = parent_step_count_ref
936
+ self.is_terminated = parent_terminated_ref
937
+ logger.debug(
938
+ "[scoped_worker:%s] EXIT parent trajectory.len=%s step_count=%s (worker output: GC discarded)",
939
+ worker_name,
940
+ len(self.trajectory),
941
+ self.step_count,
942
+ )
943
+ # 双保险 GC:scoped_worker 退出边界固定点回收 worker 私有轨迹内存与循环引用
944
+ try:
945
+ gc.collect()
946
+ except Exception: # pragma: no cover - gc.collect() 极少抛
947
+ logger.debug("[scoped_worker:%s] gc.collect non-fatal", worker_name, exc_info=True)
948
+ # 若上层捕获/吞异常,依然保留原始异常语义(不丢 traceback)
949
+ del exc_info
950
+
951
+ # ------------------------------------------------------------------
952
+ # 内部:ReAct Loop / Step (Fix Issue #1)
953
+ # ------------------------------------------------------------------
954
+
955
+ async def _react_loop(self, initial_user_input: str) -> dict[str, Any]:
956
+ retries_this_turn = 0
957
+ while (
958
+ not self.is_terminated
959
+ and len((self._trace and self._trace.turns) or []) < self.max_turns
960
+ ):
961
+ self.step_count += 1
962
+ step_result = await self._react_step()
963
+ st = step_result.get("status")
964
+ if st == "success" and step_result.get("final_answer") is not None:
965
+ return step_result
966
+ if st == "blocked":
967
+ # Constrain 阻断:直接上报,不再 raise(与 V2 run() 中 blocked 分支契约对齐)
968
+ return step_result
969
+ if st == "failed":
970
+ # Correct 熔断:同样直接上报,避免外层断言被迫匹配 max_turns
971
+ return step_result
972
+ if st == "human_required":
973
+ return step_result
974
+ if st == "retry":
975
+ retries_this_turn += 1
976
+ continue
977
+ retries_this_turn = 0
978
+ return {"status": "failed", "error": f"ReAct exceeded max_turns={self.max_turns}"}
979
+
980
+ async def _react_step(self) -> dict[str, Any]:
981
+ assert self._trace is not None, "run() or step() must init trace before _react_step"
982
+ turn = ReActTurnV2(index=len(self._trace.turns))
983
+ self._trace.turns.append(turn)
984
+ retries_this_step = 0
985
+ max_local_retries = max(
986
+ 0, int(self.harness_config.get("correct", {}).get("max_retries", 3))
987
+ )
988
+ last_correction: dict[str, Any] | None = None
989
+ # SRS NFR-OBS-1:如果调用方 install_harness_tracer 设置了 otel_tracer,则每轮生成 span `agentlisp.react.turn`
990
+ otel_tracer = getattr(self, "otel_tracer", None)
991
+ turn_attrs: dict[str, Any] | None = None
992
+ span_ctx: Any = None
993
+ if otel_tracer is not None:
994
+ try:
995
+ from .otel_tracer import AGENTLISP_TURN_SPAN
996
+ except Exception:
997
+ AGENTLISP_TURN_SPAN = "agentlisp.react.turn"
998
+ turn_attrs = {
999
+ "agentlisp.run_id": str(self._trace.run_id),
1000
+ "agentlisp.agent_name": str(self.agent_name or ""),
1001
+ "agentlisp.turn_index": int(turn.index),
1002
+ }
1003
+ span_ctx = otel_tracer.start_as_current_span(
1004
+ AGENTLISP_TURN_SPAN, attributes=dict(turn_attrs)
1005
+ )
1006
+ try:
1007
+ if span_ctx is not None:
1008
+ _span_obj = span_ctx.__enter__()
1009
+ try:
1010
+ self.status_bar.on_step(self._trace.run_id, turn.index, "start")
1011
+
1012
+ while True:
1013
+ # 1) 组装 KV 对齐上下文(每轮 retry 都要重建,因为 correct 的 feedback 会追加进 trajectory)
1014
+ messages = self.build_context()
1015
+
1016
+ # 2) LLM 决策:CR-16 P1-5 Provider / CircuitBreaker / Context-Leakage 层
1017
+ try:
1018
+ if self._llm_orchestrator is not None:
1019
+ # 走新 orchestrator(primary 过 CircuitBreaker / forbidden_context_keys 过滤 / fallback-model)
1020
+ llm_out = await self._llm_orchestrator.achat(messages)
1021
+ else:
1022
+ llm_out = await self.llm_client.achat(messages)
1023
+ except Exception as exc: # Provider 层异常统一落 trace:
1024
+ from .llm_client import (
1025
+ ERR_CONTEXT_LEAKAGE,
1026
+ CircuitBroken,
1027
+ HarnessError,
1028
+ ProviderHTTPError,
1029
+ )
1030
+
1031
+ err_str = str(exc)
1032
+ if isinstance(exc, HarnessError) and ERR_CONTEXT_LEAKAGE in err_str:
1033
+ # FR-CHECK-3 runtime 层 context leakage:trace.status=blocked
1034
+ turn.status = "blocked"
1035
+ self.trajectory.append(
1036
+ {
1037
+ "role": "assistant",
1038
+ "content": err_str,
1039
+ "blocked": True,
1040
+ "context_leakage": True,
1041
+ }
1042
+ )
1043
+ self.status_bar.on_step(
1044
+ self._trace.run_id, turn.index, "blocked", err_str
1045
+ )
1046
+ await self._save_checkpoint()
1047
+ return {"status": "blocked", "reason": err_str}
1048
+ if isinstance(exc, CircuitBroken):
1049
+ # FR-CORRECT-2:circuit breaker 熔断;strategy ∈ {abort / ask-human / fallback-model}
1050
+ # abort / fallback-model(fallback 也失败)→ trace.status=failed;ask-human → 留给上层 HITL
1051
+ if exc.strategy == "ask-human":
1052
+ turn.status = "blocked"
1053
+ self.trajectory.append(
1054
+ {
1055
+ "role": "assistant",
1056
+ "content": err_str,
1057
+ "circuit_break": True,
1058
+ "ask_human_required": True,
1059
+ }
1060
+ )
1061
+ return {"status": "human_required", "reason": err_str}
1062
+ turn.status = "failed"
1063
+ self.trajectory.append(
1064
+ {"role": "assistant", "content": err_str, "circuit_break": True}
1065
+ )
1066
+ return {"status": "failed", "error": err_str, "feedback": err_str}
1067
+ if isinstance(exc, (ProviderHTTPError, TimeoutError, asyncio.TimeoutError)):
1068
+ # provider 单轮失败(没到 breaker 阈值):计入 retry
1069
+ last_correction = {
1070
+ "status": "retry",
1071
+ "feedback": (
1072
+ f"[Provider error] {type(exc).__name__}: {exc}; "
1073
+ "请重发 tool_call 重试。"
1074
+ ),
1075
+ }
1076
+ self.trajectory.append(
1077
+ {"role": "assistant", "content": last_correction["feedback"]}
1078
+ )
1079
+ retries_this_step += 1
1080
+ if retries_this_step > max_local_retries + 3:
1081
+ # 防御性上限:局部 max_retries 之外再容忍 provider 层面若干次失败
1082
+ turn.status = "failed"
1083
+ return {
1084
+ "status": "failed",
1085
+ "error": err_str,
1086
+ "feedback": err_str,
1087
+ }
1088
+ continue
1089
+ raise # 其余异常交给 finally 外层 catch
1090
+ assistant_msg = dict(llm_out)
1091
+ content = assistant_msg.get("content")
1092
+ tool_calls = assistant_msg.get("tool_calls")
1093
+
1094
+ # 无 tool_calls:要么是 final answer,要么是 retry feedback(追加到 trajectory 再跑)
1095
+ if not tool_calls:
1096
+ answer = str(content) if content is not None else ""
1097
+ if last_correction is not None and retries_this_step <= max_local_retries:
1098
+ # 第一次 tool_call verify 失败后 correct 反馈 → 模型回复 content 的情况:
1099
+ # 把 feedback 塞给用户让模型重出 tool_calls(此处把它写回 trajectory 触发下一次模型生成 tool_call)
1100
+ self.trajectory.append({"role": "assistant", "content": answer})
1101
+ retries_this_step += 1
1102
+ if retries_this_step > max_local_retries:
1103
+ turn.answer = answer
1104
+ turn.status = "retry-ignored"
1105
+ return last_correction
1106
+ continue
1107
+ turn.thought = answer or None
1108
+ turn.answer = answer
1109
+ turn.status = "success"
1110
+ self.trajectory.append({"role": "assistant", "content": answer})
1111
+ self.status_bar.on_step(self._trace.run_id, turn.index, "answer", answer)
1112
+ await self._save_checkpoint()
1113
+ return {"status": "success", "final_answer": answer}
1114
+
1115
+ tc = tool_calls[0]
1116
+ fn = tc["function"]
1117
+ tool_name = fn["name"]
1118
+ action_input: dict[str, Any] = fn.get("arguments") or {}
1119
+ tool_call = {"tool_name": tool_name, "args": action_input}
1120
+
1121
+ turn.action = tool_name
1122
+ turn.action_input = action_input
1123
+ if not turn.thought and content:
1124
+ turn.thought = str(content)
1125
+
1126
+ # 3) Constrain → Execute → Verify → Correct 循环
1127
+ allowed, reason = self.constrain(tool_call)
1128
+ if not allowed:
1129
+ turn.status = "blocked"
1130
+ self.trajectory.append(
1131
+ {"role": "tool", "name": tool_name, "content": reason, "blocked": True}
1132
+ )
1133
+ self.status_bar.on_step(self._trace.run_id, turn.index, "blocked", reason)
1134
+ await self._save_checkpoint()
1135
+ return {"status": "blocked", "reason": reason}
1136
+
1137
+ self.status_bar.on_step(self._trace.run_id, turn.index, "tool_call", tool_call)
1138
+ observation: dict[str, Any]
1139
+ if self.tool_registry is not None and tool_name in (
1140
+ self.tool_registry.names() or []
1141
+ ):
1142
+ try:
1143
+ result = await self.tool_registry.acall(tool_name, **action_input)
1144
+ except Exception as exc:
1145
+ observation = {
1146
+ "exit_code": 1,
1147
+ "stdout": "",
1148
+ "stderr": f"{type(exc).__name__}: {exc}",
1149
+ }
1150
+ else:
1151
+ if isinstance(result, dict) and "exit_code" in result:
1152
+ # 工具本身返回结构化 {exit_code, stdout, stderr}: 直接采用(支持 verify 的 exit_code 断言)
1153
+ observation = {
1154
+ "exit_code": int(result.get("exit_code", 0)),
1155
+ "stdout": result.get("stdout", ""),
1156
+ "stderr": result.get("stderr", ""),
1157
+ }
1158
+ else:
1159
+ observation = {
1160
+ "exit_code": 0,
1161
+ "stdout": str(result)
1162
+ if not isinstance(result, (dict, list))
1163
+ else result,
1164
+ "stderr": "",
1165
+ }
1166
+ else:
1167
+ observation = {
1168
+ "exit_code": 0,
1169
+ "stdout": f"[tool {tool_name} not registered; arguments={action_input!r}]",
1170
+ "stderr": "",
1171
+ }
1172
+
1173
+ passed, verify_msg = self.verify(observation)
1174
+ if not passed:
1175
+ turn.status = "retry"
1176
+ retries_this_step += 1
1177
+ correction = await self.correct(
1178
+ tool_call, verify_msg, retries=retries_this_step
1179
+ )
1180
+ last_correction = correction
1181
+ if correction.get("status") == "failed":
1182
+ turn.status = "failed"
1183
+ self.trajectory.append(
1184
+ {
1185
+ "role": "tool",
1186
+ "name": tool_name,
1187
+ "content": verify_msg,
1188
+ "verify_failed": True,
1189
+ }
1190
+ )
1191
+ feedback = correction.get("feedback") or correction.get("error") or ""
1192
+ action_info = correction.get("action")
1193
+ # 把熔断策略(fail_fast / ask_human 等)拼进 feedback,run() 读 failed 分支的 error 字段能读到更完整信息
1194
+ if action_info:
1195
+ feedback = (
1196
+ f"[HarnessCorrect circuit-breaker] strategy={action_info}; "
1197
+ f"retries_exceeded={retries_this_step}; last_error={feedback or verify_msg}"
1198
+ )
1199
+ self.trajectory.append({"role": "assistant", "content": feedback})
1200
+ correction = dict(correction)
1201
+ correction["feedback"] = feedback
1202
+ correction["error"] = feedback
1203
+ await self._save_checkpoint()
1204
+ return correction
1205
+ # retry:把 verify 失败原因 + correct feedback 追加进 trajectory 继续下一轮
1206
+ self.trajectory.append(
1207
+ {
1208
+ "role": "tool",
1209
+ "name": tool_name,
1210
+ "content": verify_msg,
1211
+ "retry": True,
1212
+ }
1213
+ )
1214
+ self.trajectory.append(
1215
+ {"role": "user", "content": correction.get("feedback") or ""}
1216
+ )
1217
+ await self._save_checkpoint()
1218
+ if retries_this_step > max_local_retries:
1219
+ return last_correction or correction
1220
+ continue
1221
+
1222
+ # 成功分支
1223
+ turn.observation = observation
1224
+ turn.status = "success"
1225
+ stdout_for_traj = (
1226
+ observation
1227
+ if isinstance(observation, (dict, list))
1228
+ else observation.get("stdout")
1229
+ )
1230
+ self.trajectory.append(
1231
+ {"role": "tool", "name": tool_name, "content": stdout_for_traj}
1232
+ )
1233
+ self.status_bar.on_step(
1234
+ self._trace.run_id, turn.index, "tool_result", observation
1235
+ )
1236
+ await self._save_checkpoint()
1237
+ return {
1238
+ "status": "success",
1239
+ "output": observation.get("stdout", ""),
1240
+ "observation": observation,
1241
+ }
1242
+ finally:
1243
+ pass
1244
+ except asyncio.CancelledError:
1245
+ if span_ctx is not None and _span_obj is not None:
1246
+ try:
1247
+ _span_obj.record_exception(asyncio.CancelledError())
1248
+ except Exception:
1249
+ pass
1250
+ raise
1251
+ except Exception as exc:
1252
+ if self._trace is not None and self.checkpoint_store is not None:
1253
+ try:
1254
+ await self.checkpoint_store.save(
1255
+ self._trace.run_id,
1256
+ {
1257
+ "trace": self._trace.to_dict(),
1258
+ "trajectory": list(self.trajectory),
1259
+ "step_count": self.step_count,
1260
+ "is_terminated": self.is_terminated,
1261
+ },
1262
+ )
1263
+ except Exception:
1264
+ logger.exception("[Checkpoint] emergency save failed, non-fatal")
1265
+ turn.status = "failed"
1266
+ if span_ctx is not None and _span_obj is not None:
1267
+ try:
1268
+ _span_obj.set_attribute("agentlisp.turn_status", "failed")
1269
+ _span_obj.record_exception(exc)
1270
+ except Exception:
1271
+ pass
1272
+ raise
1273
+ finally:
1274
+ try:
1275
+ if span_ctx is not None and _span_obj is not None:
1276
+ try:
1277
+ _span_obj.set_attribute("agentlisp.turn_status", str(turn.status or ""))
1278
+ except Exception:
1279
+ pass
1280
+ if span_ctx is not None:
1281
+ span_ctx.__exit__(None, None, None)
1282
+ except Exception:
1283
+ logger.debug("[otel] span end non-fatal error", exc_info=True)
1284
+ turn.finished_at = time.monotonic()
1285
+
1286
+ # ------------------------------------------------------------------
1287
+ # Checkpoint Hook (Fix Issue #5)
1288
+ # ------------------------------------------------------------------
1289
+
1290
+ async def _save_checkpoint(self) -> None:
1291
+ if self._trace is None or self.checkpoint_store is None:
1292
+ return
1293
+ try:
1294
+ await self.checkpoint_store.save(
1295
+ self._trace.run_id,
1296
+ {
1297
+ "trace": self._trace.to_dict(),
1298
+ "trajectory": list(self.trajectory),
1299
+ "step_count": self.step_count,
1300
+ "is_terminated": self.is_terminated,
1301
+ },
1302
+ )
1303
+ except Exception:
1304
+ logger.exception("[Checkpoint] save failed, non-fatal, continue")
1305
+
1306
+ async def from_checkpoint(self, run_id: str) -> ExecutionTraceV2:
1307
+ payload = await self.checkpoint_store.load(run_id)
1308
+ if not payload:
1309
+ raise KeyError(f"checkpoint not found: {run_id}")
1310
+ # 恢复内存状态
1311
+ self.trajectory = list(payload.get("trajectory", []))
1312
+ self.step_count = int(payload.get("step_count", 0))
1313
+ self.is_terminated = bool(payload.get("is_terminated", False))
1314
+ td = payload["trace"]
1315
+ self._trace = ExecutionTraceV2(
1316
+ run_id=td["run_id"],
1317
+ agent_name=td.get("agent_name"),
1318
+ started_at=datetime.fromisoformat(td["started_at"]),
1319
+ finished_at=datetime.fromisoformat(td["finished_at"])
1320
+ if td.get("finished_at")
1321
+ else None,
1322
+ turns=[
1323
+ ReActTurnV2(
1324
+ **{
1325
+ k: v
1326
+ for k, v in t.items()
1327
+ if k
1328
+ in (
1329
+ "index",
1330
+ "thought",
1331
+ "action",
1332
+ "action_input",
1333
+ "observation",
1334
+ "answer",
1335
+ "status",
1336
+ "started_at",
1337
+ "finished_at",
1338
+ )
1339
+ }
1340
+ )
1341
+ for t in td.get("turns", [])
1342
+ ],
1343
+ final_answer=td.get("final_answer"),
1344
+ status=td.get("status", "pending"),
1345
+ error=td.get("error"),
1346
+ )
1347
+ return self._trace
1348
+
1349
+
1350
+ # ==============================================================================
1351
+ # 单文件本地冒烟(对应用户提供版本的 __main__ 入口)
1352
+ # ==============================================================================
1353
+
1354
+
1355
+ if __name__ == "__main__":
1356
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - [%(levelname)s] - %(message)s")
1357
+ print("=== 测试 AgentLisp BaseHarnessV2 合并版运行时 ===")
1358
+
1359
+ harness = BaseHarnessV2(
1360
+ model_config={
1361
+ "system_prompt": "你是一个自动修复 Bug 的 Agent。",
1362
+ "agent_name": "document-repairer-v2",
1363
+ },
1364
+ context_config={"status_bar": {"step_count": True}},
1365
+ tools_config={"tools_schema": "Tool: bash(command: str)"},
1366
+ harness_config={
1367
+ "constrain": {"forbidden_commands": ["rm -rf"]},
1368
+ "correct": {"max_retries": 3, "on_failure": "ask_human"},
1369
+ },
1370
+ )
1371
+
1372
+ trace = asyncio.run(harness.run("请检查当前目录下的文件"))
1373
+ print(
1374
+ f"✅ BaseHarnessV2 运行成功:run_id={trace.run_id} status={trace.status} turns={len(trace.turns)} duration_ms={trace.duration_ms}"
1375
+ )
1376
+ for t in trace.turns:
1377
+ print(
1378
+ f" turn[{t.index}] status={t.status} action={t.action!r} duration_ms={t.duration_ms}"
1379
+ )