Sync: Redaction app containers now bind the output and eval folders so that files can be visible on system
e9bc94e Download agent-redact/redaction_langgraph/workflow_continue.py from seanpedrickcase/document_redaction: direct link, hf CLI and curl.
- Browser
- Download file 13.5 kB
-
https://huggingface.co/spaces/seanpedrickcase/document_redaction/resolve/main/agent-redact/redaction_langgraph/workflow_continue.py
- Command line
-
hf download hf://spaces/seanpedrickcase/document_redaction/agent-redact/redaction_langgraph/workflow_continue.py
-
curl -L -o workflow_continue.py https://huggingface.co/spaces/seanpedrickcase/document_redaction/resolve/main/agent-redact/redaction_langgraph/workflow_continue.py
13.5 kB
| """Detect incomplete redaction turns and build LangGraph auto-continue nudges.""" | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| from typing import Any | |
| # Assistant / tool marker: auto-continue must not nudge past a policy pause. | |
| CLARIFICATION_NEEDED_MARKER = "CLARIFICATION_NEEDED:" | |
| _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 clarification_marker_in_text(text: str | None) -> bool: | |
| """True when assistant prose uses the fixed clarification pause marker.""" | |
| if not text: | |
| return False | |
| return CLARIFICATION_NEEDED_MARKER in text | |
| def clarification_requested( | |
| tool_outputs: list[tuple[str, str]] | None = None, | |
| *, | |
| assistant_text: str | None = None, | |
| ) -> bool: | |
| """True when the agent intentionally paused for user policy clarification. | |
| Prefers a successful ``request_clarification`` tool result; also accepts the | |
| fixed ``CLARIFICATION_NEEDED:`` marker in assistant text (fallback when the | |
| model asks in prose without the tool). | |
| """ | |
| if clarification_marker_in_text(assistant_text): | |
| return True | |
| for name, output in reversed(tool_outputs or []): | |
| if name != "request_clarification": | |
| continue | |
| data = _parse_write_workspace_payload(output) | |
| if isinstance(data, dict) and data.get("awaiting_clarification"): | |
| return True | |
| # Tolerant fallback if the tool returned plain text. | |
| if CLARIFICATION_NEEDED_MARKER in (output or ""): | |
| return True | |
| if ( | |
| '"awaiting_clarification"' in (output or "") | |
| and "true" in (output or "").lower() | |
| ): | |
| return True | |
| return False | |
| def redaction_workflow_incomplete( | |
| tool_names: set[str], | |
| tool_outputs: list[tuple[str, str]] | None = None, | |
| *, | |
| assistant_text: 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. | |
| A deliberate clarification pause is **not** incomplete: auto-continue must | |
| not nudge past ``request_clarification`` / ``CLARIFICATION_NEEDED:``. | |
| """ | |
| outputs = tool_outputs or [] | |
| if clarification_requested(outputs, assistant_text=assistant_text): | |
| return False | |
| if "review_apply" in tool_names: | |
| return False | |
| if "doc_redact" in tool_names: | |
| return True | |
| 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 | |