Files
hermes-agent/hermes_cli/web_models.py
Teknium 39ed610f8c feat(cron): create paused jobs without a scheduling race
Persist paused state, timestamp, reason and no first trigger in the original
locked creation write. Forward the same boolean contract across CLI, tool,
gateway API and dashboard API, validating at the store boundary. Preserve
explicit operator force-run behavior and normal enabled creation.

The live CLI probe also caught the command shim dropping failure return codes;
forward them so invalid creation reports exit 1 rather than success.

Credit earlier atomic-creation work in #78935 and #94952 and the focused
implementation in #104578. The broader manifest staging layer is not imported.

Co-authored-by: Konstantin Khlopkov <konstantin.khlopkov93@gmail.com>
Co-authored-by: Chloé DuPont <321112755+misschloedupont@users.noreply.github.com>
2026-09-07 08:27:50 -07:00

507 lines
15 KiB
Python

"""Pydantic request/response models for the Hermes dashboard web server."""
from __future__ import annotations
import math
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, SecretStr, StrictBool, field_validator
class ConfigUpdate(BaseModel):
config: dict
profile: Optional[str] = None
class EnvVarUpdate(BaseModel):
key: str
value: str
profile: Optional[str] = None
# Bearer for the OPENAI_BASE_URL connectivity probe (auth-gated /v1/models otherwise looks
# "reachable but empty"); ignored by plain PUT /api/env.
api_key: str = ""
class EnvVarDelete(BaseModel):
key: str
profile: Optional[str] = None
class EnvVarReveal(EnvVarDelete):
pass
class MemoryProviderConfigUpdate(BaseModel):
values: Dict[str, Any] = {}
class MemoryProviderSetupRequest(BaseModel):
values: Dict[str, Any] = {}
class CustomEndpointUpdate(BaseModel):
id: str = ""
name: str
base_url: str
model: str
api_key: Optional[str] = None
context_length: Optional[int] = None
discover_models: bool = True
make_default: bool = False
models: Optional[List[str]] = None
class MessagingPlatformUpdate(BaseModel):
enabled: Optional[bool] = None
env: Dict[str, str] = {}
clear_env: List[str] = []
# Explicit body profile beats the switcher's query param (same as other scoped writes).
profile: Optional[str] = None
class TelegramOnboardingStart(BaseModel):
bot_name: Optional[str] = None
class TelegramOnboardingApply(BaseModel):
allowed_user_ids: List[str]
profile: Optional[str] = None
class WhatsAppOnboardingStart(BaseModel):
mode: Optional[str] = "bot"
allowed_users: Optional[str] = ""
profile: Optional[str] = None
class WhatsAppOnboardingApply(BaseModel):
mode: Optional[str] = None
allowed_users: Optional[str] = None
profile: Optional[str] = None
class AudioTranscriptionRequest(BaseModel):
data_url: str
mime_type: Optional[str] = None
class ManagedFileUpload(BaseModel):
path: str
data_url: str
overwrite: bool = True
class ChatImageUpload(BaseModel):
data_url: str
filename: Optional[str] = None
class ManagedDirectoryCreate(BaseModel):
path: str
class ManagedFileDelete(BaseModel):
path: str
recursive: bool = False
class ModelAssignment(BaseModel):
"""POST /api/model/set — assign a provider/model to a slot.
scope="main" → model.provider + model.default; scope="auxiliary" → auxiliary.<task>.*
(task="" = every auxiliary slot, task="__reset__" = reset every slot to provider="auto").
"""
scope: str
provider: str
model: str
task: str = ""
# Custom/local endpoint URL + key, honored on main AND auxiliary slots: the runtime resolvers
# read model.base_url / auxiliary.<task>.base_url (+ .api_key) and ignore OPENAI_BASE_URL.
base_url: str = ""
api_key: str = ""
confirm_expensive_model: bool = False
profile: Optional[str] = None
class MoaModelSlot(BaseModel):
provider: str = ""
model: str = ""
# Declared so a GET round-trip doesn't strip and wipe it.
reasoning_effort: Optional[str] = None
enabled: bool = True
class _MoaReferenceControls(BaseModel):
# None = no per-preset override; inherits auxiliary.moa_reference.timeout (900s default).
reference_timeout: Optional[float] = None
degraded_reference_policy: Literal["loud", "silent"] = "loud"
@field_validator("reference_timeout", mode="before")
@classmethod
def _validate_reference_timeout(cls, value: Any) -> Optional[float]:
"""Reject JSON booleans/non-finite values before float coercion."""
if value is None or value == "":
return None
try:
timeout = float(value) if not isinstance(value, bool) else math.nan
except (TypeError, ValueError) as exc:
raise ValueError("reference_timeout must be a finite positive number") from exc
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("reference_timeout must be a finite positive number")
return timeout
class MoaPresetPayload(_MoaReferenceControls):
reference_models: list[MoaModelSlot] = []
aggregator: MoaModelSlot = MoaModelSlot()
# None = temperature omitted from API calls (provider default), as for single-model agents.
reference_temperature: Optional[float] = None
aggregator_temperature: Optional[float] = None
# Newer per-preset knobs (moa_config._normalize_preset): optional for older clients,
# declared so GET round-trips don't erase them.
fanout: Optional[str] = None
enabled: bool = True
class MoaConfigPayload(_MoaReferenceControls):
default_preset: str = "default"
active_preset: str = ""
presets: dict[str, MoaPresetPayload] = {}
# Backward-compatible flat payload fields for older dashboard/desktop clients.
reference_models: list[MoaModelSlot] = []
aggregator: MoaModelSlot = MoaModelSlot()
reference_temperature: Optional[float] = None
aggregator_temperature: Optional[float] = None
fanout: Optional[str] = None
enabled: bool = True
profile: Optional[str] = None
class FsWriteText(BaseModel):
path: str
content: str
class GitPathBody(BaseModel):
path: str
class GitFileBody(BaseModel):
path: str
file: Optional[str] = None
class GitPrListBody(BaseModel):
path: str
branches: List[str] = []
# PRs a session recovered from its transcript — known by number, not branch.
numbers: List[int] = []
class SessionPrScanBody(BaseModel):
ids: List[str] = []
class GitCommitBody(BaseModel):
path: str
message: str
push: bool = False
class GitWorktreeAddBody(BaseModel):
path: str
name: Optional[str] = None
branch: Optional[str] = None
base: Optional[str] = None
existingBranch: Optional[str] = None
class GitWorktreeRemoveBody(BaseModel):
path: str
worktreePath: str
force: bool = False
class GitBranchSwitchBody(BaseModel):
path: str
branch: str
class CuratorPause(BaseModel):
paused: bool
class LearningNodeRef(BaseModel):
id: str
profile: Optional[str] = None
class LearningNodeEdit(BaseModel):
id: str
content: str
profile: Optional[str] = None
class DebugShareRequest(BaseModel):
# Redaction scrubs credential-shaped tokens before logs leave the machine; opt-out only.
redact: bool = True
lines: int = 200 # recent log lines in the summary tail (full logs are separate)
class TTSSpeakRequest(BaseModel):
text: str
class TTSLeaseRequest(BaseModel):
"""POST /api/audio/tts-lease: ``lease`` names the toggle/surface holding the lease
(``desktop:read-aloud``, ``desktop:conversation``); ``active`` True acquires + warms, False releases."""
lease: str
active: bool = True
class OAuthSubmitBody(BaseModel):
session_id: str
code: str
class BulkDeleteSessions(BaseModel):
ids: List[str]
profile: Optional[str] = None
class SessionImport(BaseModel):
sessions: List[Dict[str, Any]]
profile: Optional[str] = None
class SessionRename(BaseModel):
title: Optional[str] = None
archived: Optional[bool] = None
hidden: Optional[bool] = None # also used by cross-profile reconciliation
pinned: Optional[bool] = None # durable "keep" (Desktop pins); exempt from auto_archive
# Read-state watermark (sessions.last_read_at): True = unread, False = read now, None = leave.
unread: Optional[bool] = None
profile: Optional[str] = None # session owned by another profile (opens its state.db)
class SessionOwnerBackfill(BaseModel):
"""POST /api/sessions/owner-backfill (legacy migration). ``profile`` scopes WHICH state.db is
stamped; the stamped value is always that store's own serving-profile identity — the caller
cannot inject an arbitrary owner."""
profile: Optional[str] = None
class SessionPrune(BaseModel):
older_than_days: Optional[float] = 90
source: Optional[str] = None
profile: Optional[str] = None
# Extended filters (all optional, ANDed — mirrors the CLI flags); *_before/after = epoch s
started_before: Optional[float] = None
started_after: Optional[float] = None
title_like: Optional[str] = None
end_reason: Optional[str] = None
cwd_prefix: Optional[str] = None
min_messages: Optional[int] = None
max_messages: Optional[int] = None
model_like: Optional[str] = None
provider: Optional[str] = None
user_id: Optional[str] = None
chat_id: Optional[str] = None
chat_type: Optional[str] = None
branch_like: Optional[str] = None
min_tokens: Optional[int] = None
max_tokens: Optional[int] = None
min_cost: Optional[float] = None
max_cost: Optional[float] = None
min_tool_calls: Optional[int] = None
max_tool_calls: Optional[int] = None
include_archived: bool = False
dry_run: bool = False
class CronJobCreate(BaseModel):
paused: StrictBool = False
paused_reason: Optional[str] = None
prompt: str = ""
schedule: str
name: str = ""
deliver: str = "local"
skills: Optional[List[str]] = None
model: Optional[str] = None
provider: Optional[str] = None
base_url: Optional[str] = None
script: Optional[str] = None
context_from: Optional[Any] = None
enabled_toolsets: Optional[List[str]] = None
workdir: Optional[str] = None
no_agent: bool = False
class CronJobUpdate(BaseModel):
updates: dict
class AutomationBlueprintInstantiate(BaseModel):
blueprint: str # blueprint key, e.g. "morning-brief"
values: Dict[str, Any] = {} # filled slot values from the form
class MCPServerCreate(BaseModel):
name: str
url: Optional[str] = None
command: Optional[str] = None
args: List[str] = []
env: Dict[str, str] = {} # KEY=VALUE for stdio servers (API keys, etc.)
auth: Optional[str] = None # "none" | "oauth" | "header" | None
# One-time provisioning input; persisted only to the profile's .env.
bearer_token: Optional[SecretStr] = None
profile: Optional[str] = None
class MCPServersReplace(BaseModel):
# Whole-map replace (name → raw config) for the GUI mcp.json editor.
servers: Dict[str, Dict[str, Any]] = {}
profile: Optional[str] = None
class MCPEnabledToggle(BaseModel):
enabled: bool
profile: Optional[str] = None
class MCPCatalogInstall(BaseModel):
name: str
env: Dict[str, str] = {} # KEY=VALUE for entries declaring required env vars
enable: bool = True
profile: Optional[str] = None
class PairingApprove(BaseModel):
platform: str
code: str = ""
request_id: str = ""
profile: Optional[str] = None
class PairingRevoke(BaseModel):
platform: str
user_id: str
profile: Optional[str] = None
class WebhookCreate(BaseModel):
name: str
description: Optional[str] = None
events: List[str] = []
prompt: Optional[str] = None
script: Optional[str] = None
skills: List[str] = []
deliver: str = "log"
deliver_only: bool = False
deliver_chat_id: Optional[str] = None
secret: Optional[str] = None # omit to auto-generate
class WebhookEnabledToggle(BaseModel):
enabled: bool
class CredentialPoolAdd(BaseModel):
provider: str
api_key: str # OAuth pooling stays CLI-only (needs an interactive browser flow)
label: Optional[str] = None
class MemoryProviderSelect(BaseModel):
provider: str # "" or "built-in" disables the external provider
class MemoryReset(BaseModel):
target: str = "all" # "all" | "memory" | "user"
class BackupRequest(BaseModel):
output: Optional[str] = None # defaults to a timestamped zip in the home dir
class ImportRequest(BaseModel):
archive: str
# --force: the spawned `hermes import` has stdin=DEVNULL, so its "Continue? [y/N]" prompt would
# hit EOF and abort; the dashboard confirms in its own modal.
force: bool = False
class HookCreate(BaseModel):
event: str
command: str
matcher: Optional[str] = None
timeout: Optional[int] = None
# Also write the consent allowlist entry; without it the hook won't fire until approved.
approve: bool = True
class HookDelete(BaseModel):
event: str
command: str
class SkillInstallRequest(BaseModel):
identifier: str
profile: Optional[str] = None
class SkillUninstallRequest(BaseModel):
name: str
profile: Optional[str] = None
class SkillsUpdateRequest(BaseModel):
profile: Optional[str] = None
class ProfileCreate(BaseModel):
name: str
clone_from: Optional[str] = None
clone_from_default: bool = False # legacy clients; new ones send clone_from explicitly
clone_all: bool = False
no_skills: bool = False
description: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
# Profile-builder additions, applied best-effort AFTER the profile dir exists (a hiccup never 500s).
mcp_servers: List["MCPServerCreate"] = []
keep_skills: List[str] = [] # skills to KEEP: non-empty = replace semantics (unlisted seeded ones disabled)
# Installed async via `hermes -p <name> skills install` (skills_hub.SKILLS_DIR is import-time-bound,
# so HERMES_HOME can't redirect it); PIDs go back for the UI to poll.
hub_skills: List[str] = []
class ProfileRename(BaseModel):
new_name: str
class ProfileExport(BaseModel):
extra_files: Dict[str, str] = {} # extra root-level files, filename → text
output: str = "" # archive path; empty → a staging path under HERMES_HOME
class ProfileImport(BaseModel):
archive: str # profile .tar.gz on the backend's filesystem
name: Optional[str] = None # overrides the name inferred from the archive root
class ProfileSoulUpdate(BaseModel):
content: str
class ProfileActiveUpdate(BaseModel):
name: str
class ProfileDescriptionUpdate(BaseModel):
description: str = ""
class ProfileModelUpdate(BaseModel):
provider: str
model: str
class ProfileDescribeAuto(BaseModel):
overwrite: bool = False
class SkillToggle(BaseModel):
name: str
enabled: bool
profile: Optional[str] = None
class SkillCreate(BaseModel):
name: str
content: str
category: Optional[str] = None
profile: Optional[str] = None
class SkillContentUpdate(BaseModel):
name: str
content: str
profile: Optional[str] = None
class ToolsetToggle(BaseModel):
enabled: bool
profile: Optional[str] = None
class ToolsetProviderSelect(BaseModel):
provider: str
# Web-only scope 'search' | 'extract'; omitted → whole-provider (legacy web.backend path).
capability: Optional[str] = None
profile: Optional[str] = None
class ToolsetModelSelect(BaseModel):
model: str
provider: Optional[str] = None
profile: Optional[str] = None
class ToolsetEnvUpdate(BaseModel):
env: Dict[str, str]
profile: Optional[str] = None
class ToolsetPostSetup(BaseModel):
key: str
profile: Optional[str] = None
class TerminalBackendSelect(BaseModel):
backend: str
profile: Optional[str] = None
class RawConfigUpdate(BaseModel):
yaml_text: str
profile: Optional[str] = None
class ThemeSetBody(BaseModel):
name: str
class FontSetBody(BaseModel):
font: str
class _AgentPluginInstallBody(BaseModel):
identifier: str
force: bool = False
enable: bool = True
class _PluginProvidersPutBody(BaseModel):
memory_provider: Optional[str] = None
context_engine: Optional[str] = None
class _PluginVisibilityBody(BaseModel):
hidden: bool