Spaces:
Running
Running
| """The LangGraph agent and the streaming turn runner. | |
| A deliberately plain ReAct loop: the model calls campus tools until it has enough | |
| to answer, capped so a confused turn can't spin. The interesting parts are around | |
| the edges rather than in the graph shape: | |
| * **No checkpointer.** The durable transcript is the thread JSON in the dataset | |
| repo (see `threads.py`), which the request loads and passes in. A Space sleeps | |
| and restarts, so an in-process checkpointer would be a second, less reliable | |
| source of truth. `compile(checkpointer=...)` is a one-line change if a future | |
| feature needs mid-turn resumption. | |
| * **Citations are derived, not trusted.** Tools record every document they return. | |
| After the turn, the links the model actually wrote are matched back against that | |
| set by URL, so a source chip means "retrieval returned this", not "the model said | |
| so". Anything it linked that retrieval never saw is reported as a web result. | |
| * **Sub-agents are a seam.** The team wants a "plan my next semesters" course | |
| planner later. It becomes another node here; nothing about this file has to | |
| change to accommodate it. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import re | |
| from typing import Annotated, TypedDict | |
| from langchain_core.messages import (AIMessage, AIMessageChunk, HumanMessage, | |
| SystemMessage, ToolMessage) | |
| from langgraph.graph import END, START, StateGraph | |
| from langgraph.graph.message import add_messages | |
| from .. import kb | |
| from . import prompts, tools as t | |
| log = logging.getLogger("foresight.agent") | |
| DEFAULT_MODEL = "gpt-5.6-sol" | |
| MAX_TOOL_LOOPS = 6 | |
| # Long conversations get expensive and drift. Keep the most recent exchanges; | |
| # the full transcript is always on disk. | |
| MAX_HISTORY_MESSAGES = 24 | |
| _MARKDOWN_LINK = re.compile(r"\[[^\]]*\]\((https?://[^\s)]+)\)") | |
| def model_name() -> str: | |
| return os.environ.get("FORESIGHT_CHAT_MODEL", DEFAULT_MODEL) | |
| def enabled() -> bool: | |
| return bool(os.environ.get("OPENAI_API_KEY")) | |
| class State(TypedDict): | |
| messages: Annotated[list, add_messages] | |
| loops: int | |
| def _chat_model(): | |
| """Bound chat model. Imported lazily so the app boots without the SDK or a key.""" | |
| from langchain_openai import ChatOpenAI | |
| llm = ChatOpenAI(model=model_name(), use_responses_api=True, streaming=True) | |
| # `web_search` is OpenAI-hosted: it runs server-side, so there's no local | |
| # execution branch for it and results come back already attributed. | |
| return llm.bind_tools([*t.KB_TOOLS, {"type": "web_search"}]) | |
| _BY_NAME = {tool.name: tool for tool in t.KB_TOOLS} | |
| def _build(): | |
| def agent(state: State): | |
| return {"messages": [_chat_model().invoke(state["messages"])]} | |
| def run_tools(state: State): | |
| last = state["messages"][-1] | |
| out = [] | |
| for call in getattr(last, "tool_calls", []) or []: | |
| tool = _BY_NAME.get(call["name"]) | |
| if tool is None: | |
| # Hosted tools (web_search) never reach here; anything else is a | |
| # model mistake and should be reported back rather than crash. | |
| out.append(ToolMessage(content=f"Unknown tool: {call['name']}", | |
| tool_call_id=call["id"], name=call["name"])) | |
| continue | |
| try: | |
| result = tool.invoke(call["args"]) | |
| except Exception as err: | |
| log.warning("tool %s failed: %s", call["name"], err) | |
| result = {"error": f"{call['name']} failed: {err}"} | |
| out.append(ToolMessage(content=str(result), tool_call_id=call["id"], | |
| name=call["name"])) | |
| return {"messages": out, "loops": state.get("loops", 0) + 1} | |
| def next_step(state: State): | |
| last = state["messages"][-1] | |
| if not getattr(last, "tool_calls", None): | |
| return END | |
| if state.get("loops", 0) >= MAX_TOOL_LOOPS: | |
| log.info("agent: hit the %d-loop cap — answering with what it has", MAX_TOOL_LOOPS) | |
| return END | |
| return "tools" | |
| graph = StateGraph(State) | |
| graph.add_node("agent", agent) | |
| graph.add_node("tools", run_tools) | |
| graph.add_edge(START, "agent") | |
| graph.add_conditional_edges("agent", next_step, {"tools": "tools", END: END}) | |
| graph.add_edge("tools", "agent") | |
| return graph.compile() | |
| _graph = None | |
| def get_graph(): | |
| global _graph | |
| if _graph is None: | |
| _graph = _build() | |
| return _graph | |
| def _history(messages: list[dict]) -> list: | |
| out = [] | |
| for m in messages[-MAX_HISTORY_MESSAGES:]: | |
| text = m.get("text") or "" | |
| if not text: | |
| continue | |
| out.append(HumanMessage(text) if m.get("role") == "user" else AIMessage(text)) | |
| return out | |
| def _citations(answer: str, retrieved: dict) -> tuple[list[dict], list[str]]: | |
| """Split the answer's links into knowledge-base citations and web links. | |
| A source chip means "a tool returned this document during this turn" — so the | |
| only thing matched against is `retrieved`. Deliberately *not* the whole index: | |
| looking a URL up there would hand a chip to a model that guessed a real | |
| vanderbilt.edu address without ever searching for it, which is exactly the | |
| failure the chip is supposed to rule out. | |
| """ | |
| by_url = {d.url: d for d in retrieved.values() if d.url} | |
| cites, web, seen = [], [], set() | |
| for url in _MARKDOWN_LINK.findall(answer): | |
| if url in seen: | |
| continue | |
| seen.add(url) | |
| doc = by_url.get(url) | |
| if doc is not None: | |
| cites.append(doc.cite()) | |
| else: | |
| web.append(url) | |
| return cites, web | |
| async def run_turn(question: str, history: list[dict], profile: dict | None, | |
| first_name: str = ""): | |
| """Run one turn, yielding SSE-shaped events. | |
| Yields dicts: {"type": "tool"|"token"|"sources"|"suggestion"|"error"}. | |
| The caller owns thread ids and persistence. | |
| """ | |
| retrieved, suggestion = t.start_turn() | |
| answer_parts: list[str] = [] | |
| announced: set[str] = set() | |
| state = { | |
| "messages": [SystemMessage(prompts.system_prompt(profile, first_name)), | |
| *_history(history), HumanMessage(question)], | |
| "loops": 0, | |
| } | |
| try: | |
| async for kind, payload in get_graph().astream( | |
| state, stream_mode=["messages", "updates"]): | |
| if kind == "messages": | |
| chunk, _meta = payload | |
| # This stream carries every message a node produces, including | |
| # ToolMessages. Only the model's own output is the answer — without | |
| # this check the student watches raw tool JSON scroll past. | |
| if not isinstance(chunk, (AIMessage, AIMessageChunk)): | |
| continue | |
| text = _text_of(chunk) | |
| if text: | |
| answer_parts.append(text) | |
| yield {"type": "token", "text": text} | |
| for call in getattr(chunk, "tool_call_chunks", None) or []: | |
| name = call.get("name") | |
| if name and name not in announced: | |
| announced.add(name) | |
| label = t.TOOL_LABELS.get(name) | |
| if label: | |
| yield {"type": "tool", "name": name, "status": "running", | |
| "label": label} | |
| elif kind == "updates": | |
| for node, update in (payload or {}).items(): | |
| if node != "tools": | |
| continue | |
| for msg in update.get("messages", []): | |
| if getattr(msg, "name", None): | |
| yield {"type": "tool", "name": msg.name, "status": "done"} | |
| except Exception as err: | |
| log.exception("agent: turn failed") | |
| yield {"type": "error", "message": str(err)} | |
| return | |
| answer = "".join(answer_parts) | |
| cites, web = _citations(answer, retrieved) | |
| if cites: | |
| yield {"type": "sources", "items": cites} | |
| if web: | |
| yield {"type": "web", "items": web} | |
| if suggestion: | |
| yield {"type": "suggestion", **suggestion} | |
| yield {"type": "final", "text": answer, "sources": cites, | |
| "suggestion": suggestion or None, "tools": sorted(announced)} | |
| def _text_of(chunk) -> str: | |
| """Text out of a streamed chunk, whose content may be a string or a list of | |
| typed blocks depending on which tools are bound.""" | |
| content = getattr(chunk, "content", "") | |
| if isinstance(content, str): | |
| return content | |
| out = [] | |
| for block in content or []: | |
| if isinstance(block, str): | |
| out.append(block) | |
| elif isinstance(block, dict) and block.get("type") in ("text", "output_text"): | |
| out.append(block.get("text") or "") | |
| return "".join(out) | |