Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,12 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_list.add_argument("--archived", action="store_true",
help="Include archived tasks")
p_list.add_argument("--json", action="store_true")
p_list.add_argument(
"--sort",
default=None,
choices=sorted(kb.VALID_SORT_ORDERS.keys()),
help="Sort order for listed tasks (default: priority)",
)

# --- show ---
p_show = sub.add_parser("show", help="Show a task with comments + events")
Expand Down Expand Up @@ -1112,6 +1118,7 @@ def _cmd_list(args: argparse.Namespace) -> int:
status=args.status,
tenant=args.tenant,
include_archived=args.archived,
order_by=getattr(args, "sort", None),
)
if getattr(args, "json", False):
print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False))
Expand Down
25 changes: 24 additions & 1 deletion hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,20 @@ def get_task(conn: sqlite3.Connection, task_id: str) -> Optional[Task]:
return Task.from_row(row) if row else None


# Canonical sort-order mappings for ``hermes kanban list --sort``.
# Each value is a raw SQL fragment appended after ``ORDER BY``.
VALID_SORT_ORDERS: dict[str, str] = {
"created": "created_at ASC, id ASC",
"created-desc": "created_at DESC, id DESC",
"priority": "priority DESC, created_at ASC",
"priority-desc": "priority ASC, created_at ASC",
"status": "status ASC, created_at ASC",
"assignee": "assignee ASC, created_at ASC",
"title": "title ASC, id ASC",
"updated": "started_at DESC NULLS LAST, created_at DESC",
}


def list_tasks(
conn: sqlite3.Connection,
*,
Expand All @@ -1450,6 +1464,7 @@ def list_tasks(
tenant: Optional[str] = None,
include_archived: bool = False,
limit: Optional[int] = None,
order_by: Optional[str] = None,
) -> list[Task]:
query = "SELECT * FROM tasks WHERE 1=1"
params: list[Any] = []
Expand All @@ -1466,7 +1481,15 @@ def list_tasks(
params.append(tenant)
if not include_archived and status != "archived":
query += " AND status != 'archived'"
query += " ORDER BY priority DESC, created_at ASC"
if order_by is not None:
order_by = order_by.strip().lower()
if order_by not in VALID_SORT_ORDERS:
raise ValueError(
f"order_by must be one of {sorted(VALID_SORT_ORDERS.keys())}"
)
query += f" ORDER BY {VALID_SORT_ORDERS[order_by]}"
else:
query += " ORDER BY priority DESC, created_at ASC"
if limit:
query += f" LIMIT {int(limit)}"
rows = conn.execute(query, params).fetchall()
Expand Down
33 changes: 33 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,39 @@ def test_archive_hides_from_default_list(kanban_home):
assert len(kb.list_tasks(conn, include_archived=True)) == 1


def test_list_tasks_order_by(kanban_home):
with kb.connect() as conn:
# Create tasks with different titles and priorities
t_a = kb.create_task(conn, title="alpha", priority=1)
t_b = kb.create_task(conn, title="beta", priority=2)
t_c = kb.create_task(conn, title="gamma", priority=1)

# Default sort: priority DESC, created ASC
default = kb.list_tasks(conn)
assert [t.id for t in default] == [t_b, t_a, t_c]

# Sort by title ASC
by_title = kb.list_tasks(conn, order_by="title")
assert [t.id for t in by_title] == [t_a, t_b, t_c]

# Sort by assignee
kb.assign_task(conn, t_a, "alice")
kb.assign_task(conn, t_b, "bob")
kb.assign_task(conn, t_c, "alice")
by_assignee = kb.list_tasks(conn, order_by="assignee")
# alice's tasks first (alphabetically), then bob's
assignees = [t.assignee for t in by_assignee]
assert assignees[:2] == ["alice", "alice"]
assert assignees[2] == "bob"

# Invalid sort order raises ValueError
try:
kb.list_tasks(conn, order_by="bogus")
assert False, "Should have raised ValueError"
except ValueError as e:
assert "order_by must be one of" in str(e)


# ---------------------------------------------------------------------------
# Comments / events / worker context
# ---------------------------------------------------------------------------
Expand Down
50 changes: 49 additions & 1 deletion tools/image_generation_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ def _load_fal_client() -> Any:
},
"supports": {
"prompt", "image_size", "quality", "num_images", "output_format",
"sync_mode",
"sync_mode", "image",
# openai_api_key (BYOK) intentionally omitted — all users go
# through the shared FAL billing path.
},
Expand Down Expand Up @@ -661,6 +661,7 @@ def image_generate_tool(
num_images: Optional[int] = None,
output_format: Optional[str] = None,
seed: Optional[int] = None,
reference_image_path: Optional[str] = None,
) -> str:
"""Generate an image from a text prompt using the configured FAL model.

Expand All @@ -669,6 +670,11 @@ def image_generate_tool(
per-model via the ``supports`` whitelist (unsupported overrides are
silently dropped so legacy callers don't break when switching models).

Args:
reference_image_path: Optional absolute path to a local reference image
file. When provided, supported backends (e.g., GPT Image 2) will use
it for style transfer, subject likeness, or composition guidance.

Returns a JSON string with ``{"success": bool, "image": url | None,
"error": str, "error_type": str}``.
"""
Expand All @@ -684,6 +690,7 @@ def image_generate_tool(
"num_images": num_images,
"output_format": output_format,
"seed": seed,
"reference_image_path": reference_image_path,
},
"error": None,
"success": False,
Expand All @@ -697,6 +704,23 @@ def image_generate_tool(
if not prompt or not isinstance(prompt, str) or len(prompt.strip()) == 0:
raise ValueError("Prompt is required and must be a non-empty string")

# Validate reference image path if provided
if reference_image_path:
if not isinstance(reference_image_path, str):
raise ValueError("reference_image_path must be a string")
ref_path = reference_image_path.strip()
if not ref_path:
raise ValueError("reference_image_path must be a non-empty string")
if not os.path.isfile(ref_path):
raise ValueError(f"Reference image file not found: {ref_path}")
# Validate it's an image file by extension
valid_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff'}
ext = os.path.splitext(ref_path)[1].lower()
if ext not in valid_extensions:
raise ValueError(f"Reference image must be a valid image file. Supported: {', '.join(sorted(valid_extensions))}")
else:
ref_path = None

if not (fal_key_is_configured() or _resolve_managed_fal_gateway()):
message = "FAL_KEY environment variable not set"
if managed_nous_tools_enabled():
Expand Down Expand Up @@ -725,6 +749,24 @@ def image_generate_tool(
model_id, prompt, aspect_lc, seed=seed, overrides=overrides,
)

# Add reference image if provided and supported by the model
if ref_path:
# Check if the model supports image input
model_supports_image = "image" in meta.get("supports", set()) or \
"reference_image" in meta.get("supports", set()) or \
"input_image" in meta.get("supports", set())

if model_supports_image:
# For models that support image input, add the reference image
# FAL accepts either a URL or a local file path (it will upload automatically)
arguments["image"] = ref_path
logger.info("Using reference image: %s", ref_path)
else:
logger.warning(
"Model %s does not support reference images; ignoring reference_image_path",
model_id,
)

logger.info(
"Generating image with %s (%s) — prompt: %s",
meta.get("display", model_id), model_id, prompt[:80],
Expand Down Expand Up @@ -915,6 +957,10 @@ def check_image_generation_requirements() -> bool:
"description": "The aspect ratio of the generated image. 'landscape' is 16:9 wide, 'portrait' is 16:9 tall, 'square' is 1:1.",
"default": DEFAULT_ASPECT_RATIO,
},
"reference_image_path": {
"type": "string",
"description": "Optional absolute path to a local reference image file. When provided, the image generation API will use it for style transfer, subject likeness, or composition guidance. Only supported by certain backends (e.g., GPT Image 2).",
},
},
"required": ["prompt"],
},
Expand Down Expand Up @@ -1040,6 +1086,7 @@ def _handle_image_generate(args, **kw):
if not prompt:
return tool_error("prompt is required for image generation")
aspect_ratio = args.get("aspect_ratio", DEFAULT_ASPECT_RATIO)
reference_image_path = args.get("reference_image_path")

# Route to a plugin-registered provider if one is active (and it's
# not the in-tree FAL path).
Expand All @@ -1050,6 +1097,7 @@ def _handle_image_generate(args, **kw):
return image_generate_tool(
prompt=prompt,
aspect_ratio=aspect_ratio,
reference_image_path=reference_image_path,
)


Expand Down