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
464 lines
17 KiB
Python
464 lines
17 KiB
Python
#!/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())
|