mirror of
https://github.com/hanzoai/agents.git
synced 2026-10-30 18:48:36 +00:00
Transforms hanzoai/agents from a marketplace config repo into a full agent orchestration platform by merging the AgentField infrastructure: - Go control plane (K8s-like agent orchestration, service mesh, DAG workflows) - Python SDK (FastAPI-based agent framework, LiteLLM, memory, DID/VC) - TypeScript SDK (@hanzo/agents - Vercel AI SDK, MCP, workflows) - Go SDK (idiomatic agent builder + client) - React 19 embedded dashboard (XyFlow DAGs, Recharts, Radix UI) - Docker/K8s/Helm deployment configs - Examples: hello world, deep research, RAG, simulation, benchmarks - Functional test suite across Python/Go/TypeScript All references rebranded: AgentField → Hanzo Agents - Module: github.com/hanzoai/agents/control-plane - Python: hanzo_agents - TypeScript: @hanzo/agents - Go: github.com/hanzoai/agents/sdk/go - Env vars: HANZO_AGENTS_* - Config: hanzo-agents.yaml Previous marketplace configs moved to config/ directory.
528 lines
18 KiB
Python
528 lines
18 KiB
Python
"""
|
|
Enhanced decorators for Hanzo Agents SDK with automatic workflow tracking.
|
|
Provides always-on workflow tracking for reasoner calls.
|
|
"""
|
|
|
|
import asyncio
|
|
import functools
|
|
import inspect
|
|
import time
|
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
|
|
from hanzo_agents.logger import log_warn
|
|
|
|
from .execution_context import (
|
|
ExecutionContext,
|
|
get_current_context,
|
|
set_execution_context,
|
|
reset_execution_context,
|
|
)
|
|
from .agent_registry import get_current_agent_instance
|
|
from .types import ReasonerDefinition
|
|
from .pydantic_utils import convert_function_args, should_convert_args
|
|
from pydantic import ValidationError
|
|
|
|
|
|
def reasoner(
|
|
func=None,
|
|
*,
|
|
path: Optional[str] = None,
|
|
tags: Optional[List[str]] = None,
|
|
description: Optional[str] = None,
|
|
track_workflow: bool = True,
|
|
**kwargs,
|
|
):
|
|
"""
|
|
Enhanced reasoner decorator with automatic workflow tracking and full feature support.
|
|
|
|
Supports both:
|
|
@reasoner # Default: track_workflow=True
|
|
@reasoner(track_workflow=False) # Explicit: disable tracking
|
|
@reasoner(path="/custom/path") # Custom endpoint path
|
|
@reasoner(tags=["ai", "nlp"]) # Tags for organization
|
|
@reasoner(description="...") # Custom description
|
|
|
|
Args:
|
|
func: The function to decorate (when used without parentheses)
|
|
path: Custom API endpoint path for this reasoner
|
|
tags: List of tags for organizing and categorizing reasoners
|
|
description: Description of what this reasoner does
|
|
track_workflow: Whether to enable automatic workflow tracking (default: True)
|
|
**kwargs: Additional metadata to store with the reasoner
|
|
|
|
Returns:
|
|
Decorated function with workflow tracking capabilities and full metadata support
|
|
"""
|
|
|
|
def decorator(f: Callable) -> Callable:
|
|
@functools.wraps(f)
|
|
async def wrapper(*args, **kwargs):
|
|
if track_workflow:
|
|
# Execute with automatic workflow tracking
|
|
return await _execute_with_tracking(f, *args, **kwargs)
|
|
else:
|
|
# Execute without tracking
|
|
if asyncio.iscoroutinefunction(f):
|
|
return await f(*args, **kwargs)
|
|
else:
|
|
return f(*args, **kwargs)
|
|
|
|
# Store comprehensive metadata on the function
|
|
wrapper._is_reasoner = True
|
|
wrapper._track_workflow = track_workflow
|
|
wrapper._reasoner_name = f.__name__
|
|
wrapper._original_func = f
|
|
wrapper._reasoner_path = path
|
|
wrapper._reasoner_tags = tags or []
|
|
wrapper._reasoner_description = (
|
|
description or f.__doc__ or f"Reasoner: {f.__name__}"
|
|
)
|
|
|
|
# Store any additional metadata
|
|
for key, value in kwargs.items():
|
|
setattr(wrapper, f"_reasoner_{key}", value)
|
|
|
|
return wrapper
|
|
|
|
# Handle both @reasoner and @reasoner(...) syntax
|
|
if func is None:
|
|
# Called as @reasoner(track_workflow=False) or @reasoner(path="/custom")
|
|
return decorator
|
|
else:
|
|
# Called as @reasoner (no parentheses)
|
|
return decorator(func)
|
|
|
|
|
|
async def _execute_with_tracking(func: Callable, *args, **kwargs) -> Any:
|
|
"""
|
|
Core function that handles automatic workflow tracking for reasoner calls.
|
|
|
|
Args:
|
|
func: The reasoner function to execute
|
|
*args: Positional arguments for the function
|
|
**kwargs: Keyword arguments for the function
|
|
|
|
Returns:
|
|
The result of the function execution
|
|
"""
|
|
# Get current execution context
|
|
current_context = get_current_context()
|
|
|
|
# Get agent instance (from context or global registry)
|
|
agent_instance = get_current_agent_instance()
|
|
|
|
if not agent_instance:
|
|
# No agent context - execute without tracking
|
|
if asyncio.iscoroutinefunction(func):
|
|
return await func(*args, **kwargs)
|
|
else:
|
|
return func(*args, **kwargs)
|
|
|
|
workflow_handler = getattr(agent_instance, "workflow_handler", None)
|
|
reasoner_name = getattr(func, "__name__", "reasoner")
|
|
|
|
# Generate execution metadata
|
|
# Build a child context when executing under an existing workflow; otherwise create a root context
|
|
if current_context:
|
|
execution_context = current_context.create_child_context()
|
|
execution_context.reasoner_name = reasoner_name
|
|
parent_context = current_context
|
|
else:
|
|
workflow_name = reasoner_name
|
|
if hasattr(agent_instance, "node_id"):
|
|
workflow_name = f"{agent_instance.node_id}_{workflow_name}"
|
|
execution_context = ExecutionContext.new_root(
|
|
agent_node_id=getattr(agent_instance, "node_id", "agent"),
|
|
reasoner_name=workflow_name,
|
|
)
|
|
execution_context.reasoner_name = reasoner_name
|
|
execution_context.agent_instance = agent_instance
|
|
parent_context = None
|
|
|
|
# Align run/session metadata with the parent context so registration inherits the workflow run
|
|
if parent_context:
|
|
execution_context.run_id = parent_context.run_id
|
|
execution_context.session_id = parent_context.session_id
|
|
execution_context.caller_did = parent_context.caller_did
|
|
execution_context.target_did = parent_context.target_did
|
|
execution_context.agent_node_did = parent_context.agent_node_did
|
|
execution_context.agent_instance = agent_instance
|
|
|
|
if workflow_handler is not None:
|
|
execution_context = await workflow_handler._ensure_execution_registered(
|
|
execution_context, reasoner_name, parent_context
|
|
)
|
|
|
|
previous_agent_context = getattr(agent_instance, "_current_execution_context", None)
|
|
agent_instance._current_execution_context = execution_context
|
|
|
|
client = getattr(agent_instance, "client", None)
|
|
previous_client_context = None
|
|
if client is not None:
|
|
previous_client_context = getattr(client, "_current_workflow_context", None)
|
|
client._current_workflow_context = execution_context
|
|
|
|
token = None
|
|
start_time = time.time()
|
|
parent_execution_id = parent_context.execution_id if parent_context else None
|
|
|
|
sig = inspect.signature(func)
|
|
call_kwargs = dict(kwargs or {})
|
|
input_data: Dict[str, Any] = {}
|
|
|
|
# Prepare DID-aware execution context so VC generation works for decorator-driven calls
|
|
did_execution_context = None
|
|
agent_has_did = getattr(agent_instance, "did_enabled", False) and getattr(
|
|
agent_instance, "did_manager", None
|
|
)
|
|
if agent_has_did:
|
|
try:
|
|
session_id = execution_context.session_id or execution_context.workflow_id
|
|
did_execution_context = agent_instance.did_manager.create_execution_context(
|
|
execution_context.execution_id,
|
|
execution_context.workflow_id,
|
|
session_id,
|
|
"agent",
|
|
reasoner_name,
|
|
)
|
|
if did_execution_context and hasattr(
|
|
agent_instance, "_populate_execution_context_with_did"
|
|
):
|
|
agent_instance._populate_execution_context_with_did(
|
|
execution_context, did_execution_context
|
|
)
|
|
except Exception as exc: # pragma: no cover - diagnostic only
|
|
if getattr(agent_instance, "dev_mode", False):
|
|
log_warn(f"Failed to build DID context for {reasoner_name}: {exc}")
|
|
did_execution_context = None
|
|
|
|
def _maybe_generate_vc(
|
|
status: str, result_payload: Any, duration_ms: int, error_message: Optional[str]
|
|
) -> None:
|
|
"""Fire-and-forget VC generation so decorator parity matches HTTP path."""
|
|
generate_vc = getattr(agent_instance, "_generate_vc_async", None)
|
|
vc_generator = getattr(agent_instance, "vc_generator", None)
|
|
if (
|
|
did_execution_context
|
|
and callable(generate_vc)
|
|
and hasattr(agent_instance, "_should_generate_vc")
|
|
and agent_instance._should_generate_vc(
|
|
reasoner_name, getattr(agent_instance, "_reasoner_vc_overrides", {})
|
|
)
|
|
):
|
|
asyncio.create_task(
|
|
generate_vc(
|
|
vc_generator,
|
|
did_execution_context,
|
|
reasoner_name,
|
|
input_data,
|
|
result_payload,
|
|
status=status,
|
|
error_message=error_message,
|
|
duration_ms=duration_ms,
|
|
)
|
|
)
|
|
|
|
try:
|
|
# Execute function with new context
|
|
token = set_execution_context(execution_context)
|
|
|
|
# Inject execution_context if the function accepts it
|
|
if "execution_context" in sig.parameters:
|
|
call_kwargs.setdefault("execution_context", execution_context)
|
|
|
|
# 🔥 NEW: Automatic Pydantic model conversion (FastAPI-like behavior)
|
|
try:
|
|
if should_convert_args(func):
|
|
converted_args, converted_kwargs = convert_function_args(
|
|
func, args, call_kwargs
|
|
)
|
|
args = converted_args
|
|
call_kwargs = converted_kwargs
|
|
except ValidationError as e:
|
|
# Re-raise validation errors with context
|
|
raise ValidationError(
|
|
f"Pydantic validation failed for reasoner '{func.__name__}': {e}",
|
|
model=getattr(e, "model", None),
|
|
) from e
|
|
except Exception as e:
|
|
# Log conversion errors but continue with original args for backward compatibility
|
|
if hasattr(agent_instance, "dev_mode") and agent_instance.dev_mode:
|
|
log_warn(f"Failed to convert arguments for {func.__name__}: {e}")
|
|
|
|
input_data = _build_input_payload(sig, args, call_kwargs)
|
|
|
|
start_payload = {
|
|
"reasoner_name": reasoner_name,
|
|
"args": list(args),
|
|
"kwargs": dict(call_kwargs),
|
|
"input_data": input_data,
|
|
"parent_execution_id": parent_execution_id,
|
|
}
|
|
await asyncio.create_task(
|
|
_send_workflow_start(
|
|
agent_instance,
|
|
execution_context,
|
|
start_payload,
|
|
)
|
|
)
|
|
|
|
if asyncio.iscoroutinefunction(func):
|
|
result = await func(*args, **call_kwargs)
|
|
else:
|
|
result = func(*args, **call_kwargs)
|
|
|
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
completion_payload = {
|
|
"input_data": input_data,
|
|
"parent_execution_id": parent_execution_id,
|
|
}
|
|
await asyncio.create_task(
|
|
_send_workflow_completion(
|
|
agent_instance,
|
|
execution_context,
|
|
result,
|
|
duration_ms,
|
|
completion_payload,
|
|
)
|
|
)
|
|
_maybe_generate_vc("success", result, duration_ms, None)
|
|
return result
|
|
except Exception as exc:
|
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
error_payload = {
|
|
"input_data": input_data,
|
|
"parent_execution_id": parent_execution_id,
|
|
}
|
|
_maybe_generate_vc("error", None, duration_ms, str(exc))
|
|
await asyncio.create_task(
|
|
_send_workflow_error(
|
|
agent_instance,
|
|
execution_context,
|
|
str(exc),
|
|
duration_ms,
|
|
error_payload,
|
|
)
|
|
)
|
|
raise
|
|
|
|
finally:
|
|
if token is not None:
|
|
reset_execution_context(token)
|
|
agent_instance._current_execution_context = previous_agent_context
|
|
if client is not None:
|
|
client._current_workflow_context = previous_client_context
|
|
|
|
|
|
def _build_input_payload(
|
|
signature: inspect.Signature, args: tuple, kwargs: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
if not signature.parameters:
|
|
return dict(kwargs)
|
|
|
|
try:
|
|
bound = signature.bind_partial(*args, **kwargs)
|
|
bound.apply_defaults()
|
|
except Exception:
|
|
payload = {f"arg_{idx}": value for idx, value in enumerate(args)}
|
|
payload.update(kwargs)
|
|
return payload
|
|
|
|
payload = {}
|
|
for name, value in bound.arguments.items():
|
|
if name == "self":
|
|
continue
|
|
payload[name] = value
|
|
return payload
|
|
|
|
|
|
def _compose_event_payload(
|
|
agent,
|
|
context: ExecutionContext,
|
|
reasoner_name: str,
|
|
status: str,
|
|
parent_execution_id: Optional[str],
|
|
input_data: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
event: Dict[str, Any] = {
|
|
"execution_id": context.execution_id,
|
|
"workflow_id": context.workflow_id,
|
|
"run_id": context.run_id,
|
|
"reasoner_id": reasoner_name,
|
|
"agent_node_id": getattr(agent, "node_id", None),
|
|
"status": status,
|
|
"type": reasoner_name,
|
|
"parent_execution_id": parent_execution_id,
|
|
"parent_workflow_id": context.parent_workflow_id,
|
|
}
|
|
if input_data is not None:
|
|
event["input_data"] = input_data
|
|
return event
|
|
|
|
|
|
def on_change(pattern: Union[str, List[str]]):
|
|
"""
|
|
Decorator to mark a function as a memory event listener.
|
|
|
|
Args:
|
|
pattern: Memory pattern(s) to listen for changes
|
|
|
|
Returns:
|
|
Decorated function with memory event listener metadata
|
|
"""
|
|
|
|
def decorator(func: Callable) -> Callable:
|
|
@functools.wraps(func)
|
|
async def wrapper(*args, **kwargs):
|
|
return await func(*args, **kwargs)
|
|
|
|
# Attach metadata to the function
|
|
wrapper._memory_event_listener = True
|
|
wrapper._memory_event_patterns = (
|
|
pattern if isinstance(pattern, list) else [pattern]
|
|
)
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
# Legacy support for old reasoner decorator signature
|
|
async def _send_workflow_start(
|
|
agent, context: ExecutionContext, payload: Dict[str, Any]
|
|
) -> None:
|
|
handler = getattr(agent, "workflow_handler", None)
|
|
if handler is None:
|
|
return
|
|
try:
|
|
reasoner_name = payload.get("reasoner_name", context.reasoner_name)
|
|
parent_execution_id = payload.get("parent_execution_id")
|
|
input_data = payload.get("input_data") or {}
|
|
|
|
if hasattr(handler, "notify_call_start"):
|
|
await handler.notify_call_start(
|
|
context.execution_id,
|
|
context,
|
|
reasoner_name,
|
|
input_data,
|
|
parent_execution_id=parent_execution_id,
|
|
)
|
|
elif hasattr(handler, "fire_and_forget_update"):
|
|
event_payload = _compose_event_payload(
|
|
agent,
|
|
context,
|
|
reasoner_name,
|
|
"running",
|
|
parent_execution_id,
|
|
input_data=input_data,
|
|
)
|
|
await handler.fire_and_forget_update(event_payload)
|
|
except Exception as exc: # pragma: no cover - logging pathway
|
|
if getattr(agent, "dev_mode", False):
|
|
log_warn(f"Failed to emit workflow start: {exc}")
|
|
|
|
|
|
async def _send_workflow_completion(
|
|
agent,
|
|
context: ExecutionContext,
|
|
result: Any,
|
|
duration_ms: int,
|
|
payload: Dict[str, Any],
|
|
) -> None:
|
|
handler = getattr(agent, "workflow_handler", None)
|
|
if handler is None:
|
|
return
|
|
try:
|
|
parent_execution_id = payload.get("parent_execution_id")
|
|
input_data = payload.get("input_data")
|
|
reasoner_name = context.reasoner_name
|
|
|
|
if hasattr(handler, "notify_call_complete"):
|
|
await handler.notify_call_complete(
|
|
context.execution_id,
|
|
context.workflow_id,
|
|
result,
|
|
duration_ms,
|
|
context,
|
|
input_data=input_data,
|
|
parent_execution_id=parent_execution_id,
|
|
)
|
|
elif hasattr(handler, "fire_and_forget_update"):
|
|
event_payload = _compose_event_payload(
|
|
agent,
|
|
context,
|
|
reasoner_name,
|
|
"succeeded",
|
|
parent_execution_id,
|
|
input_data=input_data,
|
|
)
|
|
event_payload["result"] = result
|
|
event_payload["duration_ms"] = duration_ms
|
|
await handler.fire_and_forget_update(event_payload)
|
|
except Exception as exc: # pragma: no cover - logging pathway
|
|
if getattr(agent, "dev_mode", False):
|
|
log_warn(f"Failed to emit workflow completion: {exc}")
|
|
|
|
|
|
async def _send_workflow_error(
|
|
agent,
|
|
context: ExecutionContext,
|
|
message: str,
|
|
duration_ms: int,
|
|
payload: Dict[str, Any],
|
|
) -> None:
|
|
handler = getattr(agent, "workflow_handler", None)
|
|
if handler is None:
|
|
return
|
|
try:
|
|
parent_execution_id = payload.get("parent_execution_id")
|
|
input_data = payload.get("input_data")
|
|
reasoner_name = context.reasoner_name
|
|
|
|
if hasattr(handler, "notify_call_error"):
|
|
await handler.notify_call_error(
|
|
context.execution_id,
|
|
context.workflow_id,
|
|
message,
|
|
duration_ms,
|
|
context,
|
|
input_data=input_data,
|
|
parent_execution_id=parent_execution_id,
|
|
)
|
|
elif hasattr(handler, "fire_and_forget_update"):
|
|
event_payload = _compose_event_payload(
|
|
agent,
|
|
context,
|
|
reasoner_name,
|
|
"failed",
|
|
parent_execution_id,
|
|
input_data=input_data,
|
|
)
|
|
event_payload["error"] = message
|
|
event_payload["duration_ms"] = duration_ms
|
|
await handler.fire_and_forget_update(event_payload)
|
|
except Exception as exc: # pragma: no cover - logging pathway
|
|
if getattr(agent, "dev_mode", False):
|
|
log_warn(f"Failed to emit workflow error: {exc}")
|
|
|
|
|
|
def legacy_reasoner(reasoner_id: str, input_schema: dict, output_schema: dict):
|
|
"""
|
|
Legacy reasoner decorator for backward compatibility.
|
|
|
|
This is kept for compatibility with existing code that uses the old signature.
|
|
New code should use the enhanced @reasoner decorator.
|
|
"""
|
|
|
|
def decorator(func):
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
return func(*args, **kwargs)
|
|
|
|
# Attach metadata to the function
|
|
wrapper._reasoner_def = ReasonerDefinition(
|
|
id=reasoner_id, input_schema=input_schema, output_schema=output_schema
|
|
)
|
|
return wrapper
|
|
|
|
return decorator
|