mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 17:27:37 +08:00
The audit of v4.1 surfaced ~70 issues across the five scripts and three
reference docs — most user-visible (silent file overwrites, status-error
misclassified as success, X-API-Key leaked to S3 on /api/view redirect,
Cloud endpoints that 404 because they were renamed). v5.0.0 fixes those
and fills the gaps that previously forced users to write their own glue
(WebSocket monitoring, batch/sweep, img2img upload helper, dep auto-fix,
log fetch, health check, example workflows).
Critical fixes
- run_workflow.py: poll_status now checks status_str==error BEFORE
completed:true, so a failed run no longer reports success
- run_workflow.py: download_output streams to disk via safe_path_join,
preserves server subfolder structure (no silent overwrites), and
retries with exponential backoff
- run_workflow.py: refuses to overwrite a link with a literal in
inject_params (would silently break wiring)
- _common.py: _StripSensitiveOnRedirectSession (subclasses
requests.Session.rebuild_auth) drops X-API-Key/Cookie on cross-host
redirects — fixes a real key-leak path through Cloud's signed-URL
download flow. Tested
- Cloud routing (verified live): /history → /history_v2,
/models/<f> → /experiment/models/<f>, plus folder aliases for the
unet ↔ diffusion_models and clip ↔ text_encoders rename
- check_deps.py: distinguishes 200/empty vs 404 folder_not_found vs
403 free-tier; emits concrete fix_command per missing dep
- extract_schema.py: prompt vs negative_prompt determined by tracing
KSampler.{positive,negative} connections (incl. through Reroute /
Primitive nodes) instead of meta-title heuristic; symmetric
duplicate-name resolution; cycle-safe trace_to_node
- hardware_check.py: multi-GPU pick-best, Apple variant detection,
Rosetta detection, WSL2, ROCm --json, disk-space check, optional
PyTorch probe; powershell preferred over deprecated wmic
- comfyui_setup.sh: prefers pipx → uvx → pip --user (with PEP-668
fallback); idempotent — skips relaunch if server already up;
configurable port/workspace; persistent log; SIGINT trap
New scripts
- run_batch.py — count or sweep (cartesian product), parallel up to
cloud tier limit
- ws_monitor.py — real-time WebSocket viewer; saves preview frames
- auto_fix_deps.py — runs comfy node install / model download for
whatever check_deps reports missing (with --dry-run)
- health_check.py — single command that runs the verification checklist
(comfy-cli + server + checkpoints + optional smoke test that cancels
itself to avoid burning compute)
- fetch_logs.py — pull traceback / status messages for a prompt_id
Coverage expansion
- Param patterns now cover Flux (BasicScheduler, BasicGuider,
RandomNoise, ModelSamplingFlux), SD3, Wan/Hunyuan/LTX video,
IPAdapter, rgthree, easy-use, AnimateDiff
- Embedding refs in CLIPTextEncode strings extracted as model deps
- ckpt_name / vae_name / lora_name / unet_name now controllable so
workflows can be retargeted per run
Examples
- workflows/{sd15,sdxl,flux_dev}_txt2img.json
- workflows/sdxl_{img2img,inpaint}.json
- workflows/upscale_4x.json
- workflows/{animatediff_video,wan_video_t2v}.json + README
Tests
- 117 tests (105 unit + 8 cloud integration + 4 cross-host security)
- Cloud tests auto-skip without COMFY_CLOUD_API_KEY; verified end-to-end
against live cloud API
Backwards compatibility
- All existing CLI flags continue to work; new behavior is opt-in
(--ws, --input-image, --randomize-seed, --flat-output, etc.)
186 lines
7.8 KiB
Python
186 lines
7.8 KiB
Python
"""Tests for extract_schema.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from extract_schema import (
|
|
extract_schema,
|
|
find_negative_prompt_node,
|
|
find_positive_prompt_node,
|
|
trace_to_node,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Connection tracing
|
|
# =============================================================================
|
|
|
|
class TestConnectionTracing:
|
|
def test_direct_link(self):
|
|
wf = {
|
|
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}},
|
|
"2": {"class_type": "KSampler",
|
|
"inputs": {"positive": ["1", 0], "negative": ["1", 0]}},
|
|
}
|
|
assert trace_to_node(wf, ["1", 0]) == "1"
|
|
|
|
def test_through_reroute(self):
|
|
wf = {
|
|
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}},
|
|
"2": {"class_type": "Reroute", "inputs": {"input": ["1", 0]}},
|
|
"3": {"class_type": "Reroute", "inputs": {"input": ["2", 0]}},
|
|
}
|
|
assert trace_to_node(wf, ["3", 0]) == "1"
|
|
|
|
def test_circular_safe(self):
|
|
wf = {
|
|
"1": {"class_type": "Reroute", "inputs": {"input": ["2", 0]}},
|
|
"2": {"class_type": "Reroute", "inputs": {"input": ["1", 0]}},
|
|
}
|
|
# Should hit max_hops without infinite loop
|
|
result = trace_to_node(wf, ["1", 0], max_hops=5)
|
|
assert result in ("1", "2") # any node, just don't hang
|
|
|
|
|
|
class TestPositiveNegativeDetection:
|
|
def test_basic(self, sd15_workflow):
|
|
# In sd15_workflow.json node 6 is positive, node 7 is negative
|
|
assert find_positive_prompt_node(sd15_workflow) == "6"
|
|
assert find_negative_prompt_node(sd15_workflow) == "7"
|
|
|
|
def test_swapped_order(self):
|
|
wf = {
|
|
"3": {"class_type": "KSampler",
|
|
"inputs": {
|
|
"positive": ["7", 0], "negative": ["6", 0],
|
|
"model": ["4", 0], "latent_image": ["5", 0],
|
|
"seed": 1, "steps": 20, "cfg": 7.5,
|
|
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
|
}},
|
|
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
|
|
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}},
|
|
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": "ugly", "clip": ["4", 1]}},
|
|
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "beautiful", "clip": ["4", 1]}},
|
|
}
|
|
# Now 7 is the positive (despite higher node ID)
|
|
assert find_positive_prompt_node(wf) == "7"
|
|
assert find_negative_prompt_node(wf) == "6"
|
|
|
|
|
|
# =============================================================================
|
|
# Schema extraction
|
|
# =============================================================================
|
|
|
|
class TestExtractSchema:
|
|
def test_basic_sd15(self, sd15_workflow):
|
|
schema = extract_schema(sd15_workflow)
|
|
params = schema["parameters"]
|
|
assert "prompt" in params
|
|
assert "negative_prompt" in params
|
|
assert "seed" in params
|
|
assert "steps" in params
|
|
assert "cfg" in params
|
|
assert "width" in params
|
|
assert "height" in params
|
|
|
|
def test_prompt_value_correct(self, sd15_workflow):
|
|
schema = extract_schema(sd15_workflow)
|
|
# The positive prompt in the example is the landscape one
|
|
assert "landscape" in schema["parameters"]["prompt"]["value"]
|
|
assert "ugly" in schema["parameters"]["negative_prompt"]["value"]
|
|
|
|
def test_model_dependencies(self, sd15_workflow):
|
|
schema = extract_schema(sd15_workflow)
|
|
deps = schema["model_dependencies"]
|
|
ckpts = [d["value"] for d in deps if d["folder"] == "checkpoints"]
|
|
assert "v1-5-pruned-emaonly.safetensors" in ckpts
|
|
|
|
def test_output_nodes(self, sd15_workflow):
|
|
schema = extract_schema(sd15_workflow)
|
|
assert "9" in schema["output_nodes"]
|
|
|
|
def test_summary(self, sd15_workflow):
|
|
schema = extract_schema(sd15_workflow)
|
|
s = schema["summary"]
|
|
assert s["has_negative_prompt"] is True
|
|
assert s["has_seed"] is True
|
|
assert s["is_video_workflow"] is False
|
|
assert s["parameter_count"] > 5
|
|
|
|
def test_flux_workflow(self, flux_workflow):
|
|
schema = extract_schema(flux_workflow)
|
|
# Flux uses RandomNoise for seed
|
|
assert schema["summary"]["has_seed"] is True
|
|
# Flux has only positive prompt (no negative encoder)
|
|
assert schema["summary"]["has_negative_prompt"] is False
|
|
|
|
def test_video_detected(self, video_workflow):
|
|
schema = extract_schema(video_workflow)
|
|
assert schema["summary"]["is_video_workflow"] is True
|
|
|
|
|
|
class TestEmbeddingDeps:
|
|
def test_extract_from_prompt(self):
|
|
wf = {
|
|
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
|
|
"5": {"class_type": "EmptyLatentImage",
|
|
"inputs": {"width": 512, "height": 512, "batch_size": 1}},
|
|
"6": {"class_type": "CLIPTextEncode",
|
|
"inputs": {
|
|
"text": "a cat, embedding:goodvibes, embedding:art:1.2",
|
|
"clip": ["1", 1]
|
|
}},
|
|
"7": {"class_type": "CLIPTextEncode",
|
|
"inputs": {
|
|
"text": "ugly, embedding:badhands",
|
|
"clip": ["1", 1]
|
|
}},
|
|
"3": {"class_type": "KSampler",
|
|
"inputs": {
|
|
"positive": ["6", 0], "negative": ["7", 0],
|
|
"model": ["1", 0], "latent_image": ["5", 0],
|
|
"seed": 1, "steps": 20, "cfg": 7.5,
|
|
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
|
}},
|
|
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x", "images": ["3", 0]}},
|
|
}
|
|
schema = extract_schema(wf)
|
|
names = [d["embedding_name"] for d in schema["embedding_dependencies"]]
|
|
assert sorted(names) == ["art", "badhands", "goodvibes"]
|
|
|
|
|
|
class TestDuplicateDeduplication:
|
|
def test_two_ksamplers_get_unique_names(self):
|
|
wf = {
|
|
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "x"}},
|
|
"5": {"class_type": "EmptyLatentImage",
|
|
"inputs": {"width": 512, "height": 512, "batch_size": 1}},
|
|
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": "a", "clip": ["1", 1]}},
|
|
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "b", "clip": ["1", 1]}},
|
|
"3": {"class_type": "KSampler",
|
|
"inputs": {
|
|
"positive": ["6", 0], "negative": ["7", 0],
|
|
"model": ["1", 0], "latent_image": ["5", 0],
|
|
"seed": 42, "steps": 20, "cfg": 7.5,
|
|
"sampler_name": "euler", "scheduler": "normal", "denoise": 1.0,
|
|
}},
|
|
"4": {"class_type": "KSampler",
|
|
"inputs": {
|
|
"positive": ["6", 0], "negative": ["7", 0],
|
|
"model": ["1", 0], "latent_image": ["5", 0],
|
|
"seed": 99, "steps": 30, "cfg": 8.0,
|
|
"sampler_name": "euler", "scheduler": "normal", "denoise": 0.6,
|
|
}},
|
|
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "x", "images": ["3", 0]}},
|
|
}
|
|
schema = extract_schema(wf)
|
|
params = schema["parameters"]
|
|
# Both seeds present with disambiguated names
|
|
seed_keys = [k for k in params if "seed" in k]
|
|
# Symmetric: both renamed (no bare "seed")
|
|
assert "seed" not in params
|
|
assert "seed_3" in params and "seed_4" in params
|
|
assert params["seed_3"]["value"] == 42
|
|
assert params["seed_4"]["value"] == 99
|