Files
Genarrative/rust/scripts/run-agent-llm-eval.sh
kdletters 202279c6d9 新增独立 Agent Runtime Rust 工作区
新增 Core、Engine、Runtime、SQLite、Provider、MCP、Skill、Codex、CLI 与 DAG crate

补齐 OpenAI endpoint 配置、Provider 实例/协议路由和统一工具权限边界

加入持久化、lease、checkpoint、reconciliation、审批恢复与消息历史回归

加入独立 workspace CI、依赖边界、能力集和 Fake Agent 测试脚本

同步建设计划、TODO、架构、测试与验收文档
2026-09-06 17:44:54 +08:00

216 lines
10 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
# 最小真实 Provider eval。默认只列出用例;显式 --real 才会产生网络请求。
# 临时状态默认写入 ~/data/tmp(优先尊重显式 TMPDIR;可用 AGENT_EVAL_TMPDIR 覆盖),
# 退出时清理本轮目录,不把构建或测试产物写到用户 home 的其它位置。
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
workspace_root="$(cd -- "$script_dir/.." && pwd)"
dataset="$workspace_root/tests/agent-llm-eval.jsonl"
tmp_parent="${AGENT_EVAL_TMPDIR:-${TMPDIR:-${HOME:?HOME 未设置}/data/tmp}}"
run_real=0
list_only=0
from_codex_config=0
usage() {
cat <<'EOF'
用法:
./scripts/run-agent-llm-eval.sh --real # 调用 OpenAI-compatible Provider
./scripts/run-agent-llm-eval.sh --real --from-codex-config # 使用当前 Codex 配置
./scripts/run-agent-llm-eval.sh --dataset tests/agent-llm-capability-eval.jsonl --real --from-codex-config
./scripts/run-agent-llm-eval.sh --list # 列出用例,不联网
真实运行需要 OPENAI_API_KEY(或 OPENAI_API_KEY_ENV/AGENT_OPENAI_API_KEY_ENV
指向的变量)。脚本不会读取或打印密钥值;可用 AGENT_MODEL/OPENAI_MODEL 选模型。
`--from-codex-config` 从 `$CODEX_HOME/config.toml`(默认 `~/.codex/config.toml`
和同目录 `auth.json` 读取当前模型、Responses 网关和密钥,仅注入本轮子进程。
EOF
}
while (($# > 0)); do
case "$1" in
--dataset)
[[ $# -ge 2 ]] || { echo "--dataset 需要路径" >&2; exit 2; }
dataset="$2"
[[ "$dataset" = /* ]] || dataset="$workspace_root/$dataset"
shift
;;
--real) run_real=1 ;;
--from-codex-config) from_codex_config=1 ;;
--list) list_only=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数:$1" >&2; usage >&2; exit 2 ;;
esac
shift
done
if [[ "$list_only" == 1 ]]; then
python3 - "$dataset" <<'PY'
import json, sys
for line in open(sys.argv[1], encoding="utf-8"):
if line.strip():
case = json.loads(line)
print(f"{case['id']}: {case['input']['stream'] and 'stream' or 'complete'}")
PY
exit 0
fi
if [[ "$run_real" != 1 ]]; then
echo "未执行:真实 eval 需要显式 --real(使用 --list 查看用例)" >&2
exit 2
fi
if [[ "$from_codex_config" == 1 ]]; then
codex_home="${CODEX_HOME:-${HOME:?HOME 未设置}/.codex}"
codex_config="$codex_home/config.toml"
codex_auth="$codex_home/auth.json"
if [[ ! -r "$codex_config" || ! -r "$codex_auth" ]]; then
echo "当前 Codex 配置不完整:需要 config.toml 和 auth.json(不会显示内容)" >&2
exit 2
fi
# 只在进程内搬运配置;stdout 为 base64 字段,不会直接回显凭据。
config_values="$(python3 - "$codex_config" "$codex_auth" <<'PY'
import base64, json, pathlib, sys, tomllib
config = tomllib.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
auth = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8"))
provider_name = config.get("model_provider")
provider = (config.get("model_providers") or {}).get(provider_name or "", {})
model = str(config.get("model") or "")
url = str(provider.get("base_url") or "")
key = str(auth.get("OPENAI_API_KEY") or "")
if not model or not url or not key:
raise SystemExit("Codex 配置缺少 model、provider base_url 或 OPENAI_API_KEY")
enc = lambda value: base64.b64encode(value.encode()).decode()
print("\t".join((model, enc(url), enc(key))))
PY
)"
IFS=$'\t' read -r codex_model codex_base64_url codex_base64_key <<<"$config_values"
export AGENT_MODEL="$codex_model"
export OPENAI_BASE_URL="$(printf '%s' "$codex_base64_url" | base64 -d)"
export OPENAI_API_KEY="$(printf '%s' "$codex_base64_key" | base64 -d)"
fi
key_name="${AGENT_OPENAI_API_KEY_ENV:-${OPENAI_API_KEY_ENV:-OPENAI_API_KEY}}"
if [[ -z "${!key_name:-}" ]]; then
echo "缺少真实 Provider 凭据环境变量:$key_name(不会显示其值)" >&2
exit 2
fi
mkdir -p -- "$tmp_parent"
tmp_parent="$(cd -- "$tmp_parent" && pwd)"
suite_dir="$(mktemp -d "$tmp_parent/agent-llm-eval.XXXXXX")"
cleanup() { rm -rf -- "$suite_dir"; }
trap cleanup EXIT
export TMPDIR="$suite_dir/tmp"
export CARGO_TARGET_DIR="$suite_dir/target"
mkdir -p -- "$TMPDIR"
python3 - "$dataset" "$workspace_root" "$suite_dir" <<'PY'
import json, os, pathlib, re, subprocess, sys
dataset, workspace, suite_dir = map(pathlib.Path, sys.argv[1:])
def scrub_error(raw: bytes) -> str:
text = raw.decode("utf-8", errors="replace")
names = {"OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY_ENV", ""), os.environ.get("AGENT_OPENAI_API_KEY_ENV", "")}
for name in names:
if name:
value = os.environ.get(name, "")
if value:
text = text.replace(value, "[redacted]")
text = re.sub(r"(?i)(authorization|api[-_ ]?key|token|password)\s*[:=]\s*[^\s,;]+", r"\1=[redacted]", text)
return next((line.strip() for line in reversed(text.splitlines()) if line.strip()), "无错误详情")
def run_case(case, case_dir):
env = os.environ.copy()
for name in ("AGENT_SKILL_ROOT", "AGENT_SKILL_ROOTS", "AGENT_SKILLS", "AGENT_MCP_STDIO_COMMAND", "AGENT_MCP_STDIO_ARGS", "AGENT_MCP_HTTP_URL", "AGENT_MCP_HTTP_HEADERS", "AGENT_MCP_ALLOW", "AGENT_SYSTEM_PROMPT", "AGENT_DEVELOPER_PROMPT", "AGENT_CONTEXT_PROMPT"):
env.pop(name, None)
env.update({"AGENT_PROVIDER": "openai", "AGENT_CONFIG": str(case_dir / "missing.toml"), "AGENT_DB": str(case_dir / "agent.db"), "AGENT_STREAM": "1" if case["input"].get("stream") else "0"})
if case["input"].get("model"):
env["AGENT_MODEL"] = case["input"]["model"]
for field, env_name in (("system_prompt", "AGENT_SYSTEM_PROMPT"), ("developer_prompt", "AGENT_DEVELOPER_PROMPT"), ("context_prompt", "AGENT_CONTEXT_PROMPT")):
value = case["input"].get(field)
if value:
env[env_name] = value
args = ["cargo", "run", "--locked", "-q", "-p", "agent-cli", "--", "run", "--jsonl", "--stream" if case["input"].get("stream") else "--no-stream", case["input"]["prompt"]]
proc = subprocess.run(args, cwd=workspace, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180, check=False)
if proc.returncode:
raise RuntimeError(f"退出码 {proc.returncode}{scrub_error(proc.stderr)}")
result = None
for line in proc.stdout.decode("utf-8", errors="replace").splitlines():
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if record.get("type") == "result":
result = record.get("result")
if result is None:
raise RuntimeError("Provider 输出缺少 JSONL result 记录")
return result
def check(case, result):
out = result.get("output") or {}
events = out.get("events") or []
types = [e.get("type") for e in events]
assertion = case.get("assert", {})
if assertion.get("status"):
# completed 是 Engine 的 Finished 语义;CLI 结果中不重复返回状态字段。
if "finished" not in types:
raise AssertionError("缺少 finished 事件")
text = str(out.get("text") or "")
expected = assertion.get("final_text", {})
if expected.get("mode") == "contains_all" and any(v not in text for v in expected.get("values", [])):
raise AssertionError("最终文本未满足 contains_all")
steps = int(out.get("steps") or 0)
bounds = assertion.get("steps", {})
if steps < bounds.get("min", 0) or (bounds.get("max") is not None and steps > bounds["max"]):
raise AssertionError(f"steps={steps} 超出范围")
if len(out.get("stream_events") or []) < assertion.get("stream_events", {}).get("min", 0):
raise AssertionError("stream_events 不足")
tool_assert = assertion.get("tool_calls", {})
completed_tools = [
event.get("call") or {}
for event in events
if event.get("type") == "tool_requested"
]
min_tools = int(tool_assert.get("min_completed", 0))
max_tools = tool_assert.get("max_completed")
if len(completed_tools) < min_tools or (max_tools is not None and len(completed_tools) > int(max_tools)):
raise AssertionError(f"tool_requested 数量={len(completed_tools)} 超出范围")
for expected_tool in tool_assert.get("required", []):
matches = [call for call in completed_tools if call.get("name") == expected_tool.get("name")]
if not matches:
raise AssertionError(f"未调用工具 {expected_tool.get('name')}")
actual_args = matches[0].get("arguments") or {}
expected_args = expected_tool.get("arguments") or {}
if actual_args != expected_args:
raise AssertionError(f"工具 {expected_tool.get('name')} 参数不匹配")
if assertion.get("secrets_absent"):
for name in ("OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY_ENV", ""), os.environ.get("AGENT_OPENAI_API_KEY_ENV", "")):
value = os.environ.get(name, "")
if value and value in text:
raise AssertionError("最终文本疑似包含 Provider 凭据")
weights = case.get("score", {}).get("weights", {})
observed = {"status": 1.0, "final_text": 1.0, "streaming": 1.0, "efficiency": 1.0, "tool_calls": 1.0}
total = sum(float(value) for value in weights.values())
score = (sum(float(weights.get(name, 0)) * observed.get(name, 0.0) for name in weights) / total) if total else 1.0
threshold = float(case.get("score", {}).get("pass_threshold", 1.0))
if score < threshold:
raise AssertionError(f"score={score:.2f} 低于阈值 {threshold:.2f}")
return score
cases = [json.loads(line) for line in dataset.read_text(encoding="utf-8").splitlines() if line.strip()]
passed = 0
for case in cases:
case_dir = suite_dir / case["id"]
case_dir.mkdir(parents=True)
try:
score = check(case, run_case(case, case_dir))
passed += 1
print(f"PASS {case['id']} score={score:.2f}")
except Exception as exc:
print(f"FAIL {case['id']}: {exc}")
print(f"SUMMARY total={len(cases)} passed={passed} failed={len(cases)-passed}")
raise SystemExit(0 if passed == len(cases) else 1)
PY