Files
hanzoai_agent/sdk/go/agent/agent.go
zeekay ff7ef7114f feat: merge agents control plane + SDKs into unified agent repo
Consolidate all Hanzo agent infrastructure into one repo:
- control-plane/: Go control plane server (was agents-new)
- sdk/: Go, Python, TypeScript SDKs
- deployments/: Docker, Kubernetes, Helm configs
- Dockerfile.sdk: Python SDK image (ghcr.io/hanzoai/agent:latest)
- CI builds both SDK + control-plane images

No more agentfield — everything is hanzoai/agent.
2026-02-13 12:24:31 -08:00

1281 lines
36 KiB
Go

package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/hanzoai/agents/sdk/go/ai"
"github.com/hanzoai/agents/sdk/go/client"
"github.com/hanzoai/agents/sdk/go/types"
)
type executionContextKey struct{}
// ExecutionContext captures the headers HanzoAgents sends with each execution request.
type ExecutionContext struct {
RunID string
ExecutionID string
ParentExecutionID string
SessionID string
ActorID string
WorkflowID string
ParentWorkflowID string
RootWorkflowID string
Depth int
AgentNodeID string
ReasonerName string
StartedAt time.Time
}
func init() {
rand.Seed(time.Now().UnixNano())
}
// HandlerFunc processes a reasoner invocation.
type HandlerFunc func(ctx context.Context, input map[string]any) (any, error)
// ReasonerOption applies metadata to a reasoner registration.
type ReasonerOption func(*Reasoner)
// WithInputSchema overrides the auto-generated input schema.
func WithInputSchema(raw json.RawMessage) ReasonerOption {
return func(r *Reasoner) {
if len(raw) > 0 {
r.InputSchema = raw
}
}
}
// WithOutputSchema overrides the default output schema.
func WithOutputSchema(raw json.RawMessage) ReasonerOption {
return func(r *Reasoner) {
if len(raw) > 0 {
r.OutputSchema = raw
}
}
}
// WithCLI marks this reasoner as CLI-accessible.
func WithCLI() ReasonerOption {
return func(r *Reasoner) {
r.CLIEnabled = true
}
}
// WithDefaultCLI marks the reasoner as the default CLI handler.
func WithDefaultCLI() ReasonerOption {
return func(r *Reasoner) {
r.CLIEnabled = true
r.DefaultCLI = true
}
}
// WithCLIFormatter registers a custom formatter for CLI output.
func WithCLIFormatter(formatter func(context.Context, any, error)) ReasonerOption {
return func(r *Reasoner) {
r.CLIFormatter = formatter
}
}
// WithDescription adds a human-readable description for help/list commands.
func WithDescription(desc string) ReasonerOption {
return func(r *Reasoner) {
r.Description = desc
}
}
// Reasoner represents a single handler exposed by the agent.
type Reasoner struct {
Name string
Handler HandlerFunc
InputSchema json.RawMessage
OutputSchema json.RawMessage
CLIEnabled bool
DefaultCLI bool
CLIFormatter func(context.Context, any, error)
Description string
}
// Config drives Agent behaviour.
type Config struct {
// NodeID is the unique identifier for this agent node. Required.
// Must be a non-empty identifier suitable for registration (alphanumeric
// characters, hyphens are recommended). Example: "my-agent-1".
NodeID string
// Version identifies the agent implementation version. Required.
// Typically in semver or short string form (e.g. "v1.2.3" or "1.0.0").
Version string
// TeamID groups related agents together for organization. Optional.
// Default: "default" (if empty, New() sets it to "default").
TeamID string
// HanzoAgentsURL is the base URL of the HanzoAgents control plane server.
// Optional for local-only or serverless usage, required when registering
// with a control plane or making cross-node calls. Default: empty.
// Format: a valid HTTP/HTTPS URL, e.g. "https://hanzo-agents.example.com".
HanzoAgentsURL string
// ListenAddress is the network address the agent HTTP server binds to.
// Optional. Default: ":8001" (if empty, New() sets it to ":8001").
// Format: "host:port" or ":port" (e.g. ":8001" or "0.0.0.0:8001").
ListenAddress string
// PublicURL is the public-facing base URL reported to the control plane.
// Optional. Default: "http://localhost" + ListenAddress (if empty,
// New() constructs a default using ListenAddress).
// Format: a valid HTTP/HTTPS URL.
PublicURL string
// Token is the bearer token used for authenticating to the control plane.
// Optional. Default: empty (no auth). When set, the token is sent as
// an Authorization: Bearer <token> header on control-plane requests.
Token string
// DeploymentType describes how the agent runs (affects execution behavior).
// Optional. Default: "long_running". Common values: "long_running",
// "serverless". Use a descriptive string for custom modes.
DeploymentType string
// LeaseRefreshInterval controls how frequently the agent refreshes its
// lease/heartbeat with the control plane. Optional.
// Default: 2m (2 minutes). Valid: any positive time.Duration.
LeaseRefreshInterval time.Duration
// DisableLeaseLoop disables automatic periodic lease refreshes.
// Optional. Default: false.
DisableLeaseLoop bool
// Logger is used for agent logging output. Optional.
// Default: a standard logger writing to stdout with the "[agent] " prefix
// (if nil, New() creates a default logger).
Logger *log.Logger
// AIConfig configures LLM/AI capabilities for the agent.
// Optional. If nil, AI features are disabled. Provide a valid
// *ai.Config to enable AI-related APIs.
AIConfig *ai.Config
// CLIConfig controls CLI-specific behaviour and help text.
// Optional. If nil, CLI behavior uses sensible defaults.
CLIConfig *CLIConfig
// MemoryBackend allows plugging in a custom memory storage backend.
// Optional. If nil, an in-memory backend is used (data lost on restart).
MemoryBackend MemoryBackend
}
// CLIConfig controls CLI behaviour and presentation.
type CLIConfig struct {
AppName string
AppDescription string
DisableColors bool
DefaultOutputFormat string
HelpPreamble string
HelpEpilog string
EnvironmentVars []string
}
// Agent manages registration, lease renewal, and HTTP routing.
type Agent struct {
cfg Config
client *client.Client
httpClient *http.Client
reasoners map[string]*Reasoner
aiClient *ai.Client // AI/LLM client
memory *Memory // Memory system for state management
serverMu sync.RWMutex
server *http.Server
stopLease chan struct{}
logger *log.Logger
router http.Handler
handlerOnce sync.Once
initMu sync.Mutex
initialized bool
leaseLoopOnce sync.Once
defaultCLIReasoner string
}
// New constructs an Agent.
func New(cfg Config) (*Agent, error) {
if cfg.NodeID == "" {
return nil, errors.New("config.NodeID is required")
}
if cfg.Version == "" {
return nil, errors.New("config.Version is required")
}
if cfg.TeamID == "" {
cfg.TeamID = "default"
}
if cfg.ListenAddress == "" {
cfg.ListenAddress = ":8001"
}
if cfg.PublicURL == "" {
cfg.PublicURL = "http://localhost" + cfg.ListenAddress
}
if strings.TrimSpace(cfg.DeploymentType) == "" {
cfg.DeploymentType = "long_running"
}
if cfg.LeaseRefreshInterval <= 0 {
cfg.LeaseRefreshInterval = 2 * time.Minute
}
if cfg.Logger == nil {
cfg.Logger = log.New(os.Stdout, "[agent] ", log.LstdFlags)
}
httpClient := &http.Client{
Timeout: 15 * time.Second,
}
// Initialize AI client if config provided
var aiClient *ai.Client
var err error
if cfg.AIConfig != nil {
aiClient, err = ai.NewClient(cfg.AIConfig)
if err != nil {
return nil, fmt.Errorf("initialize AI client: %w", err)
}
}
a := &Agent{
cfg: cfg,
httpClient: httpClient,
reasoners: make(map[string]*Reasoner),
aiClient: aiClient,
memory: NewMemory(cfg.MemoryBackend),
stopLease: make(chan struct{}),
logger: cfg.Logger,
}
if strings.TrimSpace(cfg.HanzoAgentsURL) != "" {
c, err := client.New(cfg.HanzoAgentsURL, client.WithHTTPClient(httpClient), client.WithBearerToken(cfg.Token))
if err != nil {
return nil, err
}
a.client = c
}
return a, nil
}
func contextWithExecution(ctx context.Context, exec ExecutionContext) context.Context {
return context.WithValue(ctx, executionContextKey{}, exec)
}
func executionContextFrom(ctx context.Context) ExecutionContext {
if ctx == nil {
return ExecutionContext{}
}
if val, ok := ctx.Value(executionContextKey{}).(ExecutionContext); ok {
return val
}
return ExecutionContext{}
}
// ChildContext creates a new execution context for a nested local call.
func (ec ExecutionContext) ChildContext(agentNodeID, reasonerName string) ExecutionContext {
runID := ec.RunID
if runID == "" {
runID = ec.WorkflowID
}
if runID == "" {
runID = generateRunID()
}
workflowID := ec.WorkflowID
if workflowID == "" {
workflowID = runID
}
rootWorkflowID := ec.RootWorkflowID
if rootWorkflowID == "" {
rootWorkflowID = workflowID
}
return ExecutionContext{
RunID: runID,
ExecutionID: generateExecutionID(),
ParentExecutionID: ec.ExecutionID,
SessionID: ec.SessionID,
ActorID: ec.ActorID,
WorkflowID: workflowID,
ParentWorkflowID: workflowID,
RootWorkflowID: rootWorkflowID,
Depth: ec.Depth + 1,
AgentNodeID: agentNodeID,
ReasonerName: reasonerName,
StartedAt: time.Now(),
}
}
func generateRunID() string {
return fmt.Sprintf("run_%d_%06d", time.Now().UnixNano(), rand.Intn(1_000_000))
}
func generateExecutionID() string {
return fmt.Sprintf("exec_%d_%06d", time.Now().UnixNano(), rand.Intn(1_000_000))
}
func cloneInputMap(input map[string]any) map[string]any {
if input == nil {
return nil
}
copied := make(map[string]any, len(input))
for k, v := range input {
copied[k] = v
}
return copied
}
func stringFromMap(m map[string]any, keys ...string) string {
for _, key := range keys {
if val, ok := m[key]; ok {
if str, ok := val.(string); ok && strings.TrimSpace(str) != "" {
return strings.TrimSpace(str)
}
}
}
return ""
}
func rawToMap(raw json.RawMessage) map[string]any {
if len(raw) == 0 {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return map[string]any{}
}
return out
}
// RegisterReasoner makes a handler available at /reasoners/{name}.
func (a *Agent) RegisterReasoner(name string, handler HandlerFunc, opts ...ReasonerOption) {
if handler == nil {
panic("nil handler supplied")
}
meta := &Reasoner{
Name: name,
Handler: handler,
InputSchema: json.RawMessage(`{"type":"object","additionalProperties":true}`),
OutputSchema: json.RawMessage(`{"type":"object","additionalProperties":true}`),
}
for _, opt := range opts {
opt(meta)
}
if meta.DefaultCLI {
if a.defaultCLIReasoner != "" && a.defaultCLIReasoner != name {
a.logger.Printf("warn: default CLI reasoner already set to %s, ignoring default flag on %s", a.defaultCLIReasoner, name)
meta.DefaultCLI = false
} else {
a.defaultCLIReasoner = name
}
}
a.reasoners[name] = meta
}
// Initialize registers the agent with the HanzoAgents control plane without starting a listener.
func (a *Agent) Initialize(ctx context.Context) error {
a.initMu.Lock()
defer a.initMu.Unlock()
if a.initialized {
return nil
}
if a.client == nil {
return errors.New("HanzoAgentsURL is required when running in server mode")
}
if len(a.reasoners) == 0 {
return errors.New("no reasoners registered")
}
if err := a.registerNode(ctx); err != nil {
return fmt.Errorf("register node: %w", err)
}
if err := a.markReady(ctx); err != nil {
a.logger.Printf("warn: initial status update failed: %v", err)
}
a.startLeaseLoop()
a.initialized = true
return nil
}
// Run intelligently routes between CLI and server modes.
func (a *Agent) Run(ctx context.Context) error {
args := os.Args[1:]
if len(args) == 0 && !a.hasCLIReasoners() {
return a.Serve(ctx)
}
if len(args) > 0 && args[0] == "serve" {
return a.Serve(ctx)
}
return a.runCLI(ctx, args)
}
// Serve starts the agent HTTP server, registers with the control plane, and blocks until ctx is cancelled.
func (a *Agent) Serve(ctx context.Context) error {
if err := a.Initialize(ctx); err != nil {
return err
}
if err := a.startServer(); err != nil {
return fmt.Errorf("start server: %w", err)
}
// listen for shutdown.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
select {
case <-ctx.Done():
return a.shutdown(context.Background())
case sig := <-sigCh:
a.logger.Printf("received signal %s, shutting down", sig)
return a.shutdown(context.Background())
}
}
func (a *Agent) registerNode(ctx context.Context) error {
now := time.Now().UTC()
reasoners := make([]types.ReasonerDefinition, 0, len(a.reasoners))
for _, reasoner := range a.reasoners {
reasoners = append(reasoners, types.ReasonerDefinition{
ID: reasoner.Name,
InputSchema: reasoner.InputSchema,
OutputSchema: reasoner.OutputSchema,
})
}
payload := types.NodeRegistrationRequest{
ID: a.cfg.NodeID,
TeamID: a.cfg.TeamID,
BaseURL: strings.TrimSuffix(a.cfg.PublicURL, "/"),
Version: a.cfg.Version,
Reasoners: reasoners,
Skills: []types.SkillDefinition{},
CommunicationConfig: types.CommunicationConfig{
Protocols: []string{"http"},
HeartbeatInterval: "0s",
},
HealthStatus: "healthy",
LastHeartbeat: now,
RegisteredAt: now,
Metadata: map[string]any{
"deployment": map[string]any{
"environment": "development",
"platform": "go",
},
"sdk": map[string]any{
"language": "go",
},
},
Features: map[string]any{},
DeploymentType: a.cfg.DeploymentType,
}
_, err := a.client.RegisterNode(ctx, payload)
if err != nil {
return err
}
a.logger.Printf("node %s registered with HanzoAgents", a.cfg.NodeID)
return nil
}
func (a *Agent) markReady(ctx context.Context) error {
score := 100
_, err := a.client.UpdateStatus(ctx, a.cfg.NodeID, types.NodeStatusUpdate{
Phase: "ready",
HealthScore: &score,
})
return err
}
func (a *Agent) startServer() error {
server := &http.Server{
Addr: a.cfg.ListenAddress,
Handler: a.Handler(),
}
a.serverMu.Lock()
a.server = server
a.serverMu.Unlock()
go func() {
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
a.logger.Printf("server error: %v", err)
}
}()
a.logger.Printf("listening on %s", a.cfg.ListenAddress)
return nil
}
// Handler exposes the agent as an http.Handler for serverless or custom hosting scenarios.
func (a *Agent) Handler() http.Handler {
return a.handler()
}
// ServeHTTP implements http.Handler directly.
func (a *Agent) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.Handler().ServeHTTP(w, r)
}
// Execute runs a specific reasoner by name.
func (a *Agent) Execute(ctx context.Context, reasonerName string, input map[string]any) (any, error) {
reasoner, ok := a.reasoners[reasonerName]
if !ok {
return nil, fmt.Errorf("unknown reasoner %q", reasonerName)
}
if input == nil {
input = make(map[string]any)
}
return reasoner.Handler(ctx, input)
}
// HandleServerlessEvent allows custom serverless entrypoints to normalize arbitrary
// platform events (Lambda, Vercel, Supabase, etc.) before delegating to the agent.
// The adapter can rewrite the incoming event into the generic payload that
// handleExecute expects: keys like path, target/reasoner, input, execution_context.
func (a *Agent) HandleServerlessEvent(ctx context.Context, event map[string]any, adapter func(map[string]any) map[string]any) (map[string]any, int, error) {
if adapter != nil {
event = adapter(event)
}
path := stringFromMap(event, "path", "rawPath")
reasoner := stringFromMap(event, "reasoner", "target", "skill")
if reasoner == "" && path != "" {
cleaned := strings.Trim(path, "/")
parts := strings.Split(cleaned, "/")
if len(parts) >= 2 && (parts[0] == "execute" || parts[0] == "reasoners" || parts[0] == "skills") {
reasoner = parts[1]
} else if len(parts) == 1 {
reasoner = parts[0]
}
}
if reasoner == "" {
return map[string]any{"error": "missing target or reasoner"}, http.StatusBadRequest, nil
}
input := extractInputFromServerless(event)
execCtx := a.buildExecutionContextFromServerless(&http.Request{Header: http.Header{}}, event, reasoner)
ctx = contextWithExecution(ctx, execCtx)
handler, ok := a.reasoners[reasoner]
if !ok {
return map[string]any{"error": "reasoner not found"}, http.StatusNotFound, nil
}
result, err := handler.Handler(ctx, input)
if err != nil {
return map[string]any{"error": err.Error()}, http.StatusInternalServerError, nil
}
// Normalize to map for consistent JSON responses.
if payload, ok := result.(map[string]any); ok {
return payload, http.StatusOK, nil
}
return map[string]any{"result": result}, http.StatusOK, nil
}
func (a *Agent) handler() http.Handler {
a.handlerOnce.Do(func() {
mux := http.NewServeMux()
mux.HandleFunc("/health", a.healthHandler)
mux.HandleFunc("/discover", a.handleDiscover)
mux.HandleFunc("/execute", a.handleExecute)
mux.HandleFunc("/execute/", a.handleExecute)
mux.HandleFunc("/reasoners/", a.handleReasoner)
a.router = mux
})
return a.router
}
func (a *Agent) healthHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
func (a *Agent) handleDiscover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, http.StatusOK, a.discoveryPayload())
}
func (a *Agent) discoveryPayload() map[string]any {
reasoners := make([]map[string]any, 0, len(a.reasoners))
for _, reasoner := range a.reasoners {
reasoners = append(reasoners, map[string]any{
"id": reasoner.Name,
"input_schema": rawToMap(reasoner.InputSchema),
"output_schema": rawToMap(reasoner.OutputSchema),
"tags": []string{},
})
}
deployment := strings.TrimSpace(a.cfg.DeploymentType)
if deployment == "" {
deployment = "long_running"
}
return map[string]any{
"node_id": a.cfg.NodeID,
"version": a.cfg.Version,
"deployment_type": deployment,
"reasoners": reasoners,
"skills": []map[string]any{},
}
}
func (a *Agent) handleExecute(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
targetName := strings.TrimPrefix(r.URL.Path, "/execute")
targetName = strings.TrimPrefix(targetName, "/")
var payload map[string]any
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
}
if payload == nil {
payload = make(map[string]any)
}
reasonerName := strings.TrimSpace(targetName)
if reasonerName == "" {
reasonerName = stringFromMap(payload, "reasoner", "target", "skill")
}
if reasonerName == "" {
http.Error(w, "missing target or reasoner", http.StatusBadRequest)
return
}
reasoner, ok := a.reasoners[reasonerName]
if !ok {
http.NotFound(w, r)
return
}
input := extractInputFromServerless(payload)
execCtx := a.buildExecutionContextFromServerless(r, payload, reasonerName)
ctx := contextWithExecution(r.Context(), execCtx)
result, err := reasoner.Handler(ctx, input)
if err != nil {
a.logger.Printf("reasoner %s failed: %v", reasonerName, err)
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
func extractInputFromServerless(payload map[string]any) map[string]any {
if payload == nil {
return map[string]any{}
}
if raw, ok := payload["input"]; ok {
if m, ok := raw.(map[string]any); ok {
return m
}
return map[string]any{"value": raw}
}
filtered := make(map[string]any)
for k, v := range payload {
switch strings.ToLower(k) {
case "target", "reasoner", "skill", "type", "target_type", "path", "execution_context", "executioncontext", "context":
continue
default:
filtered[k] = v
}
}
return filtered
}
func (a *Agent) buildExecutionContextFromServerless(r *http.Request, payload map[string]any, reasonerName string) ExecutionContext {
execCtx := ExecutionContext{
RunID: strings.TrimSpace(r.Header.Get("X-Run-ID")),
ExecutionID: strings.TrimSpace(r.Header.Get("X-Execution-ID")),
ParentExecutionID: strings.TrimSpace(r.Header.Get("X-Parent-Execution-ID")),
SessionID: strings.TrimSpace(r.Header.Get("X-Session-ID")),
ActorID: strings.TrimSpace(r.Header.Get("X-Actor-ID")),
WorkflowID: strings.TrimSpace(r.Header.Get("X-Workflow-ID")),
AgentNodeID: a.cfg.NodeID,
ReasonerName: reasonerName,
StartedAt: time.Now(),
}
if ctxMap, ok := payload["execution_context"].(map[string]any); ok {
if execCtx.ExecutionID == "" {
execCtx.ExecutionID = stringFromMap(ctxMap, "execution_id", "executionId")
}
if execCtx.RunID == "" {
execCtx.RunID = stringFromMap(ctxMap, "run_id", "runId")
}
if execCtx.WorkflowID == "" {
execCtx.WorkflowID = stringFromMap(ctxMap, "workflow_id", "workflowId")
}
if execCtx.ParentExecutionID == "" {
execCtx.ParentExecutionID = stringFromMap(ctxMap, "parent_execution_id", "parentExecutionId")
}
if execCtx.SessionID == "" {
execCtx.SessionID = stringFromMap(ctxMap, "session_id", "sessionId")
}
if execCtx.ActorID == "" {
execCtx.ActorID = stringFromMap(ctxMap, "actor_id", "actorId")
}
}
if execCtx.RunID == "" {
execCtx.RunID = generateRunID()
}
if execCtx.ExecutionID == "" {
execCtx.ExecutionID = generateExecutionID()
}
if execCtx.WorkflowID == "" {
execCtx.WorkflowID = execCtx.RunID
}
if execCtx.RootWorkflowID == "" {
execCtx.RootWorkflowID = execCtx.WorkflowID
}
if execCtx.ParentWorkflowID == "" && execCtx.ParentExecutionID != "" {
execCtx.ParentWorkflowID = execCtx.RootWorkflowID
}
return execCtx
}
func (a *Agent) handleReasoner(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/reasoners/")
if name == "" {
http.NotFound(w, r)
return
}
reasoner, ok := a.reasoners[name]
if !ok {
http.NotFound(w, r)
return
}
defer r.Body.Close()
var input map[string]any
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
execCtx := ExecutionContext{
RunID: r.Header.Get("X-Run-ID"),
ExecutionID: r.Header.Get("X-Execution-ID"),
ParentExecutionID: r.Header.Get("X-Parent-Execution-ID"),
SessionID: r.Header.Get("X-Session-ID"),
ActorID: r.Header.Get("X-Actor-ID"),
WorkflowID: r.Header.Get("X-Workflow-ID"),
AgentNodeID: a.cfg.NodeID,
ReasonerName: name,
StartedAt: time.Now(),
}
if execCtx.WorkflowID == "" {
execCtx.WorkflowID = execCtx.RunID
}
if execCtx.RootWorkflowID == "" {
execCtx.RootWorkflowID = execCtx.WorkflowID
}
ctx := contextWithExecution(r.Context(), execCtx)
// In serverless mode we want a synchronous execution so the control plane can return
// the result immediately; skip the async path even if an execution ID is present.
if a.cfg.DeploymentType != "serverless" && execCtx.ExecutionID != "" && strings.TrimSpace(a.cfg.HanzoAgentsURL) != "" {
go a.executeReasonerAsync(reasoner, cloneInputMap(input), execCtx)
writeJSON(w, http.StatusAccepted, map[string]any{
"status": "processing",
"execution_id": execCtx.ExecutionID,
"run_id": execCtx.RunID,
"reasoner_name": name,
})
return
}
result, err := reasoner.Handler(ctx, input)
if err != nil {
a.logger.Printf("reasoner %s failed: %v", name, err)
response := map[string]any{
"error": err.Error(),
}
writeJSON(w, http.StatusInternalServerError, response)
return
}
writeJSON(w, http.StatusOK, result)
}
func (a *Agent) executeReasonerAsync(reasoner *Reasoner, input map[string]any, execCtx ExecutionContext) {
ctx := contextWithExecution(context.Background(), execCtx)
start := time.Now()
defer func() {
if rec := recover(); rec != nil {
errMsg := fmt.Sprintf("panic: %v", rec)
payload := map[string]any{
"status": "failed",
"error": errMsg,
"execution_id": execCtx.ExecutionID,
"run_id": execCtx.RunID,
"completed_at": time.Now().UTC().Format(time.RFC3339),
"duration_ms": time.Since(start).Milliseconds(),
"reasoner_name": reasoner.Name,
}
if err := a.sendExecutionStatus(execCtx.ExecutionID, payload); err != nil {
a.logger.Printf("failed to send panic status: %v", err)
}
}
}()
result, err := reasoner.Handler(ctx, input)
payload := map[string]any{
"execution_id": execCtx.ExecutionID,
"run_id": execCtx.RunID,
"completed_at": time.Now().UTC().Format(time.RFC3339),
"duration_ms": time.Since(start).Milliseconds(),
"reasoner_name": reasoner.Name,
}
if err != nil {
payload["status"] = "failed"
payload["error"] = err.Error()
} else {
payload["status"] = "succeeded"
payload["result"] = result
}
if err := a.sendExecutionStatus(execCtx.ExecutionID, payload); err != nil {
a.logger.Printf("async status update failed: %v", err)
}
}
func (a *Agent) sendExecutionStatus(executionID string, payload map[string]any) error {
base := strings.TrimSpace(a.cfg.HanzoAgentsURL)
if executionID == "" || base == "" {
return fmt.Errorf("missing execution id or HanzoAgents URL")
}
callbackURL := strings.TrimSuffix(base, "/") + "/api/v1/executions/" + url.PathEscape(executionID) + "/status"
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode status payload: %w", err)
}
return a.postExecutionStatus(context.Background(), callbackURL, payloadBytes)
}
func (a *Agent) postExecutionStatus(ctx context.Context, callbackURL string, payload []byte) error {
var lastErr error
for attempt := 0; attempt < 5; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, callbackURL, bytes.NewReader(payload))
if err != nil {
cancel()
return fmt.Errorf("create status request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
lastErr = err
} else {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
cancel()
return nil
}
lastErr = fmt.Errorf("status update returned %d", resp.StatusCode)
}
cancel()
if attempt < 4 {
time.Sleep(time.Second << attempt)
}
}
return lastErr
}
// Call invokes another reasoner via the HanzoAgents control plane, preserving execution context.
func (a *Agent) Call(ctx context.Context, target string, input map[string]any) (map[string]any, error) {
if strings.TrimSpace(a.cfg.HanzoAgentsURL) == "" {
return nil, errors.New("HanzoAgentsURL is required to call other reasoners")
}
if !strings.Contains(target, ".") {
target = fmt.Sprintf("%s.%s", a.cfg.NodeID, strings.TrimPrefix(target, "."))
}
execCtx := executionContextFrom(ctx)
runID := execCtx.RunID
if runID == "" {
runID = generateRunID()
}
payload := map[string]any{"input": input}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal call payload: %w", err)
}
url := fmt.Sprintf("%s/api/v1/execute/%s", strings.TrimSuffix(a.cfg.HanzoAgentsURL, "/"), strings.TrimPrefix(target, "/"))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Run-ID", runID)
if execCtx.ExecutionID != "" {
req.Header.Set("X-Parent-Execution-ID", execCtx.ExecutionID)
}
if execCtx.WorkflowID != "" {
req.Header.Set("X-Workflow-ID", execCtx.WorkflowID)
}
if execCtx.SessionID != "" {
req.Header.Set("X-Session-ID", execCtx.SessionID)
}
if execCtx.ActorID != "" {
req.Header.Set("X-Actor-ID", execCtx.ActorID)
}
if a.cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+a.cfg.Token)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("perform execute call: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read execute response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("execute failed: %s", strings.TrimSpace(string(bodyBytes)))
}
var execResp struct {
ExecutionID string `json:"execution_id"`
RunID string `json:"run_id"`
Status string `json:"status"`
Result map[string]any `json:"result"`
ErrorMessage *string `json:"error_message"`
}
if err := json.Unmarshal(bodyBytes, &execResp); err != nil {
return nil, fmt.Errorf("decode execute response: %w", err)
}
if execResp.ErrorMessage != nil && *execResp.ErrorMessage != "" {
return nil, fmt.Errorf("execute error: %s", *execResp.ErrorMessage)
}
if !strings.EqualFold(execResp.Status, "succeeded") {
return nil, fmt.Errorf("execute status %s", execResp.Status)
}
return execResp.Result, nil
}
// emitWorkflowEvent sends a workflow event to the control plane asynchronously.
// Failures are logged but do not impact the caller.
func (a *Agent) emitWorkflowEvent(
execCtx ExecutionContext,
status string,
input map[string]any,
result any,
err error,
durationMS int64,
) {
if strings.TrimSpace(a.cfg.HanzoAgentsURL) == "" {
return
}
event := types.WorkflowExecutionEvent{
ExecutionID: execCtx.ExecutionID,
WorkflowID: execCtx.WorkflowID,
RunID: execCtx.RunID,
ReasonerID: execCtx.ReasonerName,
Type: execCtx.ReasonerName,
AgentNodeID: a.cfg.NodeID,
Status: status,
}
if execCtx.ParentExecutionID != "" {
event.ParentExecutionID = &execCtx.ParentExecutionID
}
if execCtx.ParentWorkflowID != "" {
event.ParentWorkflowID = &execCtx.ParentWorkflowID
}
if input != nil {
event.InputData = input
}
if result != nil {
event.Result = result
}
if err != nil {
event.Error = err.Error()
}
if durationMS > 0 {
event.DurationMS = &durationMS
}
if sendErr := a.sendWorkflowEvent(event); sendErr != nil {
a.logger.Printf("workflow event send failed: %v", sendErr)
}
}
func (a *Agent) sendWorkflowEvent(event types.WorkflowExecutionEvent) error {
url := strings.TrimSuffix(a.cfg.HanzoAgentsURL, "/") + "/api/v1/workflow/executions/events"
body, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if a.cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+a.cfg.Token)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("server returned %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
}
return nil
}
// CallLocal invokes a registered reasoner directly within this agent process,
// maintaining execution lineage and emitting workflow events to the control plane.
// It should be used for same-node composition; use Call for cross-node calls.
func (a *Agent) CallLocal(ctx context.Context, reasonerName string, input map[string]any) (any, error) {
reasoner, ok := a.reasoners[reasonerName]
if !ok {
return nil, fmt.Errorf("unknown reasoner %q", reasonerName)
}
parentCtx := executionContextFrom(ctx)
childCtx := a.buildChildContext(parentCtx, reasonerName)
ctx = contextWithExecution(ctx, childCtx)
a.emitWorkflowEvent(childCtx, "running", input, nil, nil, 0)
start := time.Now()
result, err := reasoner.Handler(ctx, input)
durationMS := time.Since(start).Milliseconds()
if err != nil {
a.emitWorkflowEvent(childCtx, "failed", input, nil, err, durationMS)
} else {
a.emitWorkflowEvent(childCtx, "succeeded", input, result, nil, durationMS)
}
return result, err
}
func (a *Agent) buildChildContext(parent ExecutionContext, reasonerName string) ExecutionContext {
if parent.RunID == "" && parent.ExecutionID == "" {
runID := generateRunID()
return ExecutionContext{
RunID: runID,
ExecutionID: generateExecutionID(),
SessionID: parent.SessionID,
ActorID: parent.ActorID,
WorkflowID: runID,
RootWorkflowID: runID,
Depth: 0,
AgentNodeID: a.cfg.NodeID,
ReasonerName: reasonerName,
StartedAt: time.Now(),
}
}
return parent.ChildContext(a.cfg.NodeID, reasonerName)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if payload == nil {
return
}
if err := json.NewEncoder(w).Encode(payload); err != nil {
// best-effort fallback
_, _ = w.Write([]byte(`{}`))
}
}
func (a *Agent) startLeaseLoop() {
if a.cfg.DisableLeaseLoop || a.cfg.LeaseRefreshInterval <= 0 {
return
}
a.leaseLoopOnce.Do(func() {
ticker := time.NewTicker(a.cfg.LeaseRefreshInterval)
go func() {
for {
select {
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := a.markReady(ctx); err != nil {
a.logger.Printf("lease refresh failed: %v", err)
}
cancel()
case <-a.stopLease:
ticker.Stop()
return
}
}
}()
})
}
func (a *Agent) shutdown(ctx context.Context) error {
close(a.stopLease)
if _, err := a.client.Shutdown(ctx, a.cfg.NodeID, types.ShutdownRequest{Reason: "shutdown"}); err != nil {
a.logger.Printf("failed to notify shutdown: %v", err)
}
a.serverMu.RLock()
server := a.server
a.serverMu.RUnlock()
if server != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return err
}
}
return nil
}
// AI makes an AI/LLM call with the given prompt and options.
// Returns an error if AI is not configured for this agent.
//
// Example usage:
//
// response, err := agent.AI(ctx, "What is the weather?",
// ai.WithSystem("You are a weather assistant"),
// ai.WithTemperature(0.7))
func (a *Agent) AI(ctx context.Context, prompt string, opts ...ai.Option) (*ai.Response, error) {
if a.aiClient == nil {
return nil, errors.New("AI not configured for this agent; set AIConfig in agent Config")
}
return a.aiClient.Complete(ctx, prompt, opts...)
}
// AIStream makes a streaming AI/LLM call.
// Returns channels for streaming chunks and errors.
//
// Example usage:
//
// chunks, errs := agent.AIStream(ctx, "Tell me a story")
// for chunk := range chunks {
// fmt.Print(chunk.Choices[0].Delta.Content)
// }
// if err := <-errs; err != nil {
// log.Fatal(err)
// }
func (a *Agent) AIStream(ctx context.Context, prompt string, opts ...ai.Option) (<-chan ai.StreamChunk, <-chan error) {
if a.aiClient == nil {
errCh := make(chan error, 1)
errCh <- errors.New("AI not configured for this agent; set AIConfig in agent Config")
close(errCh)
chunkCh := make(chan ai.StreamChunk)
close(chunkCh)
return chunkCh, errCh
}
return a.aiClient.StreamComplete(ctx, prompt, opts...)
}
// ExecutionContextFrom returns the execution context embedded in the provided context, if any.
func ExecutionContextFrom(ctx context.Context) ExecutionContext {
return executionContextFrom(ctx)
}
// Memory returns the agent's memory system for state management.
// Memory provides hierarchical scoped storage (workflow, session, user, global).
//
// Example usage:
//
// // Store in default (session) scope
// agent.Memory().Set(ctx, "key", "value")
//
// // Retrieve from session scope
// val, _ := agent.Memory().Get(ctx, "key")
//
// // Use global scope for cross-session data
// agent.Memory().GlobalScope().Set(ctx, "shared_key", data)
func (a *Agent) Memory() *Memory {
return a.memory
}