seanpedrickcase's picture
Sync: Removed some file name references
c8f17c4
Raw
History Blame Contribute Delete
11.4 kB
"""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,
) # noqa: E402
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage # noqa: E402
from redaction_langgraph.graph import build_redaction_agent # noqa: E402
_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 "")