更新了langgraph-plan-view,支持windows下使用 langgraph cli;更新了langgraph-vivado-install,要求安装langgraph cli,确定conda环境的名称为langgraph

This commit is contained in:
Myfro9
2026-05-25 13:13:00 +08:00
parent fda6c9a114
commit 48b965ec4e
9 changed files with 980 additions and 145 deletions
+60 -5
View File
@@ -7,6 +7,29 @@ description: Use the LangGraph plan-review workflow from the current Claude Code
Use this skill to run the LangGraph plan-review workflow. All scripts are bundled under the skill's own `scripts\` directory (`C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\`) — no dependency on `E:\LangGraph_Design\lab00`.
## Pre-flight: Environment Setup
Before running the workflow for the first time (or when unsure the environment is ready), verify that the required packages are installed in the `langgraph` conda env:
```powershell
# Install langgraph dev server, SDK, and HTTP client
conda run -n langgraph pip install "langgraph-cli[inmem]" langgraph-sdk httpx
# Verify the SDK is importable
conda run -n langgraph python -c "from langgraph_sdk import get_sync_client; print('SDK OK')"
# Verify the langgraph dev command is available
conda run -n langgraph langgraph --version
```
| Package | Purpose |
|---|---|
| `langgraph-cli[inmem]` | Provides the `langgraph dev` server command |
| `langgraph-sdk` | Python client used by `run_workflow.py` to submit runs |
| `httpx` | HTTP health-check in `run_workflow.py` |
**Codex auth** (if not already set up): `C:\Users\bowei\.codex\auth.json` must contain a valid ChatGPT OAuth token. Run `codex auth login` if the file is missing.
## Invariants
- Use the current Claude Code session for all Claude Code work. Do not launch another `claude` process.
@@ -22,13 +45,40 @@ Use this skill to run the LangGraph plan-review workflow. All scripts are bundle
- Keep all workflow files under `<Claude launch work_dir>\Notes` unless the user explicitly overrides `-NotesDir`.
- `TaskId` is the numeric suffix, such as `01`; write the plan to `Notes\Task01.md`.
- `discussxx-yy.md` numbering is managed by the LangGraph app for Codex review files. If Claude rejects a review comment, create the next numbered discuss file manually before rerunning review.
- The workflow uses LangGraph `interrupt()` and persists state in `workflow_state.db`.
- The workflow uses LangGraph `interrupt()` and persists state via the langgraph dev server.
- Each `run_langgraph.ps1` invocation advances the graph to the next interrupt or completion. Use `-Resume` to continue the same thread after completing the requested Claude Code action.
## Dev Server Lifecycle
The `langgraph dev` server must be running for the entire duration of a workflow session.
Use `manage_dev_server.ps1` to start it before the first `run_langgraph.ps1` call and stop it after the workflow completes.
```powershell
# Start (idempotent — safe to call even if already running)
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 start
# Stop (call once when workflow returns {"status":"done"})
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 stop
# Status check
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 status
```
- `start` waits up to 45 s for `http://127.0.0.1:2024/ok` to respond, then prints the Studio URL.
- `stop` kills the process by PID file and removes it; falls back to killing by port 2024 if the PID is stale.
- If the server is already running, `start` is a no-op.
- Server stdout/stderr logs are written to `%TEMP%\langgraph_dev_server.stdout.log` and `%TEMP%\langgraph_dev_server.stderr.log` for debugging.
- **Studio URL**: `https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024` — open in browser to track workflow execution in real time.
## Workflow
1. Determine `TaskId` from the user. If missing, ask for it.
2. Start or resume the LangGraph app:
2. Start the dev server (idempotent):
```powershell
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 start
```
Wait for the "Ready" confirmation before proceeding. Open the Studio URL in a browser for real-time tracking.
3. Start or resume the LangGraph app:
```powershell
# Use double backslashes (\\) in the JSON string so Windows paths are valid JSON.
# $task_path / $latest_discuss_path / etc. are Python Template placeholders — keep them literal.
@@ -39,7 +89,7 @@ Use this skill to run the LangGraph plan-review workflow. All scripts are bundle
-CodexReviewCommand $codexCmd `
-Timeout 300
```
3. Inspect the returned JSON. If `status` is `waiting`, perform `action_required` in the current Claude Code session:
4. Inspect the returned JSON. If `status` is `waiting`, perform `action_required` in the current Claude Code session:
- `write_plan`: write `Task<TaskId>.md` with project background, task objective, and concrete implementation plan, then resume with `-Resume 'true'`.
- `decide`: read the discuss file and resume with `-Resume '{"accepts":true}'` or `-Resume '{"accepts":false,"reason":"..."}'`.
- `revise`: revise `Task<TaskId>.md`, then resume with `-Resume 'true'`.
@@ -48,10 +98,13 @@ Use this skill to run the LangGraph plan-review workflow. All scripts are bundle
**Why `review_task.py`**: Using the shim keeps all codex-specific logic in Python, and the JSON array stays simple.
`run_langgraph.ps1` base64-encodes the JSON command and resume payload before passing them through `conda run`, so normal PowerShell single-quoted JSON can be used. **Windows paths inside the JSON string must use `\\` (double backslash) to produce valid JSON**, e.g. `C:\\Users\\bowei\\...`. `run_langgraph.ps1` automatically sets `PYTHONIOENCODING=utf-8` so prompts containing Unicode (Chinese, etc.) pass through correctly.
4. Inspect the command output after each resume.
5. Inspect the command output after each resume.
- Check `"status"` to determine next action: `"waiting"` means perform the next `action_required`; `"done"` means the workflow is complete.
- If a new `latest_discuss_path` is present in the output, read that discuss file for context.
5. Stop when the workflow returns `{"status":"done", ...}`.
6. Stop when the workflow returns `{"status":"done", ...}`:
```powershell
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 stop
```
## Rejection File Rule
@@ -82,3 +135,5 @@ Write the rejection content directly to `action_required.output_path`:
- `review_task.py` persists the Codex session id through LangGraph state and uses `codex exec resume` on later review passes.
- `review_task.py` runs Codex with the original Claude Code launch working directory as cwd.
- Codex review output is normalized to JSON for `agents.py`: `{"has_serious_issue":true/false,"comment":"...","codex_session_id":"..."}`.
- If the dev server restarts, `run_workflow.py` detects that the stored thread no longer exists and creates a new thread automatically.
- `run_workflow.py` passes the codex command and timeout as `config["configurable"]` per run — no server restart needed when switching projects.
+9
View File
@@ -0,0 +1,9 @@
{
"dependencies": [
"langgraph",
"langgraph-checkpoint-sqlite"
],
"graphs": {
"plan_review": "./scripts/graph.py:graph"
}
}
+19
View File
@@ -0,0 +1,19 @@
"""Module-level graph export for `langgraph dev`.
The Codex review command is injected per-run via config["configurable"],
so no runner is needed at build time. The server loads this module once;
every submitted run brings its own command through the LangGraph SDK client.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure sibling modules (agents, workflow) are importable when langgraph dev
# loads this file from the skill root via ./scripts/graph.py:graph.
sys.path.insert(0, str(Path(__file__).parent))
from workflow import build_workflow # noqa: E402
# langgraph dev manages its own persistence — do not pass a checkpointer here.
graph = build_workflow()
@@ -0,0 +1,193 @@
<#
.SYNOPSIS
Manage the langgraph dev server on Windows.
.DESCRIPTION
Start, stop, or check the status of the langgraph dev server.
The server starts from the skill root (where langgraph.json lives) so the
graph module is discovered correctly.
.PARAMETER Command
start | stop | status
.EXAMPLE
manage_dev_server.ps1 start # idempotent — safe to call if already running
manage_dev_server.ps1 stop # call once workflow returns {"status":"done"}
manage_dev_server.ps1 status # print running/stopped + Studio URL
#>
param([string]$Command = "status")
$SkillDir = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$PidFile = Join-Path $env:TEMP "langgraph_dev_server.pid"
$StdoutLog = Join-Path $env:TEMP "langgraph_dev_server.stdout.log"
$StderrLog = Join-Path $env:TEMP "langgraph_dev_server.stderr.log"
$ApiUrl = "http://127.0.0.1:2024"
$MaxWait = 45 # seconds to wait for the server to become ready
# --- helpers -----------------------------------------------------------
function Test-HttpOk([string]$Url) {
try {
$r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 300)
} catch {
return $false
}
}
function Get-StoredPid {
if (-not (Test-Path $PidFile)) { return 0 }
$raw = (Get-Content $PidFile -Raw -ErrorAction SilentlyContinue).Trim()
if (-not $raw) { return 0 }
[int]$raw
}
function Test-ServerRunning {
$pid = Get-StoredPid
if ($pid -eq 0) { return $false }
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
if (-not $proc) { return $false }
return (Test-HttpOk "$ApiUrl/ok")
}
function Find-LangGraphExe {
$up = $env:USERPROFILE
$candidates = @(
"$up\.conda\envs\langgraph\Scripts\langgraph.exe",
"$up\miniconda3\envs\langgraph\Scripts\langgraph.exe",
"$up\anaconda3\envs\langgraph\Scripts\langgraph.exe",
"$up\miniforge3\envs\langgraph\Scripts\langgraph.exe",
"$up\AppData\Local\miniconda3\envs\langgraph\Scripts\langgraph.exe",
"C:\ProgramData\miniconda3\envs\langgraph\Scripts\langgraph.exe",
"C:\ProgramData\anaconda3\envs\langgraph\Scripts\langgraph.exe"
)
foreach ($c in $candidates) {
if (Test-Path $c) { return $c }
}
# Try PATH
$cmd = Get-Command langgraph -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
# Try resolving via conda
$condaCandidates = @($env:CONDA_EXE, "C:\ProgramData\miniconda3\Scripts\conda.exe",
"$up\AppData\Local\miniconda3\Scripts\conda.exe")
foreach ($conda in $condaCandidates) {
if ($conda -and (Test-Path $conda)) {
try {
$lgPath = (& $conda run -n langgraph python -c "import shutil; print(shutil.which('langgraph') or '')" 2>$null).Trim()
if ($lgPath -and (Test-Path $lgPath)) { return $lgPath }
} catch {}
}
}
throw ("Cannot find langgraph.exe in the 'langgraph' conda env. " +
"Install it: conda run -n langgraph pip install 'langgraph-cli[inmem]'")
}
function Stop-ServerProcess([int]$PidValue) {
$stopped = $false
if ($PidValue -gt 0) {
$proc = Get-Process -Id $PidValue -ErrorAction SilentlyContinue
if ($proc) {
Stop-Process -Id $PidValue -Force -ErrorAction SilentlyContinue
$stopped = $true
}
}
# Fallback: kill whoever is listening on port 2024
if (-not $stopped) {
$conn = Get-NetTCPConnection -LocalPort 2024 -State Listen -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($conn) {
Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
Write-Host "[dev-server] Stopped process on port 2024 (PID $($conn.OwningProcess))"
$stopped = $true
}
}
return $stopped
}
# --- command dispatch ---------------------------------------------------
switch ($Command) {
"start" {
if (Test-ServerRunning) {
$pid = Get-StoredPid
Write-Host "[dev-server] Already running (PID $pid) at $ApiUrl"
Write-Host "[dev-server] Studio: https://smith.langchain.com/studio/?baseUrl=$ApiUrl"
exit 0
}
$lgExe = Find-LangGraphExe
Write-Host "[dev-server] Starting langgraph dev server..."
Write-Host "[dev-server] exe: $lgExe"
Write-Host "[dev-server] dir: $SkillDir"
# Remove stale logs
Remove-Item $StdoutLog -ErrorAction SilentlyContinue
Remove-Item $StderrLog -ErrorAction SilentlyContinue
$proc = Start-Process -FilePath $lgExe `
-ArgumentList "dev", "--no-browser" `
-WorkingDirectory $SkillDir `
-RedirectStandardOutput $StdoutLog `
-RedirectStandardError $StderrLog `
-WindowStyle Hidden -PassThru
if (-not $proc) {
Write-Error "[dev-server] Failed to launch process"
exit 1
}
$proc.Id | Out-File -FilePath $PidFile -Encoding ascii -NoNewline
# Wait for server to respond
$elapsed = 0
while (-not (Test-HttpOk "$ApiUrl/ok") -and $elapsed -lt $MaxWait) {
Start-Sleep -Seconds 1
$elapsed++
if ($proc.HasExited) {
Write-Error "[dev-server] Process exited unexpectedly (code $($proc.ExitCode))"
Write-Host "--- stdout ($StdoutLog) ---"
if (Test-Path $StdoutLog) { Get-Content $StdoutLog -Tail 30 }
Write-Host "--- stderr ($StderrLog) ---"
if (Test-Path $StderrLog) { Get-Content $StderrLog -Tail 30 }
Remove-Item $PidFile -ErrorAction SilentlyContinue
exit 1
}
}
if ($elapsed -ge $MaxWait) {
Write-Error "[dev-server] Server did not become ready within ${MaxWait}s"
Write-Host "--- stdout ($StdoutLog) ---"
if (Test-Path $StdoutLog) { Get-Content $StdoutLog -Tail 30 }
Write-Host "--- stderr ($StderrLog) ---"
if (Test-Path $StderrLog) { Get-Content $StderrLog -Tail 30 }
exit 1
}
Write-Host "[dev-server] Ready in ${elapsed}s"
Write-Host "[dev-server] API: $ApiUrl"
Write-Host "[dev-server] Studio: https://smith.langchain.com/studio/?baseUrl=$ApiUrl"
Write-Host "[dev-server] Logs: $StdoutLog / $StderrLog"
}
"stop" {
$pid = Get-StoredPid
if ((Stop-ServerProcess -PidValue $pid)) {
Write-Host "[dev-server] Stopped (PID $pid)"
} else {
Write-Host "[dev-server] Not running (nothing to stop)"
}
Remove-Item $PidFile -ErrorAction SilentlyContinue
}
"status" {
if (Test-ServerRunning) {
$pid = Get-StoredPid
Write-Host "[dev-server] Running (PID $pid) at $ApiUrl"
Write-Host "[dev-server] Studio: https://smith.langchain.com/studio/?baseUrl=$ApiUrl"
} else {
Write-Host "[dev-server] Not running"
}
}
default {
Write-Error "Usage: manage_dev_server.ps1 {start|stop|status}"
exit 1
}
}
+241 -128
View File
@@ -1,3 +1,12 @@
"""run_workflow.py — submit workflow runs to the local langgraph dev server.
Instead of running the graph directly in-process, every invocation sends a
run to the langgraph dev server (http://127.0.0.1:2024) via the LangGraph
SDK sync client. This lets LangGraph Studio track execution in real time.
The Codex review command is forwarded in the run's config so the server does
not need to be restarted when the command changes between projects.
"""
from __future__ import annotations
import argparse
@@ -5,63 +14,68 @@ import base64
import json
import logging
import sys
import time
from pathlib import Path
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command
from agents import AgentRunner
from workflow import build_workflow, resolve_notes_dir, resolve_work_dir
SERVER_URL = "http://127.0.0.1:2024"
ASSISTANT_ID = "plan_review"
THREAD_MAP_FILENAME = "workflow_threads.json"
DB_FILENAME = "workflow_state.db"
# ── path helpers ──────────────────────────────────────────────────────────────
def _resolve_work_dir(value: str) -> Path:
return Path(value or ".").resolve()
def parse_command(value: str | None) -> list[str] | None:
if not value:
return None
try:
parsed = json.loads(value)
except json.JSONDecodeError:
# Fallback: the string may contain Windows paths with unescaped backslashes
# that make it invalid JSON. Re-escape backslashes and retry before giving up.
def _resolve_notes_dir(work_dir: Path, value: str) -> Path:
notes_dir = Path(value or "Notes")
if not notes_dir.is_absolute():
notes_dir = work_dir / notes_dir
return notes_dir.resolve()
# ── thread map (task_id → server thread_id) ──────────────────────────────────
def _load_thread_map(notes_dir: Path) -> dict:
path = notes_dir / THREAD_MAP_FILENAME
if path.exists():
try:
import re
# Replace bare backslashes (not already doubled) with \\
re_escaped = re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', value)
parsed = json.loads(re_escaped)
except json.JSONDecodeError:
stripped = value.strip()
if stripped.startswith("[") and stripped.endswith("]"):
# Last-resort comma split; strip surrounding quotes from each item.
parsed = [item.strip().strip('"').strip("'")
for item in stripped[1:-1].split(",")]
else:
raise
if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed):
raise argparse.ArgumentTypeError("command must be a JSON string array")
return parsed
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return {}
def parse_command_b64(value: str | None) -> list[str] | None:
if not value:
return None
try:
decoded = base64.b64decode(value).decode("utf-8")
except Exception as exc:
raise argparse.ArgumentTypeError(f"invalid base64 command: {exc}") from exc
return parse_command(decoded)
def _save_thread_map(notes_dir: Path, mapping: dict) -> None:
path = notes_dir / THREAD_MAP_FILENAME
path.write_text(json.dumps(mapping, indent=2, ensure_ascii=False), encoding="utf-8")
def parse_json_b64(value: str | None):
if not value:
return None
try:
decoded = base64.b64decode(value).decode("utf-8")
except Exception as exc:
raise argparse.ArgumentTypeError(f"invalid base64 JSON: {exc}") from exc
return json.loads(decoded)
# ── interrupt extraction ──────────────────────────────────────────────────────
def _extract_interrupt(state: dict):
"""Return the first interrupt value from a thread-state dict, or None."""
# Primary: tasks[].interrupts (langgraph-api >= 0.5)
for task in state.get("tasks", []):
for intr in task.get("interrupts", []):
if isinstance(intr, dict):
return intr.get("value", intr)
return intr
# Fallback: __interrupt__ channel value
interrupt_list = state.get("values", {}).get("__interrupt__", [])
if interrupt_list:
intr = interrupt_list[0]
return intr.value if hasattr(intr, "value") else intr
return None
def _is_interrupted(state: dict) -> bool:
"""Return True when the thread is paused at an interrupt."""
return bool(state.get("next")) and _extract_interrupt(state) is not None
# ── core workflow runner ──────────────────────────────────────────────────────
def run_workflow(
*,
@@ -69,113 +83,210 @@ def run_workflow(
user_prompt: str = "",
notes_dir: str = "Notes",
work_dir: str = "",
runner: AgentRunner,
codex_review_command: list[str],
thread_id: str | None = None,
resume_payload=None,
timeout: int = 600,
server_url: str = SERVER_URL,
) -> dict:
effective_thread = thread_id or task_id
resolved_work_dir = resolve_work_dir(work_dir)
resolved_notes_dir = resolve_notes_dir(resolved_work_dir, notes_dir)
try:
from langgraph_sdk import get_sync_client
except ImportError:
raise ImportError(
"langgraph_sdk not installed. Run: "
"conda run -n langgraph pip install langgraph-sdk"
)
import httpx
effective_task_id = thread_id or task_id
resolved_work_dir = _resolve_work_dir(work_dir)
resolved_notes_dir = _resolve_notes_dir(resolved_work_dir, notes_dir)
resolved_notes_dir.mkdir(parents=True, exist_ok=True)
db_path = resolved_notes_dir / DB_FILENAME
with SqliteSaver.from_conn_string(str(db_path)) as checkpointer:
app = build_workflow(runner, checkpointer=checkpointer)
config = {"configurable": {"thread_id": effective_thread}}
# ── verify server is up ───────────────────────────────────────────────────
try:
httpx.get(f"{server_url}/ok", timeout=5).raise_for_status()
except Exception as exc:
raise RuntimeError(
f"langgraph dev server not reachable at {server_url}.\n"
f"Start it first:\n"
f" PowerShell -File C:\\Users\\bowei\\.claude\\skills\\langgraph-plan-review\\scripts\\manage_dev_server.ps1 start\n"
f"Error: {exc}"
)
if resume_payload is not None:
result = app.invoke(Command(resume=resume_payload), config=config)
else:
result = app.invoke(
{
"task_id": task_id,
"user_prompt": user_prompt,
"notes_dir": str(resolved_notes_dir),
"work_dir": str(resolved_work_dir),
},
config=config,
client = get_sync_client(url=server_url)
# ── resolve server thread_id ──────────────────────────────────────────────
thread_map = _load_thread_map(resolved_notes_dir)
server_thread_id: str | None = thread_map.get(effective_task_id)
# Verify the stored thread still exists on the server (server may have restarted)
if server_thread_id:
try:
client.threads.get(server_thread_id)
except Exception:
logging.warning(
"Stored thread %s no longer exists on server (server restarted?); "
"creating a new thread.",
server_thread_id,
)
server_thread_id = None
interrupts = result.get("__interrupt__", [])
if interrupts:
intr = interrupts[0]
action_required = intr.value if hasattr(intr, "value") else intr
if not server_thread_id:
thread = client.threads.create(
metadata={"task_id": effective_task_id}
)
server_thread_id = thread["thread_id"]
thread_map[effective_task_id] = server_thread_id
_save_thread_map(resolved_notes_dir, thread_map)
# ── submit run ────────────────────────────────────────────────────────────
run_config = {
"configurable": {
"codex_review_command": codex_review_command,
"codex_timeout": timeout,
}
}
if resume_payload is not None:
run = client.runs.create(
server_thread_id,
ASSISTANT_ID,
command={"resume": resume_payload},
config=run_config,
)
else:
run = client.runs.create(
server_thread_id,
ASSISTANT_ID,
input={
"task_id": effective_task_id,
"user_prompt": user_prompt,
"notes_dir": str(resolved_notes_dir),
"work_dir": str(resolved_work_dir),
},
config=run_config,
)
run_id = run["run_id"]
logging.info("Submitted run %s on thread %s", run_id, server_thread_id)
# ── wait for completion or interrupt ──────────────────────────────────────
ACTIVE = {"pending", "running"}
deadline = time.monotonic() + timeout
run_status = run.get("status", "pending")
while run_status in ACTIVE and time.monotonic() < deadline:
time.sleep(1)
try:
final_run = client.runs.get(server_thread_id, run_id)
run_status = final_run.get("status", run_status)
except Exception as exc:
logging.debug("runs.get error (will retry): %s", exc)
if run_status in ACTIVE:
raise TimeoutError(
f"Run {run_id} did not complete within {timeout}s (status={run_status})"
)
# ── detect interrupt from thread state ────────────────────────────────────
state = client.threads.get_state(server_thread_id)
if _is_interrupted(state):
interrupt_value = _extract_interrupt(state)
return {
"status": "waiting",
"thread_id": effective_thread,
"db_path": str(db_path),
"action_required": action_required,
"thread_id": effective_task_id,
"server_thread_id": server_thread_id,
"action_required": interrupt_value,
}
if run_status not in ("success",):
error = final_run.get("error") or run_status
raise RuntimeError(f"Run ended with status '{run_status}': {error}")
# ── extract final state ───────────────────────────────────────────────────
values = state.get("values", {})
return {
"status": "done",
"thread_id": effective_thread,
"db_path": str(db_path),
"task_path": result.get("task_path", ""),
"latest_discuss_path": result.get("latest_discuss_path", ""),
"work_dir": result.get("work_dir", ""),
"codex_session_id": result.get("codex_session_id", ""),
"has_serious_issue": result.get("has_serious_issue", False),
"should_exit": result.get("should_exit", True),
"thread_id": effective_task_id,
"server_thread_id": server_thread_id,
"task_path": values.get("task_path", ""),
"latest_discuss_path": values.get("latest_discuss_path", ""),
"work_dir": values.get("work_dir", ""),
"codex_session_id": values.get("codex_session_id", ""),
"has_serious_issue": values.get("has_serious_issue", False),
"should_exit": values.get("should_exit", True),
}
# ── CLI ───────────────────────────────────────────────────────────────────────
def _parse_command(value: str | None) -> list[str] | None:
if not value:
return None
try:
parsed = json.loads(value)
except json.JSONDecodeError:
import re
try:
parsed = json.loads(re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', value))
except json.JSONDecodeError:
stripped = value.strip()
if stripped.startswith("[") and stripped.endswith("]"):
parsed = [i.strip().strip('"').strip("'") for i in stripped[1:-1].split(",")]
else:
raise
if not isinstance(parsed, list) or not all(isinstance(i, str) for i in parsed):
raise argparse.ArgumentTypeError("command must be a JSON string array")
return parsed
def _parse_b64(value: str | None) -> list[str] | None:
if not value:
return None
return _parse_command(base64.b64decode(value).decode("utf-8"))
def _parse_json_b64(value: str | None):
if not value:
return None
return json.loads(base64.b64decode(value).decode("utf-8"))
def main() -> int:
parser = argparse.ArgumentParser(
description="Run the LangGraph Claude/Codex review workflow."
)
parser.add_argument("--task-id", required=True, help="Task id, for example 02")
parser.add_argument("--prompt", default="", help="User request recorded for traceability")
parser.add_argument("--notes-dir", default="Notes", help="Directory for Task/discuss files")
parser.add_argument("--work-dir", default="", help="Claude Code launch working directory")
parser.add_argument(
"--codex-review-command",
type=parse_command,
default=None,
help="JSON argv for Codex review step.",
)
parser.add_argument(
"--codex-review-command-b64",
type=parse_command_b64,
default=None,
help="Base64-encoded JSON argv for Codex review step.",
description="Submit a plan-review workflow run to the local langgraph dev server."
)
parser.add_argument("--task-id", required=True)
parser.add_argument("--prompt", default="")
parser.add_argument("--notes-dir", default="Notes")
parser.add_argument("--work-dir", default="")
parser.add_argument("--codex-review-command", type=_parse_command, default=None)
parser.add_argument("--codex-review-command-b64", type=_parse_b64, default=None)
parser.add_argument("--timeout", type=int, default=600)
parser.add_argument(
"--thread-id",
default="",
help="Thread ID for resuming an existing run (defaults to task-id)",
)
parser.add_argument(
"--resume",
default="",
help=(
"JSON payload for Command(resume=...). "
"Use true for plan/revise/rejection steps; {\"accepts\":true} for decision step."
),
)
parser.add_argument(
"--resume-b64",
default="",
help="Base64-encoded JSON payload for Command(resume=...).",
)
parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging to stderr.")
parser.add_argument("--thread-id", default="")
parser.add_argument("--resume", default="")
parser.add_argument("--resume-b64", default="")
parser.add_argument("--server-url", default=SERVER_URL)
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
if args.verbose:
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr,
format="%(asctime)s %(levelname)s %(message)s")
codex_review_command = args.codex_review_command_b64 or args.codex_review_command
if not codex_review_command:
logging.basicConfig(
level=logging.DEBUG, stream=sys.stderr,
format="%(asctime)s %(levelname)s %(message)s",
)
codex_cmd = args.codex_review_command_b64 or args.codex_review_command
if not codex_cmd:
parser.error("--codex-review-command or --codex-review-command-b64 is required")
runner = AgentRunner(
codex_review_command=codex_review_command,
timeout=args.timeout,
)
# resume=True tells LangGraph to resume from the last interrupt without a specific value.
# Callers should pass 'true' (JSON boolean) for plan/revise/rejection steps.
resume_payload = parse_json_b64(args.resume_b64) if args.resume_b64 else (
json.loads(args.resume) if args.resume else None
resume_payload = (
_parse_json_b64(args.resume_b64) if args.resume_b64
else (json.loads(args.resume) if args.resume else None)
)
output = run_workflow(
@@ -183,9 +294,11 @@ def main() -> int:
user_prompt=args.prompt,
notes_dir=args.notes_dir,
work_dir=args.work_dir,
runner=runner,
codex_review_command=codex_cmd,
thread_id=args.thread_id or None,
resume_payload=resume_payload,
timeout=args.timeout,
server_url=args.server_url,
)
print(json.dumps(output, ensure_ascii=False, indent=2))
+21 -2
View File
@@ -1,15 +1,30 @@
from __future__ import annotations
import sys
from pathlib import Path
import re
from typing import TypedDict
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, START, StateGraph
from langgraph.types import interrupt
from agents import AgentRunner
def _default_review_cmd() -> list[str]:
"""Return the real review_task.py command using the current interpreter."""
review_py = Path(__file__).parent / "review_task.py"
return [
sys.executable,
str(review_py),
"$task_path",
"$latest_discuss_path",
"$codex_session_id",
"$work_dir",
]
class WorkflowState(TypedDict):
task_id: str
user_prompt: str
@@ -129,7 +144,7 @@ def values_from_state(state: WorkflowState) -> dict[str, str]:
}
def build_workflow(runner: AgentRunner, *, checkpointer=None):
def build_workflow(*, checkpointer=None):
graph = StateGraph(WorkflowState)
def initialize(state: WorkflowState) -> WorkflowState:
@@ -163,7 +178,11 @@ def build_workflow(runner: AgentRunner, *, checkpointer=None):
validate_task_file(state["task_path"])
return state
def review(state: WorkflowState) -> WorkflowState:
def review(state: WorkflowState, config: RunnableConfig) -> WorkflowState:
cfg = config.get("configurable", {})
codex_cmd: list[str] = cfg.get("codex_review_command") or _default_review_cmd()
timeout: int = int(cfg.get("codex_timeout", 600))
runner = AgentRunner(codex_review_command=codex_cmd, timeout=timeout)
result = runner.review(values_from_state(state))
updates = {
**state,