ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the exact five-arm context ablation from book/chapter1.md.
|
||||
|
||||
Unlike the legacy demo table, this runner persists every credential-free API
|
||||
request and response. That makes it possible to prove which context component
|
||||
was removed on every inference instead of inferring the ablation from a CLI
|
||||
flag after the fact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List
|
||||
|
||||
from agent import ContextAwareAgent, ContextMode
|
||||
|
||||
|
||||
EXPERIMENT_ID = "1-1"
|
||||
MODES = list(ContextMode)
|
||||
CANONICAL_TASK = """According to the company's quarterly revenue:
|
||||
- Q1: 2.5 million USD
|
||||
- Q2: 2.1 million EUR
|
||||
- Q3: 1.8 million GBP
|
||||
- Q4: 380 million JPY
|
||||
|
||||
Use the available currency-conversion and calculation tools to convert every
|
||||
non-USD quarter to USD, then calculate the annual total and quarterly average.
|
||||
Report both values rounded to two decimal places. Do not estimate exchange
|
||||
rates yourself; use the tool observations."""
|
||||
|
||||
EXPECTED_NUMBERS = ("9602895.73", "2400723.93")
|
||||
KEY_ENV = {
|
||||
"dashscope": ("DASHSCOPE_API_KEY",),
|
||||
"qwen": ("DASHSCOPE_API_KEY",),
|
||||
"bailian": ("DASHSCOPE_API_KEY",),
|
||||
"kimi": ("MOONSHOT_API_KEY", "KIMI_API_KEY"),
|
||||
"moonshot": ("MOONSHOT_API_KEY", "KIMI_API_KEY"),
|
||||
"doubao": ("ARK_API_KEY",),
|
||||
"siliconflow": ("SILICONFLOW_API_KEY",),
|
||||
"deepseek": ("DEEPSEEK_API_KEY",),
|
||||
"openrouter": ("OPENROUTER_API_KEY",),
|
||||
}
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def git_value(*args: str) -> str | None:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", *args], text=True, stderr=subprocess.DEVNULL
|
||||
).strip()
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return None
|
||||
|
||||
|
||||
def package_version(distribution: str) -> str | None:
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version(distribution)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_key(provider: str) -> tuple[str, str]:
|
||||
names = KEY_ENV.get(provider, ())
|
||||
for name in names:
|
||||
value = os.getenv(name)
|
||||
if value:
|
||||
return value, name
|
||||
raise RuntimeError(
|
||||
f"No direct credential for {provider}; expected one of {', '.join(names)}"
|
||||
)
|
||||
|
||||
|
||||
def tool_call_dict(call: Any) -> Dict[str, Any]:
|
||||
return {
|
||||
"tool_name": call.tool_name,
|
||||
"arguments": call.arguments,
|
||||
"result": call.result,
|
||||
"timestamp": call.timestamp,
|
||||
}
|
||||
|
||||
|
||||
def call_signatures(tool_calls: Iterable[Dict[str, Any]]) -> List[str]:
|
||||
signatures = []
|
||||
for call in tool_calls:
|
||||
signatures.append(
|
||||
f"{call['tool_name']}:"
|
||||
+ json.dumps(call.get("arguments", {}), sort_keys=True, ensure_ascii=False)
|
||||
)
|
||||
return signatures
|
||||
|
||||
|
||||
def response_message(turn: Dict[str, Any]) -> Dict[str, Any]:
|
||||
choices = turn.get("response", {}).get("choices") or []
|
||||
return (choices[0].get("message") or {}) if choices else {}
|
||||
|
||||
|
||||
def request_roles(turn: Dict[str, Any]) -> List[str]:
|
||||
return [message.get("role") for message in turn.get("request", {}).get("messages", [])]
|
||||
|
||||
|
||||
def evaluate_context_contract(mode: str, turns: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Verify the actual provider request, not the requested CLI mode."""
|
||||
requests = [turn.get("request", {}) for turn in turns if turn.get("request")]
|
||||
real_responses = [turn for turn in turns if turn.get("response", {}).get("id")]
|
||||
details: Dict[str, Any] = {
|
||||
"has_provider_response_ids": len(real_responses) == len(turns) and bool(turns),
|
||||
"turn_count": len(turns),
|
||||
"request_roles": [request_roles(turn) for turn in turns],
|
||||
}
|
||||
|
||||
if mode == ContextMode.FULL.value:
|
||||
details.update(
|
||||
{
|
||||
"tools_present_every_turn": all(bool(r.get("tools")) for r in requests),
|
||||
"history_present_after_first_turn": len(requests) > 1
|
||||
and all(
|
||||
"assistant" in [m.get("role") for m in r.get("messages", [])]
|
||||
and "tool" in [m.get("role") for m in r.get("messages", [])]
|
||||
for r in requests[1:]
|
||||
),
|
||||
"reasoning_retained_after_first_turn": len(requests) > 1
|
||||
and any(
|
||||
bool(m.get("reasoning_content"))
|
||||
for m in requests[1].get("messages", [])
|
||||
if m.get("role") == "assistant"
|
||||
),
|
||||
}
|
||||
)
|
||||
required = (
|
||||
"has_provider_response_ids",
|
||||
"tools_present_every_turn",
|
||||
"history_present_after_first_turn",
|
||||
"reasoning_retained_after_first_turn",
|
||||
)
|
||||
elif mode == ContextMode.NO_TOOL_CALLS.value:
|
||||
details.update(
|
||||
{
|
||||
"tools_absent_every_turn": all(
|
||||
"tools" not in r and "tool_choice" not in r for r in requests
|
||||
),
|
||||
}
|
||||
)
|
||||
required = ("has_provider_response_ids", "tools_absent_every_turn")
|
||||
elif mode == ContextMode.NO_TOOL_RESULTS.value:
|
||||
tool_messages = [
|
||||
m
|
||||
for r in requests[1:]
|
||||
for m in r.get("messages", [])
|
||||
if m.get("role") == "tool"
|
||||
]
|
||||
details.update(
|
||||
{
|
||||
"tool_calls_retained": any(
|
||||
m.get("role") == "assistant" and m.get("tool_calls")
|
||||
for r in requests[1:]
|
||||
for m in r.get("messages", [])
|
||||
),
|
||||
"tool_results_hidden": bool(tool_messages)
|
||||
and all(
|
||||
m.get("content") == "[Tool result hidden due to context mode]"
|
||||
for m in tool_messages
|
||||
),
|
||||
}
|
||||
)
|
||||
required = (
|
||||
"has_provider_response_ids",
|
||||
"tool_calls_retained",
|
||||
"tool_results_hidden",
|
||||
)
|
||||
elif mode == ContextMode.NO_REASONING.value:
|
||||
assistant_history = [
|
||||
m
|
||||
for r in requests[1:]
|
||||
for m in r.get("messages", [])
|
||||
if m.get("role") == "assistant"
|
||||
]
|
||||
provider_reasoning = [
|
||||
response_message(turn).get("reasoning_content") for turn in turns
|
||||
]
|
||||
details.update(
|
||||
{
|
||||
"provider_generated_reasoning": any(provider_reasoning),
|
||||
"reasoning_removed_from_history": bool(assistant_history)
|
||||
and all(not m.get("reasoning_content") for m in assistant_history),
|
||||
"tool_and_result_history_retained": any(
|
||||
"tool" in request_roles(turn) for turn in turns[1:]
|
||||
),
|
||||
}
|
||||
)
|
||||
required = (
|
||||
"has_provider_response_ids",
|
||||
"provider_generated_reasoning",
|
||||
"reasoning_removed_from_history",
|
||||
"tool_and_result_history_retained",
|
||||
)
|
||||
elif mode == ContextMode.NO_HISTORY.value:
|
||||
details.update(
|
||||
{
|
||||
"only_static_prefix_and_user_every_turn": bool(requests)
|
||||
and all(
|
||||
[m.get("role") for m in r.get("messages", [])]
|
||||
== ["system", "user"]
|
||||
for r in requests
|
||||
),
|
||||
"tools_still_present": all(bool(r.get("tools")) for r in requests),
|
||||
}
|
||||
)
|
||||
required = (
|
||||
"has_provider_response_ids",
|
||||
"only_static_prefix_and_user_every_turn",
|
||||
"tools_still_present",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown mode: {mode}")
|
||||
|
||||
details["required_checks"] = list(required)
|
||||
details["passed"] = all(details[name] is True for name in required)
|
||||
return details
|
||||
|
||||
|
||||
def normalized_number_text(value: str | None) -> str:
|
||||
return (value or "").replace(",", "").replace("$", "").replace(" ", "")
|
||||
|
||||
|
||||
def canonical_answer_correct(final_answer: str | None) -> bool:
|
||||
"""Evaluate the known numeric rubric for the canonical Experiment 1-1 task.
|
||||
|
||||
This is deliberately kept outside ``ContextAwareAgent``. A generic agent
|
||||
cannot infer correctness from an arbitrary natural-language task, while
|
||||
this experiment has an explicit answer rubric.
|
||||
"""
|
||||
normalized = normalized_number_text(final_answer)
|
||||
return bool(final_answer) and all(number in normalized for number in EXPECTED_NUMBERS)
|
||||
|
||||
|
||||
def summarize_arm(mode: ContextMode, result: Dict[str, Any], elapsed: float) -> Dict[str, Any]:
|
||||
trajectory = result["trajectory"]
|
||||
tool_calls = [tool_call_dict(call) for call in trajectory.tool_calls]
|
||||
signatures = call_signatures(tool_calls)
|
||||
repeats = len(signatures) - len(set(signatures))
|
||||
final_answer = result.get("final_answer")
|
||||
completed = bool(result.get("completed", result.get("success", False)))
|
||||
task_success = canonical_answer_correct(final_answer)
|
||||
arm = {
|
||||
"mode": mode.value,
|
||||
"provider": result.get("provider"),
|
||||
"model": result.get("model"),
|
||||
"base_url": result.get("base_url"),
|
||||
"using_openrouter": result.get("using_openrouter", False),
|
||||
"started_at": None,
|
||||
"elapsed_seconds": round(elapsed, 6),
|
||||
# ``success`` is retained for compatibility with existing evidence;
|
||||
# it means terminal response/completion, not task correctness.
|
||||
"success": completed,
|
||||
"completed": completed,
|
||||
"task_success": task_success,
|
||||
"iterations": result.get("iterations", 0),
|
||||
"error": result.get("error"),
|
||||
"final_answer": final_answer,
|
||||
"tool_calls": tool_calls,
|
||||
"tool_call_signatures": signatures,
|
||||
"repeated_tool_calls": repeats,
|
||||
"reasoning_steps": trajectory.reasoning_steps,
|
||||
"api_turns": trajectory.api_turns,
|
||||
}
|
||||
arm["context_contract"] = evaluate_context_contract(mode.value, trajectory.api_turns)
|
||||
arm["behavior"] = {
|
||||
"tool_action_count": len(tool_calls),
|
||||
"has_repeated_tool_action": repeats > 0,
|
||||
"hit_iteration_ceiling": result.get("iterations") >= 5 and not completed,
|
||||
"canonical_answer_correct": task_success,
|
||||
}
|
||||
return arm
|
||||
|
||||
|
||||
def token_usage(arms: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
prompt = completion = cached = reasoning = 0
|
||||
for arm in arms:
|
||||
for turn in arm["api_turns"]:
|
||||
usage = turn.get("response", {}).get("usage") or {}
|
||||
prompt += int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
|
||||
completion += int(
|
||||
usage.get("completion_tokens") or usage.get("output_tokens") or 0
|
||||
)
|
||||
prompt_details = usage.get("prompt_tokens_details") or usage.get(
|
||||
"input_tokens_details"
|
||||
) or {}
|
||||
completion_details = usage.get("completion_tokens_details") or usage.get(
|
||||
"output_tokens_details"
|
||||
) or {}
|
||||
cached += int(prompt_details.get("cached_tokens") or 0)
|
||||
reasoning += int(completion_details.get("reasoning_tokens") or 0)
|
||||
return {
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": prompt + completion,
|
||||
"cached_prompt_tokens": cached,
|
||||
"reasoning_tokens": reasoning,
|
||||
}
|
||||
|
||||
|
||||
def analyze(arms: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
by_mode = {arm["mode"]: arm for arm in arms}
|
||||
exact_five_arms = set(by_mode) == {mode.value for mode in MODES}
|
||||
contracts_pass = exact_five_arms and all(
|
||||
arm["context_contract"]["passed"] for arm in arms
|
||||
)
|
||||
direct_real_api = all(
|
||||
not arm["using_openrouter"]
|
||||
and arm["api_turns"]
|
||||
and all(turn.get("response", {}).get("id") for turn in arm["api_turns"])
|
||||
for arm in arms
|
||||
)
|
||||
behavior = {
|
||||
"full_baseline_correct": by_mode.get("full", {}).get("behavior", {}).get(
|
||||
"canonical_answer_correct", by_mode.get("full", {}).get("task_success", False)
|
||||
),
|
||||
"without_tool_definitions_no_tool_action": by_mode.get(
|
||||
"no_tool_calls", {}
|
||||
).get("behavior", {}).get("tool_action_count")
|
||||
== 0,
|
||||
"without_tool_results_repeated_action": by_mode.get(
|
||||
"no_tool_results", {}
|
||||
).get("behavior", {}).get("has_repeated_tool_action", False),
|
||||
"without_history_repeated_action": by_mode.get("no_history", {}).get(
|
||||
"behavior", {}
|
||||
).get("has_repeated_tool_action", False),
|
||||
# Contradiction is an empirical outcome, not something the harness can
|
||||
# legitimately force. We report whether the no-reasoning answer lost
|
||||
# canonical correctness and keep this separate from execution validity.
|
||||
"without_reasoning_degraded": not by_mode.get("no_reasoning", {}).get(
|
||||
"behavior", {}
|
||||
).get("canonical_answer_correct", False),
|
||||
}
|
||||
behavior["all_manuscript_behavior_claims_observed"] = all(behavior.values())
|
||||
return {
|
||||
"exact_five_arms_present": exact_five_arms,
|
||||
"all_context_contracts_passed": contracts_pass,
|
||||
"direct_real_api_evidence": direct_real_api,
|
||||
"experiment_execution_accepted": bool(
|
||||
exact_five_arms
|
||||
and contracts_pass
|
||||
and direct_real_api
|
||||
and behavior["full_baseline_correct"]
|
||||
),
|
||||
"manuscript_behavior_claims": behavior,
|
||||
"usage": token_usage(arms),
|
||||
}
|
||||
|
||||
|
||||
def write_json(path: Path, payload: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--provider", default="kimi", choices=sorted(KEY_ENV))
|
||||
parser.add_argument("--model", default="kimi-k3")
|
||||
parser.add_argument("--max-iterations", type=int, default=5)
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.max_iterations < 2:
|
||||
parser.error("--max-iterations must be at least 2")
|
||||
|
||||
key, key_env = resolve_key(args.provider)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
output_dir = args.output_dir or Path("validation") / f"real_{stamp}"
|
||||
command = [
|
||||
sys.executable,
|
||||
Path(__file__).name,
|
||||
"--provider",
|
||||
args.provider,
|
||||
"--model",
|
||||
args.model,
|
||||
"--max-iterations",
|
||||
str(args.max_iterations),
|
||||
"--output-dir",
|
||||
str(output_dir),
|
||||
]
|
||||
|
||||
arms = []
|
||||
for mode in MODES:
|
||||
started = utc_now()
|
||||
agent = ContextAwareAgent(
|
||||
key,
|
||||
context_mode=mode,
|
||||
provider=args.provider,
|
||||
model=args.model,
|
||||
verbose=False,
|
||||
)
|
||||
begin = time.monotonic()
|
||||
result = agent.execute_task(CANONICAL_TASK, max_iterations=args.max_iterations)
|
||||
arm = summarize_arm(mode, result, time.monotonic() - begin)
|
||||
arm["started_at"] = started
|
||||
# Recompute the configured ceiling rather than retaining the default in
|
||||
# the pure summarizer (which is also exercised by unit tests).
|
||||
arm["behavior"]["hit_iteration_ceiling"] = (
|
||||
result.get("iterations") >= args.max_iterations and not result.get("success")
|
||||
)
|
||||
arms.append(arm)
|
||||
|
||||
evidence: Dict[str, Any] = {
|
||||
"schema_version": "1.0",
|
||||
"experiment_id": EXPERIMENT_ID,
|
||||
"evidence_mode": "real_api",
|
||||
"created_at": utc_now(),
|
||||
"canonical_source": "book/chapter1.md#实验-1-1-上下文的关键作用",
|
||||
"task": CANONICAL_TASK,
|
||||
"expected_numbers": list(EXPECTED_NUMBERS),
|
||||
"command": command,
|
||||
"credential_source_env": key_env,
|
||||
"credential_value_recorded": False,
|
||||
"host": {
|
||||
"platform": platform.platform(),
|
||||
"python": sys.version,
|
||||
"machine": platform.machine(),
|
||||
},
|
||||
"dependencies": {
|
||||
"openai": package_version("openai"),
|
||||
"requests": package_version("requests"),
|
||||
},
|
||||
"repository": {
|
||||
"commit": git_value("rev-parse", "HEAD"),
|
||||
"branch": git_value("branch", "--show-current"),
|
||||
"worktree_dirty": bool(git_value("status", "--porcelain")),
|
||||
},
|
||||
"arms": arms,
|
||||
}
|
||||
evidence["analysis"] = analyze(arms)
|
||||
evidence_path = output_dir / "evidence.json"
|
||||
write_json(evidence_path, evidence)
|
||||
digest = hashlib.sha256(evidence_path.read_bytes()).hexdigest()
|
||||
(output_dir / "evidence.sha256").write_text(
|
||||
f"{digest} evidence.json\n", encoding="utf-8"
|
||||
)
|
||||
latest = Path("validation/latest.json")
|
||||
latest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(evidence_path, latest)
|
||||
|
||||
print(json.dumps(evidence["analysis"], ensure_ascii=False, indent=2))
|
||||
print(f"Evidence: {evidence_path}")
|
||||
print(f"SHA-256: {digest}")
|
||||
return 0 if evidence["analysis"]["experiment_execution_accepted"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user