ManusClaw-fixes / app /schema.py
Jd Vijay
Upload app/schema.py
452568f verified
Raw
History Blame Contribute Delete
21.1 kB
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Callable, Literal, Optional, Union
from pydantic import BaseModel, Field, model_validator
# ──────────────────────────────────────────────────────────────────────────────
# Roles & States
# ──────────────────────────────────────────────────────────────────────────────
class Role(str, Enum):
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"
TOOL = "tool"
class AgentState(str, Enum):
IDLE = "IDLE"
RUNNING = "RUNNING"
FINISHED = "FINISHED"
ERROR = "ERROR"
# ──────────────────────────────────────────────────────────────────────────────
# Message primitives
# ──────────────────────────────────────────────────────────────────────────────
class Function(BaseModel):
name: str
arguments: str # JSON-encoded string
class ToolCall(BaseModel):
id: str
type: Literal["function"] = "function"
function: Function
class Message(BaseModel):
role: Role
content: Optional[str] = None
tool_calls: Optional[list[ToolCall]] = None
tool_call_id: Optional[str] = None
name: Optional[str] = None
@classmethod
def system(cls, content: str) -> "Message":
return cls(role=Role.SYSTEM, content=content)
@classmethod
def user(cls, content: str) -> "Message":
return cls(role=Role.USER, content=content)
@classmethod
def assistant(
cls,
content: Optional[str] = None,
tool_calls: Optional[list[ToolCall]] = None,
) -> "Message":
return cls(role=Role.ASSISTANT, content=content, tool_calls=tool_calls)
@classmethod
def tool(cls, content: str, tool_call_id: str, name: str) -> "Message":
return cls(role=Role.TOOL, content=content, tool_call_id=tool_call_id, name=name)
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {"role": self.role.value}
if self.content is not None:
d["content"] = self.content
if self.tool_calls:
d["tool_calls"] = [
{
"id": tc.id,
"type": tc.type,
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in self.tool_calls
]
if self.tool_call_id:
d["tool_call_id"] = self.tool_call_id
if self.name:
d["name"] = self.name
return d
# ──────────────────────────────────────────────────────────────────────────────
# Memory β€” context-window-aware
# ──────────────────────────────────────────────────────────────────────────────
class Memory(BaseModel):
messages: list[Message] = Field(default_factory=list)
max_messages: int = 100
def add(self, message: Message) -> None:
self.messages.append(message)
self._trim()
def _trim(self) -> None:
if len(self.messages) <= self.max_messages:
return
system = [m for m in self.messages if m.role == Role.SYSTEM]
rest = [m for m in self.messages if m.role != Role.SYSTEM]
keep = max(self.max_messages - len(system), 0)
self.messages = system + rest[-keep:] if keep else system
def to_list(self) -> list[dict[str, Any]]:
return [m.to_dict() for m in self.messages]
def clear(self) -> None:
self.messages = []
def token_estimate(self) -> int:
"""Estimate token count using 3.5 chars/token (more conservative than //4).
For mixed content (code, non-English) this is still approximate but better."""
total = sum(len(m.content or "") for m in self.messages)
return int(total / 3.5)
# ──────────────────────────────────────────────────────────────────────────────
# PAORR loop primitives β€” Plan, Act, Observe, Reflect, Retry
# ──────────────────────────────────────────────────────────────────────────────
def _utcnow() -> datetime:
"""UTC now β€” replacement for deprecated datetime.utcnow()."""
return datetime.now(timezone.utc)
class Observation(BaseModel):
"""Result of a single tool execution."""
tool_name: str
args: dict[str, Any]
output: Optional[str]
error: Optional[str]
success: bool
attempt: int = 1
duration_ms: int = 0
timestamp: datetime = Field(default_factory=_utcnow)
def summary(self) -> str:
if self.success:
out = (self.output or "")[:400]
return f"[{self.tool_name}] βœ“ {out}"
return f"[{self.tool_name}] βœ— ERROR: {self.error}"
class Reflection(BaseModel):
"""LLM-generated reflection on whether an observation solved the goal."""
step_goal: str
observation_summary: str
solved: bool
reason: str
next_action: Optional[str] = None
def to_prompt(self) -> str:
status = "SOLVED" if self.solved else "NOT SOLVED"
lines = [
f"Reflection [{status}]:",
f" Goal: {self.step_goal}",
f" Observation: {self.observation_summary}",
f" Reason: {self.reason}",
]
if self.next_action:
lines.append(f" Next action: {self.next_action}")
return "\n".join(lines)
class TaskStep(BaseModel):
"""A single step in the PAORR execution history."""
step_number: int
goal: str
observations: list[Observation] = Field(default_factory=list)
reflection: Optional[Reflection] = None
resolved: bool = False
timestamp: datetime = Field(default_factory=_utcnow)
def summary(self) -> str:
status = "βœ“" if self.resolved else "βœ—"
obs_summaries = " | ".join(o.summary() for o in self.observations[-3:])
return f"Step {self.step_number} {status}: {self.goal[:60]} β†’ {obs_summaries}"
class TaskHistory(BaseModel):
"""Persistent log of all steps across a task run."""
task_id: str
original_goal: str
steps: list[TaskStep] = Field(default_factory=list)
created_at: datetime = Field(default_factory=_utcnow)
def add_step(self, goal: str) -> TaskStep:
step = TaskStep(step_number=len(self.steps) + 1, goal=goal)
self.steps.append(step)
return step
def last_step(self) -> Optional[TaskStep]:
return self.steps[-1] if self.steps else None
def context_summary(self, max_steps: int = 5) -> str:
recent = self.steps[-max_steps:]
if not recent:
return "No prior steps."
lines = ["=== Task History (recent steps) ==="]
for s in recent:
lines.append(s.summary())
lines.append("=== End History ===")
return "\n".join(lines)
def is_looping(self, window: int = 3) -> bool:
if len(self.steps) < window:
return False
recent = self.steps[-window:]
if all(not s.resolved for s in recent):
tool_names = [
o.tool_name
for s in recent
for o in s.observations
]
if len(set(tool_names)) == 1 and tool_names:
return True
return False
# ──────────────────────────────────────────────────────────────────────────────
# ToolResult
# ──────────────────────────────────────────────────────────────────────────────
class ToolResult(BaseModel):
output: Optional[str] = None
error: Optional[str] = None
system: Optional[str] = None
base64_image: Optional[str] = None
@property
def success(self) -> bool:
return self.error is None
def __str__(self) -> str:
parts = []
if self.output:
parts.append(self.output)
if self.error:
parts.append(f"ERROR: {self.error}")
if self.system:
parts.append(f"[system: {self.system}]")
return "\n".join(parts) if parts else "(no output)"
# ──────────────────────────────────────────────────────────────────────────────
# Planning primitives
# ──────────────────────────────────────────────────────────────────────────────
class StepStatus(str, Enum):
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
BLOCKED = "blocked"
class PlanStep(BaseModel):
id: int
description: str
status: StepStatus = StepStatus.NOT_STARTED
assigned_to: Optional[str] = None
notes: Optional[str] = None
success_criteria: Optional[str] = None # extracted from description parens
success_score: float = 0.0 # 0.0-1.0 after completion
class Plan(BaseModel):
id: str
title: str
steps: list[PlanStep] = Field(default_factory=list)
def next_step(self) -> Optional[PlanStep]:
for step in self.steps:
if step.status == StepStatus.NOT_STARTED:
return step
return None
def is_complete(self) -> bool:
return all(s.status == StepStatus.COMPLETED for s in self.steps)
def completion_rate(self) -> float:
if not self.steps:
return 0.0
done = sum(1 for s in self.steps if s.status == StepStatus.COMPLETED)
return done / len(self.steps)
# ──────────────────────────────────────────────────────────────────────────────
# Retry Policy
# ──────────────────────────────────────────────────────────────────────────────
class RetryPolicy(BaseModel):
"""
Declarative retry configuration.
Used by agents, flows, and the orchestrator.
"""
max_attempts: int = 3
base_wait_s: float = 1.0
max_wait_s: float = 30.0
exponential_base: float = 2.0
jitter: bool = True
def wait_for(self, attempt: int) -> float:
"""Return the wait duration (seconds) before the given attempt number."""
import random
wait = min(
self.base_wait_s * (self.exponential_base ** (attempt - 1)),
self.max_wait_s,
)
if self.jitter:
wait += random.uniform(0, wait * 0.3)
return round(wait, 2)
# ──────────────────────────────────────────────────────────────────────────────
# Agent run contracts
# ──────────────────────────────────────────────────────────────────────────────
class AgentRunConfig(BaseModel):
"""
Typed contract for starting an agent run.
Passed to server endpoints and CLI entry points.
"""
prompt: str
mode: str = "build"
max_steps: int = 30
timeout: int = 3600
session_id: Optional[str] = None
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
@model_validator(mode="after")
def _validate_mode(self) -> "AgentRunConfig":
if self.mode not in ("build", "plan"):
raise ValueError(f"mode must be 'build' or 'plan', got '{self.mode}'")
return self
class AgentRunResult(BaseModel):
"""Typed result returned after an agent run completes."""
session_id: str
agent_name: str
prompt: str
output: str
state: AgentState
step_count: int
duration_s: float
success: bool
trace_id: Optional[str] = None
@property
def failed(self) -> bool:
return not self.success
# ──────────────────────────────────────────────────────────────────────────────
# Role decision primitives
# ──────────────────────────────────────────────────────────────────────────────
class RoleDecision(str, Enum):
"""
Decision made by a role after processing its input.
PROCEED β€” output is ready; publish to the next role
RETRY β€” output is incomplete; retry with a focused correction prompt
ESCALATE β€” input is ambiguous/unclear; request clarification or skip
BLOCKED β€” role cannot proceed even after retries; pipeline should continue with error
"""
PROCEED = "proceed"
RETRY = "retry"
ESCALATE = "escalate"
BLOCKED = "blocked"
class RoleResult(BaseModel):
"""Typed result from a single role execution."""
role_name: str
decision: RoleDecision
output: str
artefact: Optional[str] = None
duration_s: float = 0.0
retry_count: int = 0
escalation_reason: Optional[str] = None
@property
def succeeded(self) -> bool:
return self.decision in (RoleDecision.PROCEED, RoleDecision.ESCALATE)
# ──────────────────────────────────────────────────────────────────────────────
# Flow contracts
# ──────────────────────────────────────────────────────────────────────────────
class FlowStepResult(BaseModel):
"""Result of a single step in a PlanningFlow execution."""
step_id: int
description: str
status: StepStatus
output: Optional[str] = None
error: Optional[str] = None
attempts: int = 1
duration_s: float = 0.0
success_score: float = 0.0 # 0.0-1.0: how well success_criteria was met
class FlowResult(BaseModel):
"""Aggregated result from a complete PlanningFlow execution."""
flow_id: str
goal: str
steps: list[FlowStepResult] = Field(default_factory=list)
total_duration_s: float = 0.0
timed_out: bool = False
@property
def success_rate(self) -> float:
if not self.steps:
return 0.0
done = sum(1 for s in self.steps if s.status == StepStatus.COMPLETED)
return done / len(self.steps)
@property
def avg_success_score(self) -> float:
completed = [s for s in self.steps if s.status == StepStatus.COMPLETED]
if not completed:
return 0.0
return sum(s.success_score for s in completed) / len(completed)
# ──────────────────────────────────────────────────────────────────────────────
# Pipeline (Orchestrator) contracts
# ──────────────────────────────────────────────────────────────────────────────
class PipelineStageResult(BaseModel):
"""Result of a single role stage in the orchestrator pipeline."""
role_name: str
status: str # "completed" | "error" | "skipped"
output: str
duration_s: float = 0.0
decision: Optional[str] = None
class PipelineResult(BaseModel):
"""Aggregated result from a complete MultiAgentOrchestrator run."""
pipeline_id: str
goal: str
stages: list[PipelineStageResult] = Field(default_factory=list)
total_duration_s: float = 0.0
timed_out: bool = False
verdict: str = "unknown" # "approved"|"rework"|"timeout"|"error"
def to_summary(self) -> str:
lines = [
"═══════════════════════════════════════════════════",
" ManusClaw Multi-Agent Pipeline β€” Final Report",
"═══════════════════════════════════════════════════",
f" Pipeline ID : {self.pipeline_id}",
f" Goal : {self.goal[:80]}",
f" Duration : {self.total_duration_s:.1f}s",
f" Verdict : {self.verdict.upper()}",
"",
]
for stage in self.stages:
icon = "βœ“" if stage.status == "completed" else "βœ—"
lines.append(f" {icon} {stage.role_name.replace('_', ' ').title():<20s} "
f"[{stage.duration_s:.1f}s] β€” {stage.output[:100]}...")
if self.timed_out:
lines.append("\n ⏱ Pipeline timed out before all stages completed.")
lines.append("═══════════════════════════════════════════════════")
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# Tool call contract
# ──────────────────────────────────────────────────────────────────────────────
class ToolCallContract(BaseModel):
"""
Typed contract for a tool call.
Used for validation, documentation, and testing.
"""
tool_name: str
args: dict[str, Any]
expected_output_type: str = "text" # "text" | "json" | "file" | "image"
timeout_s: Optional[int] = None
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
def validate_args(self, schema: dict) -> tuple[bool, str]:
"""Validate args against a JSON Schema dict. Returns (valid, error_msg)."""
required = schema.get("required", [])
missing = [k for k in required if k not in self.args]
if missing:
return False, f"Missing required args: {missing}"
return True, ""