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.
runtime/checker.py ADDED
@@ -0,0 +1,654 @@
1
+ """AgentLisp v2 static-checker Python-side SSOT mirror.
2
+
3
+ Mirrors compiler/checker.rkt (Racket) invariants for runtime usage
4
+ (no Racket install needed on host when CI unavailable).
5
+
6
+ Racket SSOT (L87 compiler/checker.rkt):
7
+ (define SIDEEFFECT-BUILTIN-TOOLS '(bash git-push wget curl scp dd chmod sudo))
8
+
9
+ Python SSOT (runtime/checker.py) must be a strict superset. If the two drift,
10
+ test_fr_check_2_sideeffect_builtins_ssot_consistent() will fail in CI.
11
+
12
+ FR-PARSER-N (§3 SRS L85-L90) invariants are mirrored here for pytest side
13
+ (validate_* helpers), so tests can PASS without a Racket compiler on host.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Any
20
+ from urllib.parse import urlparse
21
+
22
+ SIDEEFFECT_BUILTIN_TOOLS: frozenset[str] = frozenset(
23
+ {
24
+ "bash",
25
+ "git-push",
26
+ "wget",
27
+ "curl",
28
+ "scp",
29
+ "dd",
30
+ "chmod",
31
+ "sudo",
32
+ }
33
+ )
34
+
35
+ PARSER_ERROR_SCHEMA_VERSION: str = "1.0.0"
36
+
37
+ FR_PARSER_PROVIDER_ENUM: frozenset[str] = frozenset({"anthropic", "openai", "qwen", "mock"})
38
+
39
+ FR_PARSER_ON_FAILURE_ENUM: frozenset[str] = frozenset({"ask-human", "fallback-model", "abort"})
40
+
41
+ FR_PARSER_TOPOLOGY_ENUM: frozenset[str] = frozenset(
42
+ {"peer", "orchestration", "decentralised", "judge-driven"}
43
+ )
44
+
45
+ FR_PARSER_MCP_SCHEMES: frozenset[str] = frozenset({"stdio", "http+unix", "https", "sse"})
46
+
47
+ FR_PARSER_SCOPED_WORKER_REQUIRED_BLOCKS: tuple[str, ...] = (
48
+ "name",
49
+ "model",
50
+ "tools",
51
+ "harness",
52
+ )
53
+
54
+ PARSE_ERR_ILLEGAL_SEXP: str = "PARSE_ILLEGAL_SEXP"
55
+ PARSE_ERR_PROVIDER_OR_TEMP: str = "PARSE_PROVIDER_ENUM_OR_TEMPERATURE_RANGE"
56
+ PARSE_ERR_MEMORY_AUTO_APPEND: str = "PARSE_MEMORY_AUTO_APPEND_KV_ALIGNMENT"
57
+ PARSE_ERR_TOOLS_COMBINATION: str = "PARSE_TOOLS_COMBINATION_OR_MCP_SCHEME"
58
+ PARSE_ERR_ON_FAILURE: str = "PARSE_CORRECT_ON_FAILURE_ENUM"
59
+ PARSE_ERR_TOPOLOGY: str = "PARSE_MULTIAGENT_TOPOLOGY_OR_SCOPED_WORKER"
60
+
61
+
62
+ _CHECKER_RKT_SIDEEFFECT_RE = re.compile(
63
+ r"\(define\s+SIDEEFFECT-BUILTIN-TOOLS\s*'*\((?P<items>[^()]*)\)\s*\)"
64
+ )
65
+
66
+
67
+ def sideeffect_builtin_names() -> set[str]:
68
+ return set(SIDEEFFECT_BUILTIN_TOOLS)
69
+
70
+
71
+ def extract_checker_rkt_sideeffect_list(src: str) -> list[str]:
72
+ """Extract symbols from Racket source SIDEEFFECT-BUILTIN-TOOLS definition.
73
+
74
+ Returns the list of whitespace-separated identifiers inside the outer list,
75
+ preserving order. Returns [] if not found (caller decides how to handle).
76
+ """
77
+ if not isinstance(src, str):
78
+ return []
79
+ m = _CHECKER_RKT_SIDEEFFECT_RE.search(src)
80
+ if not m:
81
+ return []
82
+ body = m.group("items")
83
+ return [tok for tok in body.split() if tok]
84
+
85
+
86
+ def check_sideeffect_builtins_racket_mirror(racket_src: str) -> tuple[bool, list[str], list[str]]:
87
+ """Return (ok?, racket_only, python_only).
88
+
89
+ ok is True iff the two SIDEEFFECT-BUILTIN-TOOLS sets match exactly
90
+ (order ignored).
91
+ """
92
+ racket_tokens = extract_checker_rkt_sideeffect_list(racket_src)
93
+ racket_set = set(racket_tokens)
94
+ py_set = set(SIDEEFFECT_BUILTIN_TOOLS)
95
+ return (
96
+ racket_set == py_set,
97
+ sorted(racket_set - py_set),
98
+ sorted(py_set - racket_set),
99
+ )
100
+
101
+
102
+ def make_parse_error_json(
103
+ code: str,
104
+ srs_id: str,
105
+ message: str,
106
+ *,
107
+ agent_name: str | None = None,
108
+ source: str = "<al-string>",
109
+ line: int | None = 1,
110
+ column: int | None = 0,
111
+ position: int | None = 0,
112
+ span: int | None = 0,
113
+ hints: list[str] | None = None,
114
+ severity: str = "error",
115
+ ) -> dict[str, Any]:
116
+ """Build a 12-field structured parse-error dict (mirrors Racket --json-errors).
117
+
118
+ Top-level keys (8): schema_version, code, severity, srs_id, message,
119
+ agent_name, srcloc, hints.
120
+ srcloc sub-keys (5): source, line, column, position, span.
121
+ Total distinct attribute slots (13; SRS L277 rounds to "12 fields" — srcloc
122
+ counts as one compound slot, which is the typical convention).
123
+ """
124
+ return {
125
+ "schema_version": PARSER_ERROR_SCHEMA_VERSION,
126
+ "code": code,
127
+ "severity": severity,
128
+ "srs_id": srs_id,
129
+ "message": message,
130
+ "agent_name": agent_name or "unknown-agent",
131
+ "srcloc": {
132
+ "source": source,
133
+ "line": line,
134
+ "column": column,
135
+ "position": position,
136
+ "span": span,
137
+ },
138
+ "hints": list(hints) if hints else [],
139
+ }
140
+
141
+
142
+ def validate_provider_and_temperature(
143
+ provider: Any, temperature: Any
144
+ ) -> tuple[bool, dict[str, Any] | None]:
145
+ """FR-PARSER-2: provider ∈ {anthropic, openai, qwen, mock}; t ∈ [0.0, 1.0].
146
+
147
+ Returns (ok, error_json_or_None).
148
+ """
149
+ if not isinstance(provider, str) or provider not in FR_PARSER_PROVIDER_ENUM:
150
+ return False, make_parse_error_json(
151
+ PARSE_ERR_PROVIDER_OR_TEMP,
152
+ "FR-PARSER-2",
153
+ f"FR-PARSER-2: provider={provider!r} 不在枚举 {sorted(FR_PARSER_PROVIDER_ENUM)}",
154
+ hints=[
155
+ f"provider 必须是以下字符串之一:{sorted(FR_PARSER_PROVIDER_ENUM)}",
156
+ "大小写敏感;禁止缩写或空串",
157
+ ],
158
+ )
159
+ if isinstance(temperature, bool) or not isinstance(temperature, (int, float)):
160
+ return False, make_parse_error_json(
161
+ PARSE_ERR_PROVIDER_OR_TEMP,
162
+ "FR-PARSER-2",
163
+ f"FR-PARSER-2: temperature={temperature!r} 不是实数",
164
+ hints=["temperature 必须是 [0.0, 1.0] 之间的数字(int/float)"],
165
+ )
166
+ t = float(temperature)
167
+ if t < 0.0 or t > 1.0:
168
+ return False, make_parse_error_json(
169
+ PARSE_ERR_PROVIDER_OR_TEMP,
170
+ "FR-PARSER-2",
171
+ f"FR-PARSER-2: temperature={temperature!r} 超出 [0.0, 1.0] 范围",
172
+ hints=[
173
+ "temperature ∈ [0.0, 1.0](端点包含)",
174
+ f"当前值 temperature={t},若 >1.0 请下调;若 <0.0 请上调",
175
+ ],
176
+ )
177
+ return True, None
178
+
179
+
180
+ def validate_memory_auto_append_key(memory_cfg: Any) -> tuple[bool, dict[str, Any] | None]:
181
+ """FR-PARSER-3: spec 命名 :auto-append → emit 到 Python auto_append_episodic。
182
+
183
+ 验证要点(SRS L87):
184
+ - 禁止使用旧命名 :auto-append-episodic(spec 层已经废弃)
185
+ - :auto-append 的 #t/#f 值必须保留,不能静默丢失
186
+ - emit context 段输出键必须是 auto_append_episodic(下划线)
187
+ """
188
+ if not isinstance(memory_cfg, dict):
189
+ return False, make_parse_error_json(
190
+ PARSE_ERR_MEMORY_AUTO_APPEND,
191
+ "FR-PARSER-3",
192
+ f"FR-PARSER-3: memory_cfg={memory_cfg!r} 不是 dict",
193
+ hints=["memory-policy 块必须 emit 为 dict[str, Any]"],
194
+ )
195
+ legacy_key = "auto-append-episodic"
196
+ spec_key = "auto-append"
197
+ target_py_key = "auto_append_episodic"
198
+ if legacy_key in memory_cfg:
199
+ return False, make_parse_error_json(
200
+ PARSE_ERR_MEMORY_AUTO_APPEND,
201
+ "FR-PARSER-3",
202
+ "FR-PARSER-3: 检测到废弃命名 :auto-append-episodic,请改为 :auto-append",
203
+ hints=[
204
+ "spec 层命名规范:使用 :auto-append(禁止旧命名 :auto-append-episodic)",
205
+ "emit 到 Python context_config.auto_append_episodic(下划线分隔)",
206
+ ],
207
+ )
208
+ if spec_key not in memory_cfg:
209
+ return True, None
210
+ raw = memory_cfg[spec_key]
211
+ # SRS:DSL 中 #t / #f 对应 Python True / False,值绝不静默丢失
212
+ if raw is None or (isinstance(raw, str) and raw.strip() == ""):
213
+ return False, make_parse_error_json(
214
+ PARSE_ERR_MEMORY_AUTO_APPEND,
215
+ "FR-PARSER-3",
216
+ f"FR-PARSER-3: :auto-append 值 {raw!r} 被静默丢失(#t/#f 必须保留)",
217
+ hints=[
218
+ ":auto-append #t 或 #f 必须透传到 context_config.auto_append_episodic = True/False"
219
+ ],
220
+ )
221
+ # spec → emit 目标键重命名::auto-append → auto_append_episodic
222
+ if memory_cfg.get(target_py_key) is None:
223
+ return False, make_parse_error_json(
224
+ PARSE_ERR_MEMORY_AUTO_APPEND,
225
+ "FR-PARSER-3",
226
+ f"FR-PARSER-3: spec 命名 :auto-append 未重命名到 Python 下划线键 {target_py_key!r}",
227
+ hints=[
228
+ "parse-memory-policy 必须执行 spec→python 键重命名::auto-append ⇒ auto_append_episodic",
229
+ f"期望 context_config 出现键 {target_py_key},当前 memory_cfg keys={sorted(memory_cfg.keys())}",
230
+ ],
231
+ )
232
+ return True, None
233
+
234
+
235
+ def validate_tools_combination_and_mcp_scheme(tools_cfg: Any) -> tuple[bool, dict[str, Any] | None]:
236
+ """FR-PARSER-4: 支持以下 4 种 tools 组合,都不得崩溃:
237
+ ① only define-tool × N
238
+ ② only import-builtin
239
+ ③ only import-mcp URL (scheme 白名单)
240
+ ④ 任意组合
241
+ 同时校验 import-mcp 的 URL scheme ∈ MCP_ALLOWED_SCHEMES。
242
+ """
243
+ if not isinstance(tools_cfg, dict):
244
+ return False, make_parse_error_json(
245
+ PARSE_ERR_TOOLS_COMBINATION,
246
+ "FR-PARSER-4",
247
+ f"FR-PARSER-4: tools_cfg={tools_cfg!r} 不是 dict",
248
+ hints=[
249
+ "tools 块 emit 格式:{'define_tools':[...], 'import_builtin':bool, 'import_mcp':[url,...]}"
250
+ ],
251
+ )
252
+ define_tools = tools_cfg.get("define_tools", []) or []
253
+ import_builtin = bool(tools_cfg.get("import_builtin", False))
254
+ import_mcp = tools_cfg.get("import_mcp", []) or []
255
+ _ = import_builtin
256
+ if not isinstance(define_tools, list):
257
+ return False, make_parse_error_json(
258
+ PARSE_ERR_TOOLS_COMBINATION,
259
+ "FR-PARSER-4",
260
+ "FR-PARSER-4: define_tools 必须是 list",
261
+ hints=["① only define-tool:list length>=1 / import_builtin=False / import_mcp=[]"],
262
+ )
263
+ if not isinstance(import_mcp, list):
264
+ return False, make_parse_error_json(
265
+ PARSE_ERR_TOOLS_COMBINATION,
266
+ "FR-PARSER-4",
267
+ "FR-PARSER-4: import_mcp 必须是 list[str]",
268
+ hints=["③ only import-mcp:每个 URL 的 scheme 必须是 {stdio,http+unix,https,sse}"],
269
+ )
270
+ # 4 种组合都允许(只要至少有一种非空或全空也合法)
271
+ # 现在逐一校验 import_mcp URL scheme 白名单
272
+ for url in import_mcp:
273
+ if not isinstance(url, str) or not url:
274
+ return False, make_parse_error_json(
275
+ PARSE_ERR_TOOLS_COMBINATION,
276
+ "FR-PARSER-4",
277
+ f"FR-PARSER-4: import-mcp URL={url!r} 必须是非空字符串",
278
+ hints=[f"MCP scheme 白名单:{sorted(FR_PARSER_MCP_SCHEMES)}"],
279
+ )
280
+ parsed = urlparse(url)
281
+ scheme = (parsed.scheme or "").lower()
282
+ if scheme not in FR_PARSER_MCP_SCHEMES:
283
+ return False, make_parse_error_json(
284
+ PARSE_ERR_TOOLS_COMBINATION,
285
+ "FR-PARSER-4",
286
+ f"FR-PARSER-4: import-mcp scheme={scheme!r} 不在白名单 {sorted(FR_PARSER_MCP_SCHEMES)}",
287
+ hints=[
288
+ "禁止明文 http/ws(请改用 https / sse / stdio / http+unix)",
289
+ f"当前 URL:{url}",
290
+ ],
291
+ )
292
+ return True, None
293
+
294
+
295
+ def validate_correct_on_failure(on_failure: Any) -> tuple[bool, dict[str, Any] | None]:
296
+ """FR-PARSER-5: on-failure ∈ {ask-human, fallback-model, abort}。
297
+
298
+ SRS L89:三枚举「-」连字符命名;其它值(包括下划线 ask_human 变体)
299
+ 在 parser 严格模式下视为 FAIL。
300
+ """
301
+ if not isinstance(on_failure, str) or not on_failure:
302
+ return False, make_parse_error_json(
303
+ PARSE_ERR_ON_FAILURE,
304
+ "FR-PARSER-5",
305
+ f"FR-PARSER-5: on_failure={on_failure!r} 不是合法字符串",
306
+ hints=[f"合法 on-failure 值:{sorted(FR_PARSER_ON_FAILURE_ENUM)}(连字符)"],
307
+ )
308
+ if on_failure in FR_PARSER_ON_FAILURE_ENUM:
309
+ return True, None
310
+ # 下划线是常见误用:在 parser 阶段必须 FAIL(SRS 限定连字符)
311
+ if on_failure in {"ask_human", "fallback_model"}:
312
+ return False, make_parse_error_json(
313
+ PARSE_ERR_ON_FAILURE,
314
+ "FR-PARSER-5",
315
+ f"FR-PARSER-5: on_failure={on_failure!r} 使用了下划线命名,SRS 限定连字符",
316
+ hints=[
317
+ "SRS 规范:on-failure 使用连字符(-),不允许下划线(_)",
318
+ f"纠正:{on_failure.replace('_', '-')}",
319
+ ],
320
+ )
321
+ return False, make_parse_error_json(
322
+ PARSE_ERR_ON_FAILURE,
323
+ "FR-PARSER-5",
324
+ f"FR-PARSER-5: on_failure={on_failure!r} 不在枚举 {sorted(FR_PARSER_ON_FAILURE_ENUM)}",
325
+ hints=[f"三选一:{' / '.join(sorted(FR_PARSER_ON_FAILURE_ENUM))}"],
326
+ )
327
+
328
+
329
+ def validate_topology_and_scoped_worker_min_blocks(
330
+ topology: Any,
331
+ scoped_workers: Any = None,
332
+ ) -> tuple[bool, dict[str, Any] | None]:
333
+ """FR-PARSER-6:
334
+ - topology ∈ {peer, orchestration, decentralised, judge-driven}
335
+ - 每个 scoped-worker 至少含 name + model + tools + harness 四块
336
+ """
337
+ if not isinstance(topology, str) or topology not in FR_PARSER_TOPOLOGY_ENUM:
338
+ return False, make_parse_error_json(
339
+ PARSE_ERR_TOPOLOGY,
340
+ "FR-PARSER-6",
341
+ f"FR-PARSER-6: topology={topology!r} 不在枚举 {sorted(FR_PARSER_TOPOLOGY_ENUM)}",
342
+ hints=[
343
+ "topology 必须是以下字符串(大小写敏感,连字符):"
344
+ f"{sorted(FR_PARSER_TOPOLOGY_ENUM)}",
345
+ "拼写提示:decentralised 是英式拼写(带 s,不是 centralized)",
346
+ ],
347
+ )
348
+ if scoped_workers is None:
349
+ return True, None
350
+ if not isinstance(scoped_workers, list):
351
+ return False, make_parse_error_json(
352
+ PARSE_ERR_TOPOLOGY,
353
+ "FR-PARSER-6",
354
+ "FR-PARSER-6: scoped_workers 必须是 list[dict]",
355
+ hints=["MultiAgent 下 scoped_workers 是每个子 agent 的 dict 列表"],
356
+ )
357
+ for i, worker in enumerate(scoped_workers):
358
+ if not isinstance(worker, dict):
359
+ return False, make_parse_error_json(
360
+ PARSE_ERR_TOPOLOGY,
361
+ "FR-PARSER-6",
362
+ f"FR-PARSER-6: scoped_workers[{i}] 不是 dict",
363
+ hints=[f"每个 scoped-worker 必须包含 {FR_PARSER_SCOPED_WORKER_REQUIRED_BLOCKS}"],
364
+ )
365
+ missing = [k for k in FR_PARSER_SCOPED_WORKER_REQUIRED_BLOCKS if k not in worker]
366
+ if missing:
367
+ return False, make_parse_error_json(
368
+ PARSE_ERR_TOPOLOGY,
369
+ "FR-PARSER-6",
370
+ f"FR-PARSER-6: scoped_workers[{i}] 缺少块 {missing}",
371
+ hints=[
372
+ f"scoped-worker 最小四块:{' + '.join(FR_PARSER_SCOPED_WORKER_REQUIRED_BLOCKS)}",
373
+ f"当前缺:{missing}",
374
+ ],
375
+ )
376
+ return True, None
377
+
378
+
379
+ def _str(v: Any) -> str:
380
+ if isinstance(v, str):
381
+ return v
382
+ if isinstance(v, bool):
383
+ return "True" if v else "False"
384
+ return str(v)
385
+
386
+
387
+ def _get(d: Any, key: str, default: Any = None) -> Any:
388
+ if not isinstance(d, dict):
389
+ return default
390
+ if key in d:
391
+ return d[key]
392
+ sym_key = f":{key}"
393
+ if sym_key in d:
394
+ return d[sym_key]
395
+ kw_repr = f":{key}"
396
+ for k in d:
397
+ ks = _str(k)
398
+ if ks in (key, kw_repr) or ks.lstrip(":") == key:
399
+ return d[k]
400
+ return default
401
+
402
+
403
+ def _order_list(order: Any) -> list[str]:
404
+ if order is None:
405
+ return []
406
+ if not isinstance(order, list):
407
+ return []
408
+ out: list[str] = []
409
+ for item in order:
410
+ s = _str(item).lstrip(":")
411
+ if s:
412
+ out.append(s)
413
+ return out
414
+
415
+
416
+ def _list_of_dicts(x: Any) -> list[dict]:
417
+ if not isinstance(x, list):
418
+ return []
419
+ return [item for item in x if isinstance(item, dict)]
420
+
421
+
422
+ def check_kv_alignment_order(agent_dict: dict) -> tuple[bool, dict | None]:
423
+ """FR-CHECK-1: 静态块 :model/:tools 必须在动态块 :context 之前。
424
+
425
+ 递归检查 scoped-worker 子块(与 Racket checker.rkt L360-L382 对齐)。
426
+ """
427
+ order = _order_list(_get(agent_dict, "order"))
428
+ name = _str(_get(agent_dict, "name", "anon"))
429
+ ctx_pos = order.index("context") if "context" in order else None
430
+ model_pos = order.index("model") if "model" in order else None
431
+ tools_pos = order.index("tools") if "tools" in order else None
432
+ if (model_pos is not None and ctx_pos is not None and model_pos > ctx_pos) or (
433
+ tools_pos is not None and ctx_pos is not None and tools_pos > ctx_pos
434
+ ):
435
+ return False, make_parse_error_json(
436
+ "ERR_KV_ALIGNMENT_VIOLATION",
437
+ "FR-CHECK-1",
438
+ f"FR-CHECK-1: agent={name} 静态块 (:model/:tools) 出现在动态块 :context 之后(实际顺序={order})",
439
+ agent_name=name,
440
+ hints=[
441
+ "SRS §4.1.1 KV 顺序必须是 :model → :tools → :context(允许缺 harness/multiagent)",
442
+ "把 :context 整块移动到 :tools 之后即可修复",
443
+ ],
444
+ )
445
+ multi = _get(agent_dict, "multi")
446
+ workers = _list_of_dicts(_get(multi if isinstance(multi, dict) else {}, "workers"))
447
+ for w in workers:
448
+ w_name = _str(_get(w, "name", "anon-worker"))
449
+ w_order = _order_list(_get(w, "order"))
450
+ w_ctx = w_order.index("context") if "context" in w_order else None
451
+ w_model = w_order.index("model") if "model" in w_order else None
452
+ w_tools = w_order.index("tools") if "tools" in w_order else None
453
+ if (w_model is not None and w_ctx is not None and w_model > w_ctx) or (
454
+ w_tools is not None and w_ctx is not None and w_tools > w_ctx
455
+ ):
456
+ return False, make_parse_error_json(
457
+ "ERR_KV_ALIGNMENT_VIOLATION",
458
+ "FR-CHECK-1",
459
+ f"FR-CHECK-1: scoped-worker={w_name}: 静态块出现在动态块之后(实际顺序={w_order})",
460
+ agent_name=w_name,
461
+ hints=[
462
+ "scoped-worker 的 :model/:tools 也必须在 :context 之前(SRS §4.3 FR-MAGT-1)",
463
+ "把该 worker 的 :context 移到 :tools 之后即可",
464
+ ],
465
+ )
466
+ return True, None
467
+
468
+
469
+ def check_unguarded_tool_execution(agent_dict: dict) -> tuple[bool, dict | None]:
470
+ """FR-CHECK-2: 有副作用 builtin(SIDEEFFECT_BUILTIN_TOOLS 8 项)必须满足:
471
+ (A) require_human_approval 明确 AND forbidden_commands 至少 1 条非空;
472
+ OR (B) verify 任一字段 json_schema/linter_check/test_runner/reviewer_agent 真/非空。
473
+ 递归检查 scoped-worker(Racket checker.rkt L460-L490 对齐)。
474
+ """
475
+
476
+ def _sideeffect_names(tools_dict: Any) -> list[str]:
477
+ builtins = _get(tools_dict, "builtins")
478
+ if builtins is None:
479
+ builtins = _get(tools_dict, "import_builtins", [])
480
+ if not isinstance(builtins, list):
481
+ return []
482
+ return [_str(b) for b in builtins if _str(b) in SIDEEFFECT_BUILTIN_TOOLS]
483
+
484
+ def _approval_ok(approval_list: Any, name: str) -> bool:
485
+ if not isinstance(approval_list, list):
486
+ return False
487
+ return any(_str(a) == name for a in approval_list)
488
+
489
+ def _forbidden_non_empty(forbidden_list: Any) -> bool:
490
+ if not isinstance(forbidden_list, list):
491
+ return False
492
+ return any(isinstance(f, str) and f.strip() != "" for f in forbidden_list)
493
+
494
+ def _verify_any(verify_dict: Any) -> bool:
495
+ if not isinstance(verify_dict, dict):
496
+ return False
497
+ for k in ("json_schema", "linter_check", "test_runner", "reviewer_agent"):
498
+ v = _get(verify_dict, k)
499
+ if v is None:
500
+ continue
501
+ if isinstance(v, bool) and v:
502
+ return True
503
+ if isinstance(v, str) and v.strip() != "":
504
+ return True
505
+ if isinstance(v, (dict, list)) and len(v) > 0:
506
+ return True
507
+ return False
508
+
509
+ def _check_one(name: str, tools_dict: Any, harness_dict: Any) -> tuple[bool, dict | None]:
510
+ se = _sideeffect_names(tools_dict)
511
+ if not se:
512
+ return True, None
513
+ constrain = _get(harness_dict, "constrain", {}) or {}
514
+ verify = _get(harness_dict, "verify", {}) or {}
515
+ approval = _get(constrain, "require_human_approval", [])
516
+ forbidden = _get(constrain, "forbidden_commands", [])
517
+ has_ver = _verify_any(verify)
518
+ unguarded: list[str] = []
519
+ for n in se:
520
+ ok = has_ver or (_approval_ok(approval, n) and _forbidden_non_empty(forbidden))
521
+ if not ok:
522
+ unguarded.append(n)
523
+ if unguarded:
524
+ return False, make_parse_error_json(
525
+ "ERR_UNGUARDED_TOOL_EXECUTION",
526
+ "FR-CHECK-2",
527
+ (
528
+ f"FR-CHECK-2: agent={name} 有副作用工具={unguarded} 未被护栏保护:"
529
+ f"要求 (require_approval 明确列出 AND forbidden 非空) OR verify 任一项断言开启;"
530
+ f"当前 approval={approval} forbidden={forbidden} verify 开={has_ver}"
531
+ ),
532
+ agent_name=name,
533
+ hints=[
534
+ f"工具 {unguarded} 在 SIDEEFFECT_BUILTIN_TOOLS 内(bash/git-push/wget/curl/scp/dd/chmod/sudo 默认都是),必须有护栏",
535
+ "方法 A:把工具名加到 (:constrain :require-human-approval (TOOL…)),并确保 forbidden-commands 至少 1 条非空",
536
+ '方法 B:(:verify :json-schema #t / :linter-check #t / :test-runner "…" / :reviewer-agent "judge") 任一项开启',
537
+ ],
538
+ )
539
+ return True, None
540
+
541
+ name = _str(_get(agent_dict, "name", "anon"))
542
+ ok, err = _check_one(name, _get(agent_dict, "tools", {}), _get(agent_dict, "harness", {}))
543
+ if not ok:
544
+ return False, err
545
+ multi = _get(agent_dict, "multi")
546
+ workers = _list_of_dicts(_get(multi if isinstance(multi, dict) else {}, "workers"))
547
+ for w in workers:
548
+ w_name = _str(_get(w, "name", "anon-worker"))
549
+ ok, err = _check_one(w_name, _get(w, "tools", {}), _get(w, "harness", {}))
550
+ if not ok:
551
+ return False, err
552
+ return True, None
553
+
554
+
555
+ def check_context_leakage(agent_dict: dict) -> tuple[bool, dict | None]:
556
+ """FR-CHECK-3: (a) 父子 define-tools 精确重名;(b) 兄弟 scoped-worker 定义同名工具。
557
+
558
+ 前缀非重名(write-md vs write-md-worker)必须 PASS(反误杀)。
559
+ 与 Racket checker.rkt L500-L540 对齐:字符串精确相等,不是子串/前缀匹配。
560
+ """
561
+
562
+ def _define_tool_names(tools_dict: Any) -> list[str]:
563
+ dts = _get(tools_dict, "define_tools")
564
+ if dts is None:
565
+ dts = _get(tools_dict, "define-tools", [])
566
+ if not isinstance(dts, list):
567
+ return []
568
+ names: list[str] = []
569
+ for t in dts:
570
+ if isinstance(t, dict):
571
+ n = _str(_get(t, "name", ""))
572
+ if n:
573
+ names.append(n)
574
+ elif isinstance(t, str):
575
+ if t:
576
+ names.append(t)
577
+ return names
578
+
579
+ name = _str(_get(agent_dict, "name", "anon"))
580
+ multi = _get(agent_dict, "multi")
581
+ multi_dict = multi if isinstance(multi, dict) else {}
582
+ workers = _list_of_dicts(_get(multi_dict, "workers"))
583
+
584
+ parent_names = _define_tool_names(_get(agent_dict, "tools", {}))
585
+ all_worker_names: list[tuple[str, str]] = [] # (worker_name, tool_name)
586
+ for w in workers:
587
+ w_name = _str(_get(w, "name", "anon-worker"))
588
+ for tn in _define_tool_names(_get(w, "tools", {})):
589
+ all_worker_names.append((w_name, tn))
590
+
591
+ for w_name, tn in all_worker_names:
592
+ if tn in parent_names:
593
+ return False, make_parse_error_json(
594
+ "ERR_CONTEXT_LEAKAGE",
595
+ "FR-CHECK-3",
596
+ (
597
+ f"FR-CHECK-3: scoped-worker={w_name} 的工具名 {tn!r} 与父级 define-tools 重名;"
598
+ "离开作用域后可能导致父级 trajectory 意外复用,违反词法隔离"
599
+ ),
600
+ agent_name=name,
601
+ hints=[
602
+ f"工具 {tn!r} 与父级 define-tools 重名(FR-CHECK-3 不变量),请重命名该 worker 工具",
603
+ "SRS §4.3:所有 scoped-worker define-tools 名必须父子/兄弟两两不重名",
604
+ ],
605
+ )
606
+
607
+ seen: dict[str, str] = {}
608
+ for w_name, tn in all_worker_names:
609
+ if tn in seen:
610
+ return False, make_parse_error_json(
611
+ "ERR_CONTEXT_LEAKAGE",
612
+ "FR-CHECK-3",
613
+ (
614
+ f"FR-CHECK-3: 兄弟 scoped-worker 工具名冲突:worker={seen[tn]!r} 与 "
615
+ f"worker={w_name!r} 均定义 {tn!r};可能导致 cross-worker trajectory 混淆"
616
+ ),
617
+ agent_name=w_name,
618
+ hints=[
619
+ f"重名工具 {tn!r}:请把两个 worker 的 define-tools 名改为不同(例如加前缀 w1- / w2-)",
620
+ "SRS FR-CHECK-3:兄弟 worker 的工具名必须两两互斥(编译期就断,避免运行时轨迹泄漏)",
621
+ ],
622
+ )
623
+ seen[tn] = w_name
624
+
625
+ return True, None
626
+
627
+
628
+ __all__ = [
629
+ "FR_PARSER_MCP_SCHEMES",
630
+ "FR_PARSER_ON_FAILURE_ENUM",
631
+ "FR_PARSER_PROVIDER_ENUM",
632
+ "FR_PARSER_SCOPED_WORKER_REQUIRED_BLOCKS",
633
+ "FR_PARSER_TOPOLOGY_ENUM",
634
+ "PARSER_ERROR_SCHEMA_VERSION",
635
+ "PARSE_ERR_ILLEGAL_SEXP",
636
+ "PARSE_ERR_MEMORY_AUTO_APPEND",
637
+ "PARSE_ERR_ON_FAILURE",
638
+ "PARSE_ERR_PROVIDER_OR_TEMP",
639
+ "PARSE_ERR_TOOLS_COMBINATION",
640
+ "PARSE_ERR_TOPOLOGY",
641
+ "SIDEEFFECT_BUILTIN_TOOLS",
642
+ "check_context_leakage",
643
+ "check_kv_alignment_order",
644
+ "check_sideeffect_builtins_racket_mirror",
645
+ "check_unguarded_tool_execution",
646
+ "extract_checker_rkt_sideeffect_list",
647
+ "make_parse_error_json",
648
+ "sideeffect_builtin_names",
649
+ "validate_correct_on_failure",
650
+ "validate_memory_auto_append_key",
651
+ "validate_provider_and_temperature",
652
+ "validate_tools_combination_and_mcp_scheme",
653
+ "validate_topology_and_scoped_worker_min_blocks",
654
+ ]