Skip to content

dropdevrahul/herald

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Herald

Herald is a general-purpose Go framework for composing LLM workflows and building LLM agents. It provides a lightweight alternative to Python-based frameworks like LangChain/LangGraph.

Status

Herald is in active development. The API surface is established and functional. Contributions and feedback welcome.

Features

  • Generic Agent Runtime - Provider-agnostic multi-turn tool-calling loop with stop conditions
  • Memory - Pluggable conversation memory (buffer / sliding window) for cross-run continuity
  • Human-in-the-Loop - Approve, deny, or rewrite tool calls before they execute
  • Sub-Agents - Wrap any agent as a tool to compose multi-agent systems
  • Observability - Lifecycle hooks for turns, model responses, and tool calls
  • Simple Workflows - Sequential chaining, orchestration, and parallel execution
  • Graph-based Workflows - Directed graphs with nodes, edges, and conditional routing
  • Tool Calling - Define and execute tools/functions during workflow execution
  • Streaming Support - Real-time token streaming for all workflows
  • Multi-Provider Support - OpenAI, Groq, Anthropic, Gemini compatible

Requirements

  • Go 1.24+

Installation

go get github.com/dropdevrahul/herald

Quick Start

Simple Workflows

import (
    "github.com/dropdevrahul/herald/src/model"
    "github.com/dropdevrahul/herald/src/model/openai"
    "github.com/dropdevrahul/herald/src/worklows"
)

client := openai.NewClient(apiKey, "https://api.groq.com/openai/v1")
m := openai.NewOpenAIModel(model.ModelOptions{Model: "llama-3.3-70b-versatile"}, client)

node := workflows.Node{
    Name:   "assistant",
    Prompt: "You are a helpful assistant.",
}

wf := workflows.NewChainingWorkflow(m, []workflows.Node{node})
output, _ := wf.Run(ctx, "Hello!")

Graph Workflows

g := workflows.NewGraph(m).
    AddNode("chat", "You are a helpful assistant.", func(ctx, state) (any, error) { ... }).
    AddEdge("chat", "end").
    SetStart("chat")

compiled, _ := g.Compile()
result, _ := compiled.Run(ctx, "input")

Checkpointing

Attach a Checkpointer to a compiled graph so each node's output is saved durably. Re-running the same threadID resumes from the last completed node instead of restarting from scratch.

cg, _ := graph.Compile()
cg.WithCheckpointer(workflows.NewFileCheckpointer("./checkpoints"))
out, _ := cg.RunThread(ctx, "thread-1", "input")

Use workflows.NewMemoryCheckpointer() for in-process persistence (e.g. tests), or workflows.NewFileCheckpointer(dir) for durability across process restarts. The Checkpointer interface can be implemented to back checkpoints with any store (database, object storage, etc.).

With Tools

type MyTool struct{}

func (t *MyTool) Name() string        { return "my_tool" }
func (t *MyTool) Description() string { return "Does something useful" }
func (t *MyTool) Call(ctx, args) (string, error) { ... }

wf := workflows.NewChainingWorkflow(m, nodes, &MyTool{})

(Workflow tools also implement Parameters() map[string]any, returning a JSON-schema description of their arguments.)

Functional Tools

Define a tool from a plain function — no struct required:

import "github.com/dropdevrahul/herald/src/agents"

tool := agents.NewFuncTool(
    "echo",
    "Echoes the input back to the caller",
    map[string]any{"type": "object", "properties": map[string]any{
        "text": map[string]any{"type": "string"},
    }},
    func(ctx context.Context, args string) (string, error) {
        return args, nil
    },
)

agent := agents.NewAgent(m, agents.AgentConfig{
    SystemPrompt: "You are a helpful assistant.",
    Tools:        []workflows.Tool{tool},
})

Pass nil for the parameters map to get a minimal valid JSON-schema object automatically.

Agents

The agents package provides a generic, provider-agnostic agent runtime. It loops against any model.Model, dispatching tool calls until the model stops requesting them, a stop condition fires, or the turn budget is exhausted.

import "github.com/dropdevrahul/herald/src/agents"

agent := agents.NewAgent(m, agents.AgentConfig{
    SystemPrompt: "You are a helpful assistant.",
    Tools:        []workflows.Tool{&MyTool{}},
    MaxTurns:     5,   // default 5
    Temperature:  0.7, // default 0.7
})

answer, _ := agent.Run(ctx, "What is 15 + 27?")

AgentConfig fields are all optional beyond Tools/SystemPrompt:

  • Memory — seed the run from prior messages and persist new turns back, for continuity across Run calls (see below).
  • Approver — a human-in-the-loop gate consulted before each tool call.
  • Stopfunc(turn int, lastContent string) bool to end a run early.
  • Hooks — observe lifecycle events.
  • ToolTimeout — per-call deadline; 0 means no timeout.

The coding agents (NewCodingAgentWithTools) are thin presets over this runtime.

Memory

import "github.com/dropdevrahul/herald/src/memory"

agent := agents.NewAgent(m, agents.AgentConfig{
    SystemPrompt: "You are a helpful assistant.",
    Memory:       memory.NewBufferMemory(),     // retains every message
    // or memory.NewWindowMemory(10)            // keeps the last N non-system messages
})

agent.Run(ctx, "My name is Ada.")
agent.Run(ctx, "What is my name?")  // remembers the first turn

Use memory.NewFileMemory(path) for disk-backed memory that survives process restarts — messages are persisted as JSON on every write and reloaded on construction:

mem, err := memory.NewFileMemory("/var/lib/myapp/session.json")
if err != nil {
    log.Fatal(err)
}
agent := agents.NewAgent(m, agents.AgentConfig{
    SystemPrompt: "You are a helpful assistant.",
    Memory:       mem,
})

Structured Output

model.GenerateJSON calls a model, extracts the first JSON object or array from the response (tolerating Markdown fences and surrounding prose), and unmarshals it into a Go value:

import "github.com/dropdevrahul/herald/src/model"

type Result struct {
    Summary string `json:"summary"`
    Score   int    `json:"score"`
}

var out Result
err := model.GenerateJSON(ctx, m, messages, opts, &out)
if err != nil {
    log.Fatal(err)
}
fmt.Println(out.Summary, out.Score)

For a streaming variant, model.GenerateJSONStream streams the response to an onDelta callback for live display, then unmarshals the full JSON once complete (partial JSON can't be decoded mid-stream):

err := model.GenerateJSONStream(ctx, m, messages, opts, &out, func(delta string) {
    fmt.Print(delta)
})

Resilience (Retry)

model.NewRetryModel wraps any model.Model and retries failed calls with exponential backoff (100ms * 2^attempt). The second argument is the number of extra attempts after the first:

import "github.com/dropdevrahul/herald/src/model"

base := openai.NewOpenAIModel(model.ModelOptions{Model: "llama-3.3-70b-versatile"}, client)
m := model.NewRetryModel(base, 3) // up to 4 attempts total

resp, err := m.Generate(ctx, messages, opts)

Generate retries on any error. Stream only restarts before the first delta/content is emitted — once output has been forwarded the stream is partially consumed, so errors after that point propagate unchanged (no mid-stream resume).

Human-in-the-Loop

agent := agents.NewAgent(m, agents.AgentConfig{
    Tools: []workflows.Tool{&ShellTool{}},
    Approver: func(ctx context.Context, call model.ToolCall) (agents.ApprovalDecision, error) {
        if call.Function.Name == "shell" {
            return agents.ApprovalDecision{Approved: false, Reason: "shell disabled"}, nil
        }
        return agents.ApprovalDecision{Approved: true}, nil
    },
})

Return Approved: false to deny (the reason is fed back to the model), Approved: true, Args: "..." to rewrite the arguments, or an error to abort the run.

Durable Human-in-the-Loop

Use RunThread + Resume when you need human approval that can survive a process restart. Configure a Checkpointer and an InterruptBefore predicate on AgentConfig; when the predicate returns true for a tool call the run pauses and persists its full state before executing that call.

import (
    "github.com/dropdevrahul/herald/src/agents"
    "github.com/dropdevrahul/herald/src/worklows"
)

agent := agents.NewAgent(m, agents.AgentConfig{
    Tools: []workflows.Tool{&DeleteFileTool{}},
    Checkpointer: workflows.NewFileCheckpointer("./checkpoints"),
    InterruptBefore: func(c model.ToolCall) bool {
        return c.Function.Name == "delete_file"
    },
})

res, _ := agent.RunThread(ctx, "thread-1", "Remove the temp directory.")
if res.Interrupt != nil {
    // Run paused. Obtain a human decision, then resume — even in a new process.
    agent.Resume(ctx, "thread-1", agents.ApprovalDecision{Approved: true})
}

Resume reloads the persisted state for the given threadID via the Checkpointer, so it can be called in a completely separate process after the original run exits.

Sub-Agents

Wrap an agent as a tool so a parent agent can delegate to it:

researcher := agents.NewAgent(m, agents.AgentConfig{SystemPrompt: "You research topics."})
tool := agents.NewAgentTool("researcher", "Delegate research tasks", researcher)

coordinator := agents.NewAgent(m, agents.AgentConfig{
    SystemPrompt: "You coordinate work by delegating to sub-agents.",
    Tools:        []workflows.Tool{tool},
})

Tool Resilience

Tool panics are always recovered and returned to the model as an Error: tool panicked: ... result, so a misbehaving tool cannot crash the agent run. To prevent a slow tool from blocking indefinitely, set ToolTimeout:

agent := agents.NewAgent(m, agents.AgentConfig{
    Tools:       []workflows.Tool{&MyTool{}},
    ToolTimeout: 30 * time.Second, // 0 means no timeout
})

When the deadline is exceeded the model receives Error: context deadline exceeded as the tool result and the run continues normally.

Observability

agent := agents.NewAgent(m, agents.AgentConfig{
    Tools: []workflows.Tool{&MyTool{}},
    Hooks: []agents.Hook{
        func(ctx context.Context, ev agents.Event) {
            log.Printf("[%s] turn=%d tool=%s", ev.Type, ev.Turn, ev.Tool)
        },
    },
})

Events are emitted with Type of turn_start, model_response, tool_start, tool_end, and finish.

Token Usage

agent.RunResult returns an AgentResult carrying the final content, the number of turns executed, and aggregated token counts across all turns:

import "github.com/dropdevrahul/herald/src/agents"

res, err := agent.RunResult(ctx, "Summarise this document.")
if err != nil {
    log.Fatal(err)
}
fmt.Println(res.Content)
fmt.Printf("tokens used: prompt=%d completion=%d total=%d (turns=%d)\n",
    res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.TotalTokens, res.Turns)

AgentResult fields:

Field Type Description
Content string Final model response text.
Usage model.Usage Token counts summed across every turn in the run.
Turns int Number of turns executed (including tool-call turns).

Project Structure

herald/
├── cmd/herald/          # Bubble Tea TUI coding agent
├── internal/
│   ├── config/          # Provider config + API keys
│   └── session/         # Persistent sessions (~/.herald)
├── src/
│   ├── model/           # Model interfaces & implementations
│   │   ├── model.go     # Core interfaces
│   │   ├── openai/      # OpenAI/Groq/Azure provider
│   │   ├── anthropic/   # Anthropic provider
│   │   └── gemini/      # Google Gemini provider
│   ├── memory/          # Conversation memory (buffer / window)
│   ├── agents/          # Generic agent runtime + tools
│   └── worklows/        # Workflow implementations
│       ├── workflows.go # Simple workflows
│       └── graph.go     # Graph-based workflows
├── go.mod
└── README.md

License

MIT License - see LICENSE

About

A go based agent building framework

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages