File size: 11,728 Bytes
517919c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 | """Detect incomplete redaction turns and build LangGraph auto-continue nudges."""
from __future__ import annotations
import json
import os
import re
from typing import Any
_WORKFLOW_CONTINUE_PROMPT = """Redaction work is NOT complete yet. Continue now:
1. Edit the *_review_file.csv for the user requirements (write ONE .py script, then run_workspace_python_script once)
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.
Keep write_workspace_text / script bodies compact (prefer short Python that derives rows from OCR/CSV β avoid dozens of hard-coded dict literals).
Tool args are flat strings: {"relative_path": "fix_review.py"} β never nested {"relative_path": {"relative_path": "..."}}."""
_TOOL_CALL_JSON_RETRY_PROMPT = """Your previous tool call failed: the inference server could not parse the tool arguments as JSON (usually a truncated or unescaped string inside write_workspace_text content).
Retry with a SHORT approach β do not paste a huge hard-coded row list into tool args:
1. write_workspace_text a compact .py script (ideally under ~80 lines) that reads the review/OCR CSV and adds/filters rows programmatically, OR make a small targeted CSV edit
2. run_workspace_python_script on that script (if you wrote one)
3. verify_coverage until pass_strict, then review_apply once
Use plain flat JSON string arguments only, e.g. {"relative_path": "fix_review.py", "content": "import csv\\n..."}.
Avoid triple-quoted Python docstrings and nested quote-heavy literals in content when possible."""
_IDENTICAL_ERROR_BREAKER_PROMPT = """STOP β you are stuck in a tool-error retry loop.
Your last tool calls returned the same error repeatedly. Do NOT call that tool again with the same (or empty/nested) arguments.
Tool argument format (flat strings β nesting is WRONG):
Correct: {{"pdf_relative_path": "file.pdf"}}
Correct: {{"relative_path": "fix_review.py", "content": "import csv\\n..."}}
Wrong: {{"pdf_relative_path": {{}}}}
Wrong: {{"pdf_relative_path": {{"relative_path": "file.pdf"}}}}
Wrong: {{"relative_path": {{"relative_path": "fix_review.py"}}}}
If the error says the script was already saved / write-storm: call run_workspace_python_script (NOT write_workspace_text) with only relative_path.
Last repeated error from `{tool_name}`:
{error_preview}
Before any further tool call: write out the exact flat JSON you will use (inner values are plain strings, not objects), then call ONE different next step toward review_apply."""
def build_tool_call_json_retry_prompt() -> str:
"""Nudge after llama.cpp / OpenAI-compatible tool-arg JSON parse failures."""
return _TOOL_CALL_JSON_RETRY_PROMPT
def _parse_write_workspace_payload(output: str) -> dict[str, Any] | None:
try:
data = json.loads(output)
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def tool_output_error_signature(output: str) -> str | None:
"""Stable signature for identical tool-error detection, or None if not an error."""
data = _parse_write_workspace_payload(output)
if data is None:
text = (output or "").strip()
if not text:
return None
lowered = text.lower()
if "error" not in lowered and "failed" not in lowered:
return None
return re.sub(r"\s+", " ", text.lower())[:240]
if data.get("loop_breaker"):
return f"loop_breaker:{data.get('error') or ''}"[:240]
err = data.get("error")
if err is None:
return None
return re.sub(r"\s+", " ", str(err).lower())[:240]
def identical_tool_error_streak(
tool_outputs: list[tuple[str, str]],
*,
min_streak: int = 2,
) -> tuple[str, str] | None:
"""Return (tool_name, signature) when the last min_streak errors match."""
if min_streak < 2 or len(tool_outputs) < min_streak:
return None
recent = tool_outputs[-min_streak:]
names = [name for name, _ in recent]
if len(set(names)) != 1:
return None
sigs: list[str] = []
for _, output in recent:
sig = tool_output_error_signature(output)
if not sig:
return None
sigs.append(sig)
if len(set(sigs)) != 1:
return None
return names[0], sigs[0]
def build_identical_error_breaker_prompt(
tool_name: str,
tool_outputs: list[tuple[str, str]],
) -> str:
"""Nudge after repeated identical tool errors."""
preview = ""
fix_example = ""
for name, output in reversed(tool_outputs):
if name != tool_name:
continue
data = _parse_write_workspace_payload(output)
if isinstance(data, dict) and data.get("error"):
preview = str(data["error"])[:500]
example = data.get("fix_example")
if isinstance(example, dict) and example:
fix_example = (
"\n\nUse this exact flat JSON next (copy the path strings):\n"
+ json.dumps(example)
)
else:
preview = (output or "")[:500]
break
# Prefer concrete paths from the latest successful doc_redact if present.
artifact_hint = ""
for name, output in reversed(tool_outputs):
if name != "doc_redact":
continue
data = _parse_write_workspace_payload(output)
if not isinstance(data, dict) or data.get("error"):
continue
paths = {
key: data[key]
for key in (
"review_csv_relative_path",
"ocr_words_csv_relative_path",
)
if isinstance(data.get(key), str) and data.get(key)
}
if paths:
artifact_hint = (
"\n\nKnown paths from doc_redact (use as plain strings):\n"
+ json.dumps(paths)
)
break
return _IDENTICAL_ERROR_BREAKER_PROMPT.format(
tool_name=tool_name,
error_preview=(preview or "(see prior tool output)")
+ fix_example
+ artifact_hint,
)
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
data = _parse_write_workspace_payload(output)
if not data:
continue
written = str(data.get("written") or "")
if written.lower().endswith(".py"):
return written
return None
def wrote_review_csv(tool_outputs: list[tuple[str, str]]) -> bool:
for name, output in tool_outputs:
if name != "write_workspace_text":
continue
data = _parse_write_workspace_payload(output)
if not data:
continue
written = str(data.get("written") or "").lower().replace("\\", "/")
if written.endswith(".csv") and (
"review_file" in written or written.endswith("_review.csv")
):
return True
return False
def pending_python_script(
tool_names_seen: set[str],
tool_outputs: list[tuple[str, str]],
) -> str | None:
if "run_workspace_python_script" in tool_names_seen:
# Only pending if a newer write happened after the last run.
last_run_idx = -1
last_write_idx = -1
last_write_path: str | None = None
for idx, (name, output) in enumerate(tool_outputs):
if name == "run_workspace_python_script":
last_run_idx = idx
elif name == "write_workspace_text":
data = _parse_write_workspace_payload(output)
written = str((data or {}).get("written") or "")
if written.lower().endswith(".py"):
last_write_idx = idx
last_write_path = written
if last_write_path and last_write_idx > last_run_idx:
return last_write_path
return None
return last_written_python_script(tool_outputs)
def consecutive_python_writes_without_run(
tool_outputs: list[tuple[str, str]],
) -> tuple[str | None, int]:
"""Count trailing write_workspace_text .py saves since the last script run."""
count = 0
path: str | None = None
for name, output in reversed(tool_outputs):
if name == "run_workspace_python_script":
break
if name != "write_workspace_text":
if name in {
"doc_redact",
"verify_coverage",
"review_apply",
"read_workspace_text",
"list_workspace_files",
}:
break
continue
data = _parse_write_workspace_payload(output)
if not data or data.get("error"):
continue
written = str(data.get("written") or "")
if not written.lower().endswith(".py"):
break
count += 1
path = path or written
return path, count
def build_workflow_continue_prompt(
tool_names_seen: set[str],
tool_outputs: list[tuple[str, str]],
) -> str:
streak = identical_tool_error_streak(tool_outputs, min_streak=2)
if streak:
return build_identical_error_breaker_prompt(streak[0], tool_outputs)
script_path = pending_python_script(tool_names_seen, tool_outputs)
if script_path:
return (
"Redaction work 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."
)
path, write_count = consecutive_python_writes_without_run(tool_outputs)
if path and write_count >= 2:
return (
"Redaction work is NOT complete. You have rewritten the same Python script "
f"({path}) {write_count} times without running it. "
f"Call run_workspace_python_script with relative_path={path!r} NOW β "
"do not write again."
)
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 langgraph_identical_error_stop_streak() -> int:
"""Stop / break after this many identical consecutive tool errors (default 3)."""
raw = os.environ.get("LANGGRAPH_IDENTICAL_ERROR_STOP", "3").strip()
try:
return max(2, int(raw))
except ValueError:
return 3
def redaction_workflow_incomplete(
tool_names: set[str],
tool_outputs: list[tuple[str, str]] | None = None,
) -> bool:
"""True when this turn started redaction work but stopped before review_apply.
Covers Pass 1 (doc_redact without review_apply) and follow-ups that write a
pending .py script, run a workspace script, or edit a review CSV without apply.
Explore-only turns (list/read) are treated as complete.
"""
if "review_apply" in tool_names:
return False
if "doc_redact" in tool_names:
return True
outputs = tool_outputs or []
if pending_python_script(tool_names, outputs):
return True
if "run_workspace_python_script" in tool_names:
return True
if wrote_review_csv(outputs):
return True
return False
|