更新了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
+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,