agent-harness / src /agent_harness /llm_localization.py
cuber12's picture
Publish agent harness research code and paper artifacts
d61821a verified
Raw
History Blame Contribute Delete
10.5 kB
"""Blinded, single-call LLM localization over a frozen retrieval ranking."""
from __future__ import annotations
from dataclasses import asdict
from hashlib import sha256
import json
from pathlib import Path
import time
from typing import Any, Sequence
from .lm_studio import LMStudioClient, LMStudioError
from .pilot import PilotError, research_code_revision, retrieval_metrics
from .repository import GitSnapshot
from .specs import load_experiments, load_harnesses, load_models, load_tasks
from .telemetry import EventWriter, RunIdentity
SYSTEM_PROMPT = """You are performing blinded bug localization in a large Go repository.
Use only the issue and candidate snippets supplied by the harness. Select the files that
would most likely need source-code changes. Do not propose a patch. Return one JSON object
with exactly these keys: {"files":["path/to/file.go"],"reasoning":"brief rationale"}.
The files array must contain 1-10 distinct paths copied exactly from the candidates. Keep
the rationale below 200 words and do not wrap the JSON in Markdown."""
class LocalizationError(RuntimeError):
"""Raised when a blinded localization run is invalid or cannot be completed."""
def load_ranking(path: Path, limit: int = 10) -> tuple[list[dict[str, Any]], str]:
raw = path.read_bytes()
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise LocalizationError(f"Invalid ranking JSON: {path}") from exc
if not isinstance(value, list) or not value:
raise LocalizationError("Ranking must be a non-empty JSON array")
candidates: list[dict[str, Any]] = []
seen: set[str] = set()
for record in value:
if not isinstance(record, dict):
raise LocalizationError("Every ranking entry must be an object")
try:
candidate = {
"rank": int(record["rank"]),
"path": str(record["path"]),
"line_start": int(record["line_start"]),
"line_end": int(record["line_end"]),
"score": float(record["score"]),
"source": str(record["source"]),
}
except (KeyError, TypeError, ValueError) as exc:
raise LocalizationError(f"Malformed ranking entry: {record!r}") from exc
path_value = candidate["path"]
if Path(path_value).is_absolute() or ".." in Path(path_value).parts:
raise LocalizationError(f"Unsafe candidate path: {path_value}")
if path_value not in seen:
candidates.append(candidate)
seen.add(path_value)
if len(candidates) >= limit:
break
if not candidates:
raise LocalizationError("Ranking has no usable candidate files")
return candidates, sha256(raw).hexdigest()
def build_prompt(
statement: str,
snapshot: GitSnapshot,
commit: str,
candidates: Sequence[dict[str, Any]],
) -> str:
blocks = [f"ISSUE:\n{statement}\n\nCANDIDATE SNIPPETS:"]
for candidate in candidates:
source = snapshot.read_file(commit, candidate["path"])
lines = source.text.splitlines()
start = max(candidate["line_start"], 1)
end = min(candidate["line_end"], len(lines))
numbered = "\n".join(
f"{line_number:>6}: {lines[line_number - 1]}"
for line_number in range(start, end + 1)
)
blocks.append(
f"\n--- Candidate {candidate['rank']}: {candidate['path']} "
f"(lines {start}-{end}) ---\n{numbered}"
)
return "\n".join(blocks)
def parse_selection(response: dict[str, Any], allowed_paths: set[str]) -> dict[str, Any]:
try:
message = response["choices"][0]["message"]
content = message["content"]
except (KeyError, IndexError, TypeError) as exc:
raise LocalizationError("Chat completion has no assistant content") from exc
if not isinstance(content, str):
raise LocalizationError("Assistant content is not text")
stripped = content.strip()
if stripped.startswith("```"):
stripped = stripped.removeprefix("```json").removeprefix("```")
stripped = stripped.removesuffix("```").strip()
try:
value = json.loads(stripped)
except json.JSONDecodeError:
start, end = stripped.find("{"), stripped.rfind("}")
if start < 0 or end <= start:
raise LocalizationError(f"Assistant did not return JSON: {content!r}")
try:
value = json.loads(stripped[start : end + 1])
except json.JSONDecodeError as exc:
raise LocalizationError(f"Assistant returned invalid JSON: {content!r}") from exc
if not isinstance(value, dict) or set(value) != {"files", "reasoning"}:
raise LocalizationError("Assistant JSON must contain exactly files and reasoning")
files = value["files"]
if (
not isinstance(files, list)
or not 1 <= len(files) <= 10
or not all(isinstance(item, str) for item in files)
or len(files) != len(set(files))
):
raise LocalizationError("Assistant files must be 1-10 distinct path strings")
unknown = set(files) - allowed_paths
if unknown:
raise LocalizationError(f"Assistant selected paths outside the candidates: {sorted(unknown)}")
if not isinstance(value["reasoning"], str):
raise LocalizationError("Assistant reasoning must be text")
return {"files": files, "reasoning": value["reasoning"]}
def _exclusive_agent_residency(discovery: Any, expected_key: str) -> tuple[str, ...]:
loaded = tuple(
str(record.get("key"))
for record in discovery.native_models
if record.get("loaded_instances")
)
if loaded != (expected_key,):
raise LocalizationError(
"LLM localization requires exclusive agent-model residency; "
f"expected {(expected_key,)}, observed {loaded}"
)
return loaded
def run_localization(
root: Path,
repository: Path,
ranking_path: Path,
task_id: str,
harness_id: str,
experiment_id: str = "E06",
candidate_limit: int = 10,
timeout_seconds: float = 900.0,
) -> dict[str, Any]:
revision = research_code_revision(root)
experiments = load_experiments(root)
harnesses = load_harnesses(root)
models = load_models(root)
tasks = load_tasks(root)
try:
experiment = experiments[experiment_id]
harness = harnesses[harness_id]
model = models[experiment.model_ids[0]]
task = tasks[task_id]
except KeyError as exc:
raise LocalizationError(f"Unknown experiment, harness, model, or task: {exc}") from exc
if harness_id not in experiment.harness_ids:
raise LocalizationError(f"{harness_id} is not assigned to {experiment_id}")
candidates, ranking_hash = load_ranking(ranking_path, candidate_limit)
snapshot = GitSnapshot(repository)
snapshot.verify_commit(task.base_commit)
prompt = build_prompt(task.statement, snapshot, task.base_commit, candidates)
prompt_hash = sha256(prompt.encode("utf-8")).hexdigest()
client = LMStudioClient(model, timeout_seconds=timeout_seconds)
discovery, resolved = client.resolve()
loaded_models = _exclusive_agent_residency(discovery, resolved.inference_key)
identity = RunIdentity(
experiment_id=experiment.experiment_id,
task_id=task.task_id,
harness_id=harness.harness_id,
harness_hash=harness.config_hash,
model_id=model.model_id,
model_key=resolved.inference_key,
model_config_hash=model.config_hash,
context_budget=experiment.context_budgets[0],
seed=model.seed,
repetition=1,
repository_sha=task.base_commit,
code_revision=revision,
)
resolved_model = resolved.to_dict()
resolved_model.update(
{
"exclusive_loaded_models": loaded_models,
"ranking_sha256": ranking_hash,
"prompt_sha256": prompt_hash,
"candidate_limit": len(candidates),
}
)
with EventWriter(
root / "results",
identity,
asdict(harness),
resolved_model,
) as writer:
writer.emit(
"run_started",
{
"development_only": True,
"blinded_prompt": True,
"ranking_path": str(ranking_path.resolve()),
"prompt_chars": len(prompt),
"candidate_count": len(candidates),
},
)
started = time.monotonic()
try:
response = client.chat_completions(
resolved.inference_key,
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
max_tokens=model.max_tokens,
)
elapsed = time.monotonic() - started
writer.emit(
"model_call",
{
"elapsed_seconds": elapsed,
"usage": response.get("usage", {}),
"finish_reason": response.get("choices", [{}])[0].get("finish_reason"),
},
)
selection = parse_selection(response, {item["path"] for item in candidates})
metrics = retrieval_metrics(selection["files"], task.gold_files)
final = {
"run_id": identity.run_id,
"experiment_id": experiment.experiment_id,
"task_id": task.task_id,
"harness_id": harness.harness_id,
"selected_files": selection["files"],
"reasoning": selection["reasoning"],
"metrics": metrics,
"usage": response.get("usage", {}),
"elapsed_seconds": elapsed,
"prompt_chars": len(prompt),
"prompt_sha256": prompt_hash,
"ranking_sha256": ranking_hash,
}
writer.write_artifact("model_response.json", json.dumps(response, indent=2) + "\n")
writer.write_artifact("selection.json", json.dumps(selection, indent=2) + "\n")
writer.write_artifact("final_metrics.json", json.dumps(final, indent=2) + "\n")
writer.emit("run_finished", final)
return {**final, "run_directory": str(writer.directory)}
except (LMStudioError, LocalizationError) as exc:
writer.emit("run_finished", {"status": "failed", "error": str(exc)})
raise