Files
zeekayandGitHub 5b5cf9e9db feat: merge AgentField control plane + SDKs as Hanzo Agents (#1)
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.
2026-02-09 18:53:09 -08:00

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