agentlisp 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentlisp-2.0.0.dist-info/METADATA +482 -0
- agentlisp-2.0.0.dist-info/RECORD +36 -0
- agentlisp-2.0.0.dist-info/WHEEL +4 -0
- agentlisp-2.0.0.dist-info/entry_points.txt +3 -0
- agentlisp_runtime/__init__.py +29 -0
- agentlisp_runtime/cli.py +145 -0
- agentlisp_runtime/engine.py +253 -0
- agentlisp_runtime/loader.py +126 -0
- agentlisp_runtime/models.py +129 -0
- host/__init__.py +5 -0
- host/gateway.py +128 -0
- host/gateway_sse.py +788 -0
- host/sandbox.py +143 -0
- host/sandbox_e2b.py +173 -0
- host/tests/test_infra_docker_e2e.py +247 -0
- host/workflow.py +803 -0
- host/workflow_temporal.py +89 -0
- runtime/__init__.py +29 -0
- runtime/base_agent_harness.py +164 -0
- runtime/base_harness.py +359 -0
- runtime/base_harness_v2.py +1379 -0
- runtime/checker.py +654 -0
- runtime/checkpoint.py +98 -0
- runtime/errors.py +37 -0
- runtime/llm_client.py +644 -0
- runtime/mcp_client.py +86 -0
- runtime/memory_fs.py +90 -0
- runtime/otel_tracer.py +124 -0
- runtime/status_bar.py +86 -0
- runtime/tests/test_ac1_rackunit_18cases_roundtrip.py +255 -0
- runtime/tests/test_cli_if_cli_1_exit_encoding.py +309 -0
- runtime/tests/test_harness_v2.py +2030 -0
- runtime/tests/test_sideeffect_builtin_tools_racket_and_python_ssot_8_items_bitwise_equal.py +132 -0
- runtime/tests/test_srs_acceptance.py +1369 -0
- runtime/tests/test_t2_bench_dataset.py +86 -0
- runtime/tests/test_v2_smoke.py +139 -0
|
@@ -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
|
+
)
|