Spaces:
Running
Running
eliezer avihail
UI: send-on-Enter + live reasoning trace; docs: answer-cache decision (#106)
a170f20 unverified | """The manual agent loop: the LLM drives the three tools within budgets. | |
| Each step, a planner call returns a JSON action (search_docs / read_page / | |
| ask_source / answer). We execute it, append a short observation, and repeat | |
| until the model declares it can answer or a budget trips β then generate the | |
| grounded Answer from everything accumulated. agent/graph.py is a LangGraph | |
| twin of this loop that shares the same tools, budgets, and planner. | |
| """ | |
| from __future__ import annotations | |
| import ast | |
| import json | |
| import re | |
| from agent.schemas import Answer, Referral | |
| BUDGETS = {"search_docs": 6, "read_page": 2, "ask_source": 1} | |
| MAX_STEPS = sum(BUDGETS.values()) + 2 | |
| PLANNER_SYSTEM = ( | |
| "You are planning how to answer a PyTorch question using tools. Each turn, " | |
| "reply with ONE json object and nothing else:\n" | |
| ' {"action":"search_docs","query":"english keywords","library":null,"kind":null}\n' | |
| ' {"action":"read_page","url":"<a url from a previous search result>"}\n' | |
| ' {"action":"ask_source","question":"..."} (ONLY for source-code internals)\n' | |
| ' {"action":"answer"} (when the gathered context can answer the question)\n' | |
| 'kind picks the content space: "api" = reference pages (use for catalog ' | |
| 'questions like "what loss functions exist?" or to find a specific class), ' | |
| '"tutorial" or "guide" = walkthroughs; null = everything. If a search ' | |
| "returned only tutorials but you need the actual API, search again with " | |
| 'kind "api" and the likely symbol names as the query.\n' | |
| "Guidance: decompose multi-part questions into several search_docs calls " | |
| "with different queries. Use read_page to open a promising page in full. " | |
| "Use ask_source only when the answer needs implementation details the docs " | |
| "don't cover. Answer as soon as you have enough β don't waste tool calls." | |
| ) | |
| def _plan(question: str, transcript: list[str], budgets: dict, provider, client) -> dict: | |
| from agent.llm import GenerationError, _raw_completion | |
| state = "\n".join(transcript) if transcript else "(no tools used yet)" | |
| remaining = ", ".join(f"{t}:{n}" for t, n in budgets.items()) | |
| prompt = ( | |
| f"Question: {question}\n\nTool calls so far:\n{state}\n\n" | |
| f"Remaining tool budget: {remaining}\nYour next action as one json object:" | |
| ) | |
| try: | |
| raw = _raw_completion(prompt, system=PLANNER_SYSTEM, provider=provider, client=client) | |
| except GenerationError: | |
| return {"action": "answer"} # planner unreachable β answer with what we have | |
| return _parse_action(raw) | |
| def _parse_action(raw: str) -> dict: | |
| start, end = raw.find("{"), raw.rfind("}") | |
| if start == -1 or end == -1: | |
| return {"action": "answer"} | |
| try: | |
| action = json.loads(raw[start : end + 1]) | |
| except json.JSONDecodeError: | |
| return {"action": "answer"} | |
| if action.get("action") not in BUDGETS and action.get("action") != "answer": | |
| return {"action": "answer"} | |
| return action | |
| def _humanize(line: str) -> str: | |
| """Turn a transcript step into a short grey trace line for the web UI. | |
| The transcript entries are terse function-call records (see tools_exec.py); | |
| this renders them as the model's visible reasoning. Falls back to the raw | |
| line on any shape it doesn't recognise, so a format change degrades to a | |
| still-useful trace rather than crashing the answer path. | |
| """ | |
| m = re.match(r"search_docs\((.+?)\) β (\[.*\])$", line) | |
| if m: | |
| try: | |
| query = ast.literal_eval(m.group(1)) | |
| titles = [str(t).split(" > ")[-1] for t in ast.literal_eval(m.group(2))] | |
| except (ValueError, SyntaxError): | |
| return f"π {line}" | |
| shown = " Β· ".join(t for t in titles[:4] if t) | |
| return f"π searched β{query}β" + (f" β {shown}" if shown else "") | |
| if line.startswith("read_page"): | |
| return "π read a full page" | |
| if line.startswith("ask_source"): | |
| return "π checked the source-code references" | |
| return f"β’ {line}" | |
| def answer_agentic( | |
| question: str, provider: str | None = None, client=None, progress=None | |
| ) -> Answer: | |
| """Run the tool loop and return a grounded Answer.""" | |
| from agent.grounded import answer_from_sections | |
| from agent.tools_exec import do_search, execute_tool | |
| budgets = dict(BUDGETS) | |
| sections: list[dict] = [] | |
| referrals: list[Referral] = [] | |
| seen_urls: set[str] = set() | |
| transcript: list[str] = [] | |
| def emit_last(): # surface the step we just recorded, in the UI's grey trace | |
| if progress and transcript: | |
| progress(_humanize(transcript[-1])) | |
| # always retrieve once for the raw question β never depend on a (possibly | |
| # rate-limited) planner call just to do the obvious first search | |
| budgets["search_docs"] -= 1 | |
| do_search(question, None, sections=sections, seen_urls=seen_urls, transcript=transcript) | |
| emit_last() | |
| for _ in range(MAX_STEPS): | |
| action = _plan(question, transcript, budgets, provider, client) | |
| name = action.get("action") | |
| if name == "answer" or all(v == 0 for v in budgets.values()): | |
| break | |
| if budgets.get(name, 0) == 0: | |
| transcript.append(f"{name}: budget exhausted, pick another action or answer") | |
| continue | |
| budgets[name] -= 1 | |
| execute_tool( | |
| name, action, question, | |
| sections=sections, referrals=referrals, | |
| seen_urls=seen_urls, transcript=transcript, | |
| ) | |
| emit_last() | |
| if progress: | |
| progress("βοΈ writing the answer") | |
| return answer_from_sections( | |
| question, sections, referrals=referrals, provider=provider, client=client | |
| ) | |