forked from Rockachopa/Timmy-time-dashboard
feat: Hands Infrastructure - Models, Registry, Scheduler (Phase 3.1-3.3)
Add core Hands infrastructure: - hands/models.py: Pydantic models for HAND.toml schema - HandConfig: Complete hand configuration - HandState: Runtime state tracking - HandExecution: Execution records - ApprovalRequest: Approval queue entries - hands/registry.py: HandRegistry for loading and indexing - Load Hands from hands/ directory - Parse HAND.toml manifests - SQLite indexing for fast lookup - Approval queue management - Execution history logging - hands/scheduler.py: APScheduler-based scheduling - Cron and interval triggers - Job management (schedule, pause, resume, unschedule) - Hand execution wrapper - Manual trigger support
This commit is contained in:
252
src/hands/models.py
Normal file
252
src/hands/models.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Hands Models — Pydantic schemas for HAND.toml manifests.
|
||||
|
||||
Defines the data structures for autonomous Hand agents:
|
||||
- HandConfig: Complete hand configuration from HAND.toml
|
||||
- HandState: Runtime state tracking
|
||||
- HandExecution: Execution record for audit trail
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
class HandStatus(str, Enum):
|
||||
"""Runtime status of a Hand."""
|
||||
DISABLED = "disabled"
|
||||
IDLE = "idle"
|
||||
SCHEDULED = "scheduled"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class HandOutcome(str, Enum):
|
||||
"""Outcome of a Hand execution."""
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
APPROVAL_PENDING = "approval_pending"
|
||||
TIMEOUT = "timeout"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class TriggerType(str, Enum):
|
||||
"""Types of execution triggers."""
|
||||
SCHEDULE = "schedule" # Cron schedule
|
||||
MANUAL = "manual" # User triggered
|
||||
EVENT = "event" # Event-driven
|
||||
WEBHOOK = "webhook" # External webhook
|
||||
|
||||
|
||||
# ── HAND.toml Schema Models ───────────────────────────────────────────────
|
||||
|
||||
class ToolRequirement(BaseModel):
|
||||
"""A required tool for the Hand."""
|
||||
name: str
|
||||
version: Optional[str] = None
|
||||
optional: bool = False
|
||||
|
||||
|
||||
class OutputConfig(BaseModel):
|
||||
"""Output configuration for Hand results."""
|
||||
dashboard: bool = True
|
||||
channel: Optional[str] = None # e.g., "telegram", "discord"
|
||||
format: str = "markdown" # markdown, json, html
|
||||
file_drop: Optional[str] = None # Path to write output files
|
||||
|
||||
|
||||
class ApprovalGate(BaseModel):
|
||||
"""An approval gate for sensitive operations."""
|
||||
action: str # e.g., "post_tweet", "send_payment"
|
||||
description: str
|
||||
auto_approve_after: Optional[int] = None # Seconds to auto-approve
|
||||
|
||||
|
||||
class ScheduleConfig(BaseModel):
|
||||
"""Schedule configuration for the Hand."""
|
||||
cron: Optional[str] = None # Cron expression
|
||||
interval: Optional[int] = None # Seconds between runs
|
||||
at: Optional[str] = None # Specific time (HH:MM)
|
||||
timezone: str = "UTC"
|
||||
|
||||
@validator('cron')
|
||||
def validate_cron(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
# Basic cron validation (5 fields)
|
||||
parts = v.split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError("Cron expression must have 5 fields: minute hour day month weekday")
|
||||
return v
|
||||
|
||||
|
||||
class HandConfig(BaseModel):
|
||||
"""Complete Hand configuration from HAND.toml.
|
||||
|
||||
Example HAND.toml:
|
||||
[hand]
|
||||
name = "oracle"
|
||||
schedule = "0 7,19 * * *"
|
||||
description = "Bitcoin and on-chain intelligence briefing"
|
||||
|
||||
[tools]
|
||||
required = ["mempool_fetch", "fee_estimate"]
|
||||
|
||||
[approval_gates]
|
||||
post_tweet = { action = "post_tweet", description = "Post to Twitter" }
|
||||
|
||||
[output]
|
||||
dashboard = true
|
||||
channel = "telegram"
|
||||
"""
|
||||
|
||||
# Required fields
|
||||
name: str = Field(..., description="Unique hand identifier")
|
||||
description: str = Field(..., description="What this Hand does")
|
||||
|
||||
# Schedule (one of these must be set)
|
||||
schedule: Optional[ScheduleConfig] = None
|
||||
trigger: Optional[TriggerType] = TriggerType.SCHEDULE
|
||||
|
||||
# Optional fields
|
||||
enabled: bool = True
|
||||
version: str = "1.0.0"
|
||||
author: Optional[str] = None
|
||||
|
||||
# Tools
|
||||
tools_required: list[str] = Field(default_factory=list)
|
||||
tools_optional: list[str] = Field(default_factory=list)
|
||||
|
||||
# Approval gates
|
||||
approval_gates: list[ApprovalGate] = Field(default_factory=list)
|
||||
|
||||
# Output configuration
|
||||
output: OutputConfig = Field(default_factory=OutputConfig)
|
||||
|
||||
# File paths (set at runtime)
|
||||
hand_dir: Optional[Path] = Field(None, exclude=True)
|
||||
system_prompt_path: Optional[Path] = None
|
||||
skill_paths: list[Path] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
extra = "allow" # Allow additional fields for extensibility
|
||||
|
||||
@property
|
||||
def system_md_path(self) -> Optional[Path]:
|
||||
"""Path to SYSTEM.md file."""
|
||||
if self.hand_dir:
|
||||
return self.hand_dir / "SYSTEM.md"
|
||||
return None
|
||||
|
||||
@property
|
||||
def skill_md_paths(self) -> list[Path]:
|
||||
"""Paths to SKILL.md files."""
|
||||
if self.hand_dir:
|
||||
skill_dir = self.hand_dir / "skills"
|
||||
if skill_dir.exists():
|
||||
return list(skill_dir.glob("*.md"))
|
||||
return []
|
||||
|
||||
|
||||
# ── Runtime State Models ─────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class HandState:
|
||||
"""Runtime state of a Hand."""
|
||||
name: str
|
||||
status: HandStatus = HandStatus.IDLE
|
||||
last_run: Optional[datetime] = None
|
||||
next_run: Optional[datetime] = None
|
||||
run_count: int = 0
|
||||
success_count: int = 0
|
||||
failure_count: int = 0
|
||||
error_message: Optional[str] = None
|
||||
is_paused: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"status": self.status.value,
|
||||
"last_run": self.last_run.isoformat() if self.last_run else None,
|
||||
"next_run": self.next_run.isoformat() if self.next_run else None,
|
||||
"run_count": self.run_count,
|
||||
"success_count": self.success_count,
|
||||
"failure_count": self.failure_count,
|
||||
"error_message": self.error_message,
|
||||
"is_paused": self.is_paused,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandExecution:
|
||||
"""Record of a Hand execution."""
|
||||
id: str
|
||||
hand_name: str
|
||||
trigger: TriggerType
|
||||
started_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
outcome: HandOutcome = HandOutcome.SKIPPED
|
||||
output: str = ""
|
||||
error: Optional[str] = None
|
||||
approval_id: Optional[str] = None
|
||||
files_generated: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"hand_name": self.hand_name,
|
||||
"trigger": self.trigger.value,
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"outcome": self.outcome.value,
|
||||
"output": self.output,
|
||||
"error": self.error,
|
||||
"approval_id": self.approval_id,
|
||||
"files_generated": self.files_generated,
|
||||
}
|
||||
|
||||
|
||||
# ── Approval Queue Models ────────────────────────────────────────────────
|
||||
|
||||
class ApprovalStatus(str, Enum):
|
||||
"""Status of an approval request."""
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
EXPIRED = "expired"
|
||||
AUTO_APPROVED = "auto_approved"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalRequest:
|
||||
"""A request for user approval."""
|
||||
id: str
|
||||
hand_name: str
|
||||
action: str
|
||||
description: str
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
status: ApprovalStatus = ApprovalStatus.PENDING
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
expires_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
resolved_by: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"hand_name": self.hand_name,
|
||||
"action": self.action,
|
||||
"description": self.description,
|
||||
"context": self.context,
|
||||
"status": self.status.value,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
|
||||
"resolved_by": self.resolved_by,
|
||||
}
|
||||
Reference in New Issue
Block a user