Spaces:
Sleeping
Sleeping
File size: 20,542 Bytes
c96b98a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 | import collections.abc
import inspect
from os import getenv
from uuid import uuid4
from types import GeneratorType
from typing import Any, Optional, Callable, Dict
from pydantic import BaseModel, Field, ConfigDict, field_validator, PrivateAttr
from phi.agent import Agent
from phi.run.response import RunResponse, RunEvent # noqa: F401
from phi.memory.workflow import WorkflowMemory, WorkflowRun
from phi.storage.workflow.base import WorkflowStorage
from phi.utils.log import logger, set_log_level_to_debug, set_log_level_to_info
from phi.utils.merge_dict import merge_dictionaries
from phi.workflow.session import WorkflowSession
class Workflow(BaseModel):
# -*- Workflow settings
# Workflow name
name: Optional[str] = None
# Workflow description
description: Optional[str] = None
# Workflow UUID (autogenerated if not set)
workflow_id: Optional[str] = Field(None, validate_default=True)
# Metadata associated with this workflow
workflow_data: Optional[Dict[str, Any]] = None
# -*- User settings
# ID of the user interacting with this workflow
user_id: Optional[str] = None
# Metadata associated with the user interacting with this workflow
user_data: Optional[Dict[str, Any]] = None
# -*- Session settings
# Session UUID (autogenerated if not set)
session_id: Optional[str] = Field(None, validate_default=True)
# Session name
session_name: Optional[str] = None
# Session state stored in the database
session_state: Dict[str, Any] = Field(default_factory=dict)
# -*- Workflow Memory
memory: WorkflowMemory = WorkflowMemory()
# -*- Workflow Storage
storage: Optional[WorkflowStorage] = None
# WorkflowSession from the database: DO NOT SET MANUALLY
_workflow_session: Optional[WorkflowSession] = None
# debug_mode=True enables debug logs
debug_mode: bool = Field(False, validate_default=True)
# monitoring=True logs workflow information to phidata.com
monitoring: bool = getenv("PHI_MONITORING", "false").lower() == "true"
# telemetry=True logs minimal telemetry for analytics
# This helps us improve the Agent and provide better support
telemetry: bool = getenv("PHI_TELEMETRY", "true").lower() == "true"
# DO NOT SET THE FOLLOWING FIELDS MANUALLY
# Run ID: DO NOT SET MANUALLY
run_id: Optional[str] = None
# Input to the Workflow run: DO NOT SET MANUALLY
run_input: Optional[Dict[str, Any]] = None
# Response from the Workflow run: DO NOT SET MANUALLY
run_response: RunResponse = Field(default_factory=RunResponse)
# Metadata associated with this session: DO NOT SET MANUALLY
session_data: Optional[Dict[str, Any]] = None
# The run function provided by the subclass
_subclass_run: Callable = PrivateAttr()
# Parameters of the run function
_run_parameters: Dict[str, Any] = PrivateAttr()
# Return type of the run function
_run_return_type: Optional[str] = PrivateAttr()
model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)
@field_validator("workflow_id", mode="before")
def set_workflow_id(cls, v: Optional[str]) -> str:
workflow_id = v or str(uuid4())
logger.debug(f"*********** Workflow ID: {workflow_id} ***********")
return workflow_id
@field_validator("session_id", mode="before")
def set_session_id(cls, v: Optional[str]) -> str:
session_id = v or str(uuid4())
logger.debug(f"*********** Workflow Session ID: {session_id} ***********")
return session_id
@field_validator("debug_mode", mode="before")
def set_log_level(cls, v: bool) -> bool:
if v or getenv("PHI_DEBUG", "false").lower() == "true":
set_log_level_to_debug()
logger.debug("Debug logs enabled")
elif v is False:
set_log_level_to_info()
return v
def get_workflow_data(self) -> Dict[str, Any]:
workflow_data = self.workflow_data or {}
if self.name is not None:
workflow_data["name"] = self.name
return workflow_data
def get_session_data(self) -> Dict[str, Any]:
session_data = self.session_data or {}
if self.session_name is not None:
session_data["session_name"] = self.session_name
if len(self.session_state) > 0:
session_data["session_state"] = self.session_state
return session_data
def get_workflow_session(self) -> WorkflowSession:
"""Get a WorkflowSession object, which can be saved to the database"""
return WorkflowSession(
session_id=self.session_id,
workflow_id=self.workflow_id,
user_id=self.user_id,
memory=self.memory.to_dict(),
workflow_data=self.get_workflow_data(),
user_data=self.user_data,
session_data=self.get_session_data(),
)
def from_workflow_session(self, session: WorkflowSession):
"""Load the existing Workflow from a WorkflowSession (from the database)"""
# Get the session_id, workflow_id and user_id from the database
if self.session_id is None and session.session_id is not None:
self.session_id = session.session_id
if self.workflow_id is None and session.workflow_id is not None:
self.workflow_id = session.workflow_id
if self.user_id is None and session.user_id is not None:
self.user_id = session.user_id
# Read workflow_data from the database
if session.workflow_data is not None:
# Get name from database and update the workflow name if not set
if self.name is None and "name" in session.workflow_data:
self.name = session.workflow_data.get("name")
# If workflow_data is set in the workflow, update the database workflow_data with the workflow's workflow_data
if self.workflow_data is not None:
# Updates workflow_session.workflow_data in place
merge_dictionaries(session.workflow_data, self.workflow_data)
self.workflow_data = session.workflow_data
# Read user_data from the database
if session.user_data is not None:
# If user_data is set in the workflow, update the database user_data with the workflow's user_data
if self.user_data is not None:
# Updates workflow_session.user_data in place
merge_dictionaries(session.user_data, self.user_data)
self.user_data = session.user_data
# Read session_data from the database
if session.session_data is not None:
# Get the session_name from database and update the current session_name if not set
if self.session_name is None and "session_name" in session.session_data:
self.session_name = session.session_data.get("session_name")
# Get the session_state from database and update the current session_state
if "session_state" in session.session_data:
session_state_from_db = session.session_data.get("session_state")
if (
session_state_from_db is not None
and isinstance(session_state_from_db, dict)
and len(session_state_from_db) > 0
):
# If the session_state is already set, merge the session_state from the database with the current session_state
if len(self.session_state) > 0:
# This updates session_state_from_db
merge_dictionaries(session_state_from_db, self.session_state)
# Update the current session_state
self.session_state = session_state_from_db
# If session_data is set in the workflow, update the database session_data with the workflow's session_data
if self.session_data is not None:
# Updates workflow_session.session_data in place
merge_dictionaries(session.session_data, self.session_data)
self.session_data = session.session_data
# Read memory from the database
if session.memory is not None:
try:
if "runs" in session.memory:
self.memory.runs = [WorkflowRun(**m) for m in session.memory["runs"]]
except Exception as e:
logger.warning(f"Failed to load WorkflowMemory: {e}")
logger.debug(f"-*- WorkflowSession loaded: {session.session_id}")
def read_from_storage(self) -> Optional[WorkflowSession]:
"""Load the WorkflowSession from storage.
Returns:
Optional[WorkflowSession]: The loaded WorkflowSession or None if not found.
"""
if self.storage is not None and self.session_id is not None:
self._workflow_session = self.storage.read(session_id=self.session_id)
if self._workflow_session is not None:
self.from_workflow_session(session=self._workflow_session)
return self._workflow_session
def write_to_storage(self) -> Optional[WorkflowSession]:
"""Save the WorkflowSession to storage
Returns:
Optional[WorkflowSession]: The saved WorkflowSession or None if not saved.
"""
if self.storage is not None:
self._workflow_session = self.storage.upsert(session=self.get_workflow_session())
return self._workflow_session
def load_session(self, force: bool = False) -> Optional[str]:
"""Load an existing session from the database and return the session_id.
If a session does not exist, create a new session.
- If a session exists in the database, load the session.
- If a session does not exist in the database, create a new session.
"""
# If a workflow_session is already loaded, return the session_id from the workflow_session
# if session_id matches the session_id from the workflow_session
if self._workflow_session is not None and not force:
if self.session_id is not None and self._workflow_session.session_id == self.session_id:
return self._workflow_session.session_id
# Load an existing session or create a new session
if self.storage is not None:
# Load existing session if session_id is provided
logger.debug(f"Reading WorkflowSession: {self.session_id}")
self.read_from_storage()
# Create a new session if it does not exist
if self._workflow_session is None:
logger.debug("-*- Creating new WorkflowSession")
# write_to_storage() will create a new WorkflowSession
# and populate self._workflow_session with the new session
self.write_to_storage()
if self._workflow_session is None:
raise Exception("Failed to create new WorkflowSession in storage")
logger.debug(f"-*- Created WorkflowSession: {self._workflow_session.session_id}")
self.log_workflow_session()
return self.session_id
def run(self, *args: Any, **kwargs: Any):
logger.error(f"{self.__class__.__name__}.run() method not implemented.")
return
def run_workflow(self, *args: Any, **kwargs: Any):
self.run_id = str(uuid4())
self.run_input = {"args": args, "kwargs": kwargs}
self.run_response = RunResponse(run_id=self.run_id, session_id=self.session_id, workflow_id=self.workflow_id)
self.read_from_storage()
logger.debug(f"*********** Workflow Run Start: {self.run_id} ***********")
result = self._subclass_run(*args, **kwargs)
# The run_workflow() method handles both Iterator[RunResponse] and RunResponse
# Case 1: The run method returns an Iterator[RunResponse]
if isinstance(result, (GeneratorType, collections.abc.Iterator)):
# Initialize the run_response content
self.run_response.content = ""
def result_generator():
for item in result:
if isinstance(item, RunResponse):
# Update the run_id, session_id and workflow_id of the RunResponse
item.run_id = self.run_id
item.session_id = self.session_id
item.workflow_id = self.workflow_id
# Update the run_response with the content from the result
if item.content is not None and isinstance(item.content, str):
self.run_response.content += item.content
else:
logger.warning(f"Workflow.run() should only yield RunResponse objects, got: {type(item)}")
yield item
# Add the run to the memory
self.memory.add_run(WorkflowRun(input=self.run_input, response=self.run_response))
# Write this run to the database
self.write_to_storage()
logger.debug(f"*********** Workflow Run End: {self.run_id} ***********")
return result_generator()
# Case 2: The run method returns a RunResponse
elif isinstance(result, RunResponse):
# Update the result with the run_id, session_id and workflow_id of the workflow run
result.run_id = self.run_id
result.session_id = self.session_id
result.workflow_id = self.workflow_id
# Update the run_response with the content from the result
if result.content is not None and isinstance(result.content, str):
self.run_response.content = result.content
# Add the run to the memory
self.memory.add_run(WorkflowRun(input=self.run_input, response=self.run_response))
# Write this run to the database
self.write_to_storage()
logger.debug(f"*********** Workflow Run End: {self.run_id} ***********")
return result
else:
logger.warning(f"Workflow.run() should only return RunResponse objects, got: {type(result)}")
return None
def __init__(self, **data):
super().__init__(**data)
self.name = self.name or self.__class__.__name__
# Check if 'run' is provided by the subclass
if self.__class__.run is not Workflow.run:
# Store the original run method bound to the instance
self._subclass_run = self.__class__.run.__get__(self)
# Get the parameters of the run method
sig = inspect.signature(self.__class__.run)
# Convert parameters to a serializable format
self._run_parameters = {
name: {
"name": name,
"default": param.default.default
if hasattr(param.default, "__class__") and param.default.__class__.__name__ == "FieldInfo"
else (param.default if param.default is not inspect.Parameter.empty else None),
"annotation": (
param.annotation.__name__
if hasattr(param.annotation, "__name__")
else (
str(param.annotation).replace("typing.Optional[", "").replace("]", "")
if "typing.Optional" in str(param.annotation)
else str(param.annotation)
)
)
if param.annotation is not inspect.Parameter.empty
else None,
"required": param.default is inspect.Parameter.empty,
}
for name, param in sig.parameters.items()
if name != "self"
}
# Determine the return type of the run method
return_annotation = sig.return_annotation
self._run_return_type = (
return_annotation.__name__
if return_annotation is not inspect.Signature.empty and hasattr(return_annotation, "__name__")
else str(return_annotation)
if return_annotation is not inspect.Signature.empty
else None
)
# Replace the instance's run method with run_workflow
object.__setattr__(self, "run", self.run_workflow.__get__(self))
else:
# This will log an error when called
self._subclass_run = self.run
self._run_parameters = {}
self._run_return_type = None
def model_post_init(self, __context: Any) -> None:
super().model_post_init(__context)
for field_name, field in self.__fields__.items():
value = getattr(self, field_name)
if isinstance(value, Agent):
value.session_id = self.session_id
def log_workflow_session(self):
logger.debug(f"*********** Logging WorkflowSession: {self.session_id} ***********")
def rename_session(self, session_id: str, name: str):
if self.storage is None:
raise ValueError("Storage is not set")
workflow_session = self.storage.read(session_id)
if workflow_session is None:
raise Exception(f"WorkflowSession not found: {session_id}")
if workflow_session.session_data is not None:
workflow_session.session_data["session_name"] = name
else:
workflow_session.session_data = {"session_name": name}
self.storage.upsert(workflow_session)
def delete_session(self, session_id: str):
if self.storage is None:
raise ValueError("Storage is not set")
self.storage.delete_session(session_id)
def deep_copy(self, *, update: Optional[Dict[str, Any]] = None) -> "Workflow":
"""Create and return a deep copy of this Workflow, optionally updating fields.
Args:
update (Optional[Dict[str, Any]]): Optional dictionary of fields for the new Workflow.
Returns:
Workflow: A new Workflow instance.
"""
# Extract the fields to set for the new Workflow
fields_for_new_workflow = {}
for field_name in self.model_fields_set:
field_value = getattr(self, field_name)
if field_value is not None:
if isinstance(field_value, Agent):
fields_for_new_workflow[field_name] = field_value.deep_copy()
else:
fields_for_new_workflow[field_name] = self._deep_copy_field(field_name, field_value)
# Update fields if provided
if update:
fields_for_new_workflow.update(update)
# Create a new Workflow
new_workflow = self.__class__(**fields_for_new_workflow)
logger.debug(
f"Created new Workflow: workflow_id: {new_workflow.workflow_id} | session_id: {new_workflow.session_id}"
)
return new_workflow
def _deep_copy_field(self, field_name: str, field_value: Any) -> Any:
"""Helper method to deep copy a field based on its type."""
from copy import copy, deepcopy
# For memory, use its deep_copy method
if field_name == "memory":
return field_value.deep_copy()
# For compound types, attempt a deep copy
if isinstance(field_value, (list, dict, set, WorkflowStorage)):
try:
return deepcopy(field_value)
except Exception as e:
logger.warning(f"Failed to deepcopy field: {field_name} - {e}")
try:
return copy(field_value)
except Exception as e:
logger.warning(f"Failed to copy field: {field_name} - {e}")
return field_value
# For pydantic models, attempt a deep copy
if isinstance(field_value, BaseModel):
try:
return field_value.model_copy(deep=True)
except Exception as e:
logger.warning(f"Failed to deepcopy field: {field_name} - {e}")
try:
return field_value.model_copy(deep=False)
except Exception as e:
logger.warning(f"Failed to copy field: {field_name} - {e}")
return field_value
# For other types, return as is
return field_value
|