sajitkun125's picture
deploy app
26d4279 verified
Raw
History Blame Contribute Delete
9.91 kB
"""Graph nodes. Each returns a partial AgentState; reducers in state.py merge them.
Every node checks the wall-clock deadline. That is what bounds a run even when a
provider is slow, retrying a 429, or the planner keeps asking for one more search.
"""
from __future__ import annotations
import logging
import time
from ..config import agent as agent_cfg
from ..config import rag as rag_cfg
from ..rag.llm import LLMUnavailable, complete, generate_answer
from . import corpus, prompts
from .protocol import REPAIR_PROMPT, TERMINAL, parse_action
from .state import AgentState
logger = logging.getLogger("inkference.agent")
def _out_of_time(state: AgentState) -> bool:
return time.monotonic() >= state.get("deadline", 0.0)
def _steps_left(state: AgentState) -> int:
return max(0, state.get("max_steps", agent_cfg.max_steps) - state.get("steps", 0))
def _signature(action: dict) -> tuple:
"""Identity of an action for repeat detection. Case/space-insensitive on strings
so "Plymouth" and "plymouth " count as the same search."""
args = tuple(
(k, v.strip().lower() if isinstance(v, str) else v)
for k, v in sorted(action.items())
if k not in ("action", "why")
)
return (action.get("action"), args)
def _already_done(action: dict, trace: list[dict]) -> bool:
done = {_signature({"action": t.get("action"), **(t.get("args") or {})}) for t in trace}
return _signature(action) in done
# --------------------------------------------------------------------------- #
def prepare(state: AgentState, doc_store, rag_index) -> dict:
"""Set the time budget, make sure the index exists, load the book map.
NOTE the parameter names: LangGraph injects its own BaseStore into any node
parameter literally named `store`, which would silently shadow ours with None.
"""
doc_id = state["doc_id"]
if not rag_index.exists(doc_id):
logger.info("agent: building RAG index for doc %s", doc_id)
rag_index.build_from_store(doc_id, doc_store)
books = corpus.book_map(doc_id, doc_store)
return {
"deadline": time.monotonic() + agent_cfg.time_budget_s,
# Reset from any prior standalone_question left in the checkpoint; plan
# recomputes it for this turn.
"standalone_question": state["question"],
"steps": 0,
"books": {str(b): [lo, hi] for b, (lo, hi) in sorted(books.items())},
# None = reset (see state.merge_evidence): the checkpointer carries state
# across turns, and this turn must not cite last turn's pages.
"evidence": None,
"trace": None,
# History is the one thing that must NOT reset. Record the user turn now so
# it survives even if the run errors out.
"history": [{"role": "user", "content": state["question"]}],
}
# --------------------------------------------------------------------------- #
def plan(state: AgentState) -> dict:
"""Ask the model for the next action as JSON."""
if _out_of_time(state):
logger.info("agent: deadline hit before planning -> compose")
return {"next_action": {"action": TERMINAL}, "stop_reason": "deadline"}
if _steps_left(state) <= 0:
return {"next_action": {"action": TERMINAL}, "stop_reason": "max_steps"}
history = state.get("history") or []
# Ask for the coreference-resolved question on the first PLAN STEP of every turn
# — that is when it's needed most (a follow-up like "and the weather there?"),
# and folding it in here avoids a separate rewrite round-trip.
first_step = state.get("steps", 0) == 0
question = state.get("standalone_question") or state["question"]
system = prompts.plan_system(first_step=first_step)
prompt = prompts.plan_prompt(
question=question,
history_block=prompts.render_history(history[:-1], agent_cfg.history_turns),
evidence_block=prompts.render_evidence_digest(state.get("evidence") or []),
actions_block=prompts.render_actions_taken(state.get("trace") or []),
steps_left=_steps_left(state),
)
try:
reply = complete(
system, prompt, rag_cfg,
max_tokens=400, temperature=0.0,
timeout=agent_cfg.llm_timeout_s, label="agent plan",
)
except LLMUnavailable as exc:
# No planner means no agent. Rather than compose from nothing, degrade to a
# single deterministic retrieval — which is exactly what POST /ask does — so
# the app still answers with no API key configured at all.
logger.warning("agent: planner unavailable (%s)", exc)
if not (state.get("evidence") or []):
return {
"next_action": {"action": "search", "query": question},
"stop_reason": "llm_unavailable",
}
return {"next_action": {"action": TERMINAL}, "stop_reason": "llm_unavailable"}
action, extras = parse_action(reply)
if action is None:
# One repair attempt, then give up and answer rather than loop on garbage.
logger.info("agent: unparseable plan reply (%r), retrying once", reply[:300])
try:
reply = complete(
system, prompt + "\n\n" + REPAIR_PROMPT, rag_cfg,
max_tokens=400, temperature=0.0,
timeout=agent_cfg.llm_timeout_s, label="agent plan (repair)",
)
action, extras = parse_action(reply)
except LLMUnavailable:
action, extras = None, None
if action is None:
logger.warning("agent: plan unparseable after repair -> answer")
action = {"action": TERMINAL, "why": "planner output unparseable"}
# Telling the planner "do not repeat an action" is not enough — it will happily
# call overview() four times. An exact repeat can never add evidence, so treat it
# as the signal that the planner is done.
if action.get("action") != TERMINAL and _already_done(action, state.get("trace") or []):
logger.info("agent: planner repeated %s -> compose", action)
return {"next_action": {"action": TERMINAL, "why": "repeated action"},
"stop_reason": "repeated_action"}
out: dict = {"next_action": action}
if extras and extras.get("standalone_question"):
out["standalone_question"] = extras["standalone_question"]
logger.info("agent: standalone_question=%r", out["standalone_question"])
logger.info("agent: step %d action=%s", state.get("steps", 0) + 1, action)
return out
# --------------------------------------------------------------------------- #
def act(state: AgentState, doc_store, rag_index) -> dict:
"""Run the chosen tool and fold the result into evidence + trace."""
action = state.get("next_action") or {"action": TERMINAL}
result = corpus.run_action(action, state["doc_id"], doc_store, rag_index, agent_cfg)
step_no = state.get("steps", 0) + 1
return {
"steps": step_no,
# Only the NEW passages: merge_evidence unions with what's already in state,
# so returning a pre-budgeted full list would just resurrect what was evicted.
# Budgeting therefore happens at read time, in compose.
"evidence": result["passages"],
"trace": [{
"i": step_no,
"action": action.get("action"),
"args": {k: v for k, v in action.items() if k != "action"},
"label": result["label"],
"pages": result["pages"],
"note": result["note"],
}],
}
# --------------------------------------------------------------------------- #
def compose(state: AgentState) -> dict:
"""Write the answer. Reuses the fast path's generator, so persona and the
extractive fallback behave exactly as they do for POST /ask."""
question = state.get("standalone_question") or state["question"]
# Rank best-first, then apply the token budget — compose is the only place that
# sends full passage text to a provider, so it is where the cap has to bite.
evidence = sorted(
state.get("evidence") or [], key=lambda e: e.get("score", 0.0), reverse=True
)
evidence = corpus.budget_evidence(evidence, agent_cfg)
if not evidence:
text = "No transcribed text is available to answer this question yet."
return {"answer": text, "source_pages": [], "grounded": True,
"history": [{"role": "assistant", "content": text}]}
contexts = [(e["page_number"], e["text"]) for e in evidence]
history = [
(h.get("role", "user"), h.get("content", ""))
for h in (state.get("history") or [])[:-1][-agent_cfg.history_turns:]
]
text = generate_answer(
question, contexts, rag_cfg,
persona=state.get("persona"),
history=history or None,
)
seen: set[int] = set()
source_pages: list[int] = []
for e in evidence:
page = e["page_number"]
if page > 0 and page not in seen:
seen.add(page)
source_pages.append(page)
return {
"answer": text,
"source_pages": source_pages,
"grounded": True,
"stop_reason": state.get("stop_reason") or "answer",
"history": [{"role": "assistant", "content": text}],
}
# --------------------------------------------------------------------------- #
# routers
# --------------------------------------------------------------------------- #
def route_after_plan(state: AgentState) -> str:
action = state.get("next_action") or {}
if action.get("action") == TERMINAL:
return "compose"
return "act"
def route_after_act(state: AgentState) -> str:
if _out_of_time(state):
logger.info("agent: deadline hit after step %d -> compose", state.get("steps", 0))
return "compose"
if _steps_left(state) <= 0:
return "compose"
return "plan"