| """LangGraph :class:`AgentRuntime` implementation for the Gradio UI.""" |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import sys |
| import threading |
| from collections.abc import Iterator |
| from pathlib import Path |
| from typing import Any |
|
|
| _AGENT_REDACT_ROOT = Path(__file__).resolve().parents[1] |
| if str(_AGENT_REDACT_ROOT) not in sys.path: |
| sys.path.insert(0, str(_AGENT_REDACT_ROOT)) |
|
|
| _PI_DIR = _AGENT_REDACT_ROOT / "pi" |
| if str(_PI_DIR) not in sys.path: |
| sys.path.insert(0, str(_PI_DIR)) |
|
|
| from agent_runtime import ( |
| AgentRuntime, |
| AgentRuntimeError, |
| AgentStreamEvent, |
| ) |
| from langchain_core.messages import AIMessage, HumanMessage, ToolMessage |
|
|
| from redaction_langgraph.graph import build_redaction_agent |
|
|
| _WORKFLOW_CONTINUE_PROMPT = """Pass 1 redaction is NOT complete yet. Continue now: |
| 1. Edit the *_review_file.csv for the user requirements (write_workspace_text or run_workspace_python_script) |
| 2. Run verify_coverage until pass_strict is true |
| 3. Run review_apply once on the source PDF and edited review CSV |
| Call the next required tool — do not stop after read_workspace_text or write_workspace_text.""" |
|
|
|
|
| def _last_written_python_script(tool_outputs: list[tuple[str, str]]) -> str | None: |
| for name, output in reversed(tool_outputs): |
| if name != "write_workspace_text": |
| continue |
| try: |
| data = json.loads(output) |
| except json.JSONDecodeError: |
| continue |
| written = str(data.get("written") or "") |
| if written.lower().endswith(".py"): |
| return written |
| return None |
|
|
|
|
| def _build_workflow_continue_prompt( |
| tool_names_seen: set[str], |
| tool_outputs: list[tuple[str, str]], |
| ) -> str: |
| if ( |
| "write_workspace_text" in tool_names_seen |
| and "run_workspace_python_script" not in tool_names_seen |
| ): |
| script_path = _last_written_python_script(tool_outputs) |
| if script_path: |
| return ( |
| "Pass 1 is NOT complete. The Python script is already saved at " |
| f"`{script_path}` — do NOT call write_workspace_text again. " |
| f"Call run_workspace_python_script with relative_path={script_path!r} " |
| "now, then verify_coverage and review_apply." |
| ) |
| return _WORKFLOW_CONTINUE_PROMPT |
|
|
|
|
| def _langgraph_auto_continue_enabled() -> bool: |
| return os.environ.get( |
| "LANGGRAPH_AUTO_CONTINUE_WORKFLOW", "true" |
| ).strip().lower() in { |
| "1", |
| "true", |
| "yes", |
| "on", |
| } |
|
|
|
|
| def _langgraph_max_continuations() -> int: |
| raw = os.environ.get("LANGGRAPH_WORKFLOW_CONTINUATIONS", "2").strip() |
| try: |
| return max(0, int(raw)) |
| except ValueError: |
| return 2 |
|
|
|
|
| def _redaction_workflow_incomplete(tool_names: set[str]) -> bool: |
| return "doc_redact" in tool_names and "review_apply" not in tool_names |
|
|
|
|
| class LangGraphAgentRuntime(AgentRuntime): |
| """Session-scoped LangGraph ReAct agent (curated tools, no shell).""" |
|
|
| def __init__(self, *, session_hash: str | None = None) -> None: |
| self._session_hash = session_hash |
| self._graph: Any = None |
| self._system_message: Any = None |
| self._messages: list[Any] = [] |
| self._running = False |
| self._prompt_stream_depth = 0 |
| self._abort_requested = False |
| self._lock = threading.Lock() |
| self._pending_ui_notices: list[dict[str, Any]] = [] |
| self._pending_ui_history: list[dict[str, Any]] = [] |
|
|
| @property |
| def orchestrator(self) -> str: |
| return "langgraph" |
|
|
| @property |
| def running(self) -> bool: |
| return self._running |
|
|
| @property |
| def prompt_stream_active(self) -> bool: |
| return self._prompt_stream_depth > 0 |
|
|
| def start(self) -> None: |
| if self._graph is None: |
| self._graph, self._system_message = build_redaction_agent( |
| self._session_hash |
| ) |
| self._running = True |
|
|
| def close(self) -> None: |
| self._running = False |
| self._graph = None |
| self._messages = [] |
|
|
| def abort(self) -> None: |
| self._abort_requested = True |
|
|
| def new_session(self) -> None: |
| self._messages = [] |
| self._abort_requested = False |
|
|
| def set_model(self, provider: str, model_id: str) -> dict[str, Any]: |
| os.environ["AGENT_DEFAULT_PROVIDER"] = provider |
| os.environ["AGENT_DEFAULT_MODEL"] = model_id |
| if provider == "llama-cpp": |
| os.environ["AGENT_LLAMA_MODEL_ID"] = model_id |
| self._graph = None |
| self.start() |
| return {"provider": provider, "model": model_id} |
|
|
| def apply_backend(self, provider: str, model_id: str) -> None: |
| self.set_model(provider, model_id) |
| self.new_session() |
|
|
| def get_state(self) -> dict[str, Any]: |
| return { |
| "isStreaming": self.prompt_stream_active, |
| "isCompacting": False, |
| "provider": os.environ.get("AGENT_DEFAULT_PROVIDER"), |
| "model": { |
| "provider": os.environ.get("AGENT_DEFAULT_PROVIDER"), |
| "id": os.environ.get("AGENT_DEFAULT_MODEL") |
| or os.environ.get("AGENT_LLAMA_MODEL_ID"), |
| }, |
| } |
|
|
| def get_messages(self) -> list[dict[str, Any]]: |
| out: list[dict[str, Any]] = [] |
| for message in self._messages: |
| if isinstance(message, HumanMessage): |
| out.append({"role": "user", "content": str(message.content)}) |
| elif isinstance(message, AIMessage): |
| out.append({"role": "assistant", "content": str(message.content or "")}) |
| return out |
|
|
| def stage_ui_chat_notice(self, label: str, message: str) -> None: |
| text = message.strip() |
| if not text: |
| return |
| self._pending_ui_history.append( |
| {"role": "user", "content": f"_**{label}:**_ {text}"} |
| ) |
| self._pending_ui_history.append({"role": "assistant", "content": ""}) |
|
|
| def drain_pending_ui_history(self) -> list[dict[str, Any]]: |
| pending = self._pending_ui_history[:] |
| self._pending_ui_history.clear() |
| return pending |
|
|
| def _yield_message_updates( |
| self, |
| msg: Any, |
| *, |
| assistant_chunks: list[str], |
| tool_names_seen: set[str], |
| tool_outputs: list[tuple[str, str]], |
| ) -> Iterator[AgentStreamEvent]: |
| if isinstance(msg, AIMessage): |
| text = self._stringify_content(msg.content) |
| if text: |
| assistant_chunks.append(text) |
| yield AgentStreamEvent(kind="text_snapshot", text=text) |
| for call in msg.tool_calls or []: |
| name = str(call.get("name") or "tool") |
| args = call.get("args") if isinstance(call.get("args"), dict) else {} |
| yield AgentStreamEvent( |
| kind="tool_start", |
| tool_name=name, |
| tool_args=args, |
| text=name, |
| ) |
| elif isinstance(msg, ToolMessage): |
| name = str(msg.name or "tool") |
| tool_names_seen.add(name) |
| output = str(msg.content or "") |
| tool_outputs.append((name, output)) |
| yield AgentStreamEvent( |
| kind="tool_end", |
| tool_name=name, |
| tool_output=output, |
| is_error=False, |
| ) |
|
|
| def prompt_events(self, message: str) -> Iterator[AgentStreamEvent]: |
| self._prompt_stream_depth += 1 |
| self._abort_requested = False |
| try: |
| if not self._running: |
| self.start() |
| if self._graph is None: |
| raise AgentRuntimeError("LangGraph agent is not initialized.") |
|
|
| from redaction_langgraph.graph import graph_recursion_limit |
|
|
| yield AgentStreamEvent(kind="status", text="LangGraph agent started…") |
| graph_messages: list[Any] = [ |
| self._system_message, |
| *self._messages, |
| HumanMessage(content=message), |
| ] |
| self._messages.append(HumanMessage(content=message)) |
|
|
| assistant_chunks: list[str] = [] |
| tool_names_seen: set[str] = set() |
| tool_outputs: list[tuple[str, str]] = [] |
| stream_config = {"recursion_limit": graph_recursion_limit()} |
| max_rounds = 1 + ( |
| _langgraph_max_continuations() |
| if _langgraph_auto_continue_enabled() |
| else 0 |
| ) |
|
|
| for round_idx in range(max_rounds): |
| if round_idx > 0: |
| yield AgentStreamEvent( |
| kind="status", |
| text="Pass 1 incomplete — nudging agent to continue workflow…", |
| ) |
| for event in self._graph.stream( |
| {"messages": graph_messages}, |
| stream_mode="updates", |
| config=stream_config, |
| ): |
| if self._abort_requested: |
| yield AgentStreamEvent(kind="done", text="Agent aborted.") |
| return |
| for _node, update in event.items(): |
| for msg in update.get("messages") or []: |
| graph_messages.append(msg) |
| yield from self._yield_message_updates( |
| msg, |
| assistant_chunks=assistant_chunks, |
| tool_names_seen=tool_names_seen, |
| tool_outputs=tool_outputs, |
| ) |
| if not _redaction_workflow_incomplete(tool_names_seen): |
| break |
| if round_idx >= max_rounds - 1: |
| break |
| graph_messages.append( |
| HumanMessage( |
| content=_build_workflow_continue_prompt( |
| tool_names_seen, tool_outputs |
| ) |
| ) |
| ) |
|
|
| if assistant_chunks: |
| self._messages.append(AIMessage(content="\n".join(assistant_chunks))) |
| workflow_incomplete = _redaction_workflow_incomplete(tool_names_seen) |
| done_text = "Agent finished." |
| if workflow_incomplete: |
| done_text = ( |
| "Agent finished (Pass 1 incomplete — review_apply not run; " |
| "use **Send** to continue or restart the task)." |
| ) |
| yield AgentStreamEvent( |
| kind="done", |
| text=done_text, |
| meta={"workflow_incomplete": workflow_incomplete}, |
| ) |
| finally: |
| self._prompt_stream_depth = max(0, self._prompt_stream_depth - 1) |
|
|
| @staticmethod |
| def _stringify_content(content: Any) -> str: |
| if isinstance(content, str): |
| return content |
| if isinstance(content, list): |
| parts: list[str] = [] |
| for block in content: |
| if isinstance(block, str): |
| parts.append(block) |
| elif isinstance(block, dict) and block.get("type") == "text": |
| parts.append(str(block.get("text") or "")) |
| return "".join(parts) |
| return str(content or "") |
|
|