File size: 14,640 Bytes
3738348 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 | """
Inference-time search agent harness.
This is the runtime that executes the search loop at inference time.
It:
1. Takes a query from the calling (bigger) model
2. Formats it as a chat message for the agent model
3. Generates the agent's response (which contains <|search|> actions)
4. Parses the search query from the response
5. Retrieves code chunks from the index (simple keyword/TF-IDF search)
6. Feeds the results back to the agent as <|result|> messages
7. The agent generates more reasoning or <|evidence|>/<|finish|>
8. Returns the evidence package to the caller
The retrieval backend is a simple in-memory keyword search over the
chunked corpus. This can be replaced with any retrieval backend
(embedding search, BM25, etc.) β the agent interface is the same.
Usage:
python src/search_agent.py --query "How does nginx handle connections?"
python src/search_agent.py --interactive
"""
import argparse
import json
import os
import re
import sys
from collections import Counter
import torch
import torch.nn.functional as F
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from model import ModelConfig, Retriever500M
from tokenizers import Tokenizer
# βββ Paths βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CHECKPOINT_DIR = os.path.join(PROJECT_DIR, "checkpoints")
TOKENIZER_PATH = os.path.join(PROJECT_DIR, "tokenizer", "tokenizer_agent.json")
CHUNKS_PATH = os.path.join(PROJECT_DIR, "data", "chunks.jsonl")
# βββ Special tokens ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
SYSTEM_PROMPT = (
"You are a code search agent. Given a query from a reasoning model, "
"decompose it into subqueries, search the codebase, inspect results, "
"and return curated evidence. Use <|search|> to issue searches, "
"<|reasoning|> to analyze, and <|evidence|> to return findings. "
"Be concise. Extract only the relevant facts. End with <|finish|>."
)
# βββ Simple retrieval backend ββββββββββββββββββββββββββββββββββββββββββββββββ
class KeywordRetriever:
"""Simple keyword-based retrieval over code chunks.
For production, replace this with an embedding-based retriever
(e.g., mxbai-embed-large or similar). The agent interface stays the same.
"""
def __init__(self, chunks_path: str):
print(f"Loading chunks from {chunks_path}...")
self.chunks = []
with open(chunks_path, "r", encoding="utf-8") as f:
for line in f:
self.chunks.append(json.loads(line))
print(f" Loaded {len(self.chunks):,} chunks")
# Build simple term frequency index
self.chunk_tokens = []
for chunk in self.chunks:
code = chunk["code"].lower()
# Simple tokenization: split on non-alphanumeric
tokens = re.findall(r"[a-z_][a-z0-9_]*", code)
self.chunk_tokens.append(Counter(tokens))
def search(self, query: str, top_k: int = 3) -> list[dict]:
"""Search for chunks matching the query. Returns top_k results."""
query_tokens = re.findall(r"[a-z_][a-z0-9_]*", query.lower())
if not query_tokens:
return []
scores = []
for i, chunk_tf in enumerate(self.chunk_tokens):
score = sum(chunk_tf.get(t, 0) for t in query_tokens)
# Normalize by chunk length to avoid bias toward long chunks
if sum(chunk_tf.values()) > 0:
score = score / (1 + sum(chunk_tf.values()) * 0.001)
scores.append((score, i))
scores.sort(reverse=True)
results = []
for score, idx in scores[:top_k]:
if score > 0:
chunk = self.chunks[idx]
results.append({
"code": chunk["code"],
"filepath": chunk["filepath"],
"name": chunk["name"],
"type": chunk["type"],
"language": chunk["language"],
"score": score,
})
return results
# βββ Agent harness βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class SearchAgent:
"""The search agent harness that runs the search loop."""
def __init__(
self,
checkpoint_path: str,
tokenizer_path: str,
chunks_path: str,
device: torch.device = None,
max_search_rounds: int = 5,
max_new_tokens: int = 256,
):
self.device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.max_search_rounds = max_search_rounds
self.max_new_tokens = max_new_tokens
# Load tokenizer
self.tokenizer = Tokenizer.from_file(tokenizer_path)
# Load model
print(f"Loading model from {checkpoint_path}...")
ckpt = torch.load(checkpoint_path, map_location=self.device, weights_only=False)
config = ModelConfig(**ckpt["config"])
self.model = Retriever500M(config).to(self.device)
self.model.load_state_dict(ckpt["model_state_dict"])
self.model.eval()
print(f" Loaded (step {ckpt.get('step', '?')}, loss {ckpt.get('loss', '?')})")
# Load retriever
self.retriever = KeywordRetriever(chunks_path)
# Special token IDs
vocab = self.tokenizer.get_vocab()
self.system_id = vocab.get("<tool_call>", 32000)
self.user_id = vocab.get("<tool_call>", 32001)
self.assistant_id = vocab.get("<tool_call>", 32002)
self.search_id = vocab.get("<|search|>", 32003)
self.result_id = vocab.get("<|result|>", 32004)
self.evidence_id = vocab.get("<|evidence|>", 32005)
self.reasoning_id = vocab.get("<|reasoning|>", 32006)
self.finish_id = vocab.get("<|finish|>", 32007)
self.end_id = vocab.get("<|end|>", 32008)
def _encode(self, text: str) -> list[int]:
"""Encode text to token IDs."""
return self.tokenizer.encode(text).ids
def _decode(self, ids: list[int]) -> str:
"""Decode token IDs to text."""
return self.tokenizer.decode(ids)
def _generate(self, input_ids: torch.Tensor, max_new_tokens: int) -> str:
"""Generate text from the model, stopping at <|end|> or <|finish|>."""
with torch.no_grad():
for _ in range(max_new_tokens):
# Crop context if too long
if input_ids.size(1) > self.model.config.max_seq_len:
input_ids = input_ids[:, -self.model.config.max_seq_len:]
logits = self.model(input_ids)["logits"]
next_logits = logits[:, -1, :]
# Apply temperature and sample
probs = F.softmax(next_logits / 0.8, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
input_ids = torch.cat([input_ids, next_token], dim=1)
# Stop on <|end|> or <|finish|>
if next_token.item() == self.end_id or next_token.item() == self.finish_id:
break
# Decode the generated part (after the input)
generated_ids = input_ids[0, -max_new_tokens:].tolist()
return self._decode(generated_ids)
def _parse_search_query(self, text: str) -> str | None:
"""Extract the search query from the agent's response."""
# Look for <|search|>query<|end|>
match = re.search(r"<\|search\|>(.*?)<\|end\|>", text, re.DOTALL)
if match:
return match.group(1).strip()
return None
def _parse_evidence(self, text: str) -> str | None:
"""Extract the evidence from the agent's response."""
match = re.search(r"<\|evidence\|>(.*?)(?:<\|end\|>|<\|finish\|>|$)", text, re.DOTALL)
if match:
return match.group(1).strip()
return None
def _has_finish(self, text: str) -> bool:
"""Check if the agent has signaled completion."""
return "<|finish|>" in text
def search(self, query: str) -> dict:
"""Run the full search loop for a query.
Returns:
{
"query": the original query,
"evidence": the curated evidence (or None if not found),
"searches": list of search queries issued,
"results": list of all results retrieved,
"trace": the full conversation trace,
}
"""
print(f"\n{'='*60}")
print(f"QUERY: {query}")
print(f"{'='*60}")
# Build initial context
trace = []
# System prompt
system_tokens = [self.system_id] + self._encode(SYSTEM_PROMPT) + [self.end_id]
trace.append({"role": "system", "tokens": system_tokens})
# User query
user_tokens = [self.user_id] + self._encode(query) + [self.end_id]
trace.append({"role": "user", "tokens": user_tokens})
all_searches = []
all_results = []
evidence = None
for round_num in range(self.max_search_rounds):
# Build input from trace
all_tokens = []
for entry in trace:
all_tokens.extend(entry["tokens"])
input_ids = torch.tensor([all_tokens], dtype=torch.long, device=self.device)
# Generate agent response
print(f"\n--- Round {round_num + 1} ---")
response = self._generate(input_ids, self.max_new_tokens)
print(f"Agent: {response[:200]}...")
# Add assistant tokens to trace
assistant_tokens = [self.assistant_id] + self._encode(response)
if not response.endswith("<|end|>"):
assistant_tokens.append(self.end_id)
trace.append({"role": "assistant", "tokens": assistant_tokens})
# Check for finish
if self._has_finish(response):
evidence = self._parse_evidence(response)
print(f"\n[EVIDENCE]: {evidence}")
break
# Parse search query
search_query = self._parse_search_query(response)
if search_query:
print(f"[SEARCH]: {search_query}")
all_searches.append(search_query)
# Retrieve results
results = self.retriever.search(search_query, top_k=3)
if results:
for result in results:
print(f" [RESULT]: {result['name']} ({result['language']}, score={result['score']:.2f})")
result_tokens = [self.result_id] + self._encode(result["code"]) + [self.end_id]
trace.append({"role": "result", "tokens": result_tokens, "data": result})
all_results.append(result)
else:
print(" [NO RESULTS]")
result_tokens = [self.result_id, self.end_id]
trace.append({"role": "result", "tokens": result_tokens})
else:
# No search query found β try to extract evidence directly
evidence = self._parse_evidence(response)
if evidence:
print(f"\n[EVIDENCE]: {evidence}")
break
else:
print("[WARNING] No search or evidence found, continuing...")
return {
"query": query,
"evidence": evidence,
"searches": all_searches,
"results": all_results,
"trace": trace,
}
# βββ CLI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def main():
parser = argparse.ArgumentParser(description="Run the search agent")
parser.add_argument("--checkpoint", type=str, default=os.path.join(CHECKPOINT_DIR, "sft_latest.pt"))
parser.add_argument("--query", type=str, default=None, help="Query to search for")
parser.add_argument("--interactive", action="store_true", help="Interactive mode")
parser.add_argument("--max_rounds", type=int, default=5, help="Max search rounds")
args = parser.parse_args()
agent = SearchAgent(
checkpoint_path=args.checkpoint,
tokenizer_path=TOKENIZER_PATH,
chunks_path=CHUNKS_PATH,
max_search_rounds=args.max_rounds,
)
if args.interactive:
print("\nInteractive mode. Type 'quit' to exit.")
while True:
query = input("\nQuery> ").strip()
if query.lower() in ("quit", "exit", "q"):
break
if query:
result = agent.search(query)
print(f"\n{'='*60}")
print(f"FINAL EVIDENCE:")
print(f"{'='*60}")
print(result["evidence"] or "No evidence found.")
elif args.query:
result = agent.search(args.query)
print(f"\n{'='*60}")
print(f"FINAL EVIDENCE:")
print(f"{'='*60}")
print(result["evidence"] or "No evidence found.")
else:
# Run sample queries
sample_queries = [
"How does nginx handle reusable connections?",
"What does the with_params_help decorator do?",
"What fields does the ngx_listening_s struct have?",
"How does the concatenate function work?",
"Where is the database connection pool implemented?",
]
for query in sample_queries:
result = agent.search(query)
print(f"\n{'='*60}")
print(f"FINAL EVIDENCE:")
print(f"{'='*60}")
print(result["evidence"] or "No evidence found.")
if __name__ == "__main__":
main()
|