sherma

Declarative Agents

Declarative agents let you define an entire LangGraph agent in a single YAML file – the graph topology, prompts, LLMs, tools, skills, and routing logic. Dynamic behavior is expressed with CEL (Common Expression Language) expressions evaluated against the agent’s state at runtime.

YAML Structure

A declarative agent YAML has these top-level sections:

manifest_version: 1   # Required: schema version (currently 1)
prompts:      # Prompt definitions
llms:         # LLM declarations
tools:        # Tool imports
skills:       # Skill card references
sub_agents:   # Sub-agent declarations (for multi-agent orchestration)
hooks:        # Hook executor imports
checkpointer: # Checkpointer configuration (for state persistence)
default_llm:  # Default LLM for call_llm nodes (optional)
agents:       # Agent graph definitions

Manifest Version

Every declarative agent YAML must include a manifest_version field as a top-level integer. This tracks the version of the declarative agent schema that the YAML uses, allowing a single DeclarativeAgent runtime to handle agent.yaml files with varying manifest versions simultaneously.

The current manifest version is 1. Increment it when breaking changes are made to the schema.

manifest_version: 1

All entity registrations and the graph definition live in one file, giving you a complete snapshot of the agent.

Entity Declarations

Prompts

prompts:
  - id: my-prompt
    version: "1.0.0"
    instructions: >
      You are a helpful assistant.
      Be concise and accurate.

Prompts are accessible in CEL expressions via prompts["my-prompt"]["instructions"].

LLMs

llms:
  - id: openai-gpt-4o-mini
    version: "1.0.0"
    provider: openai        # Currently supports "openai"
    model_name: gpt-4o-mini

The LLM provider reads API keys from environment variables (e.g., OPENAI_API_KEY).

Tools

Tools reference Python callables by import path:

tools:
  - id: get_weather
    version: "1.0.0"
    import_path: my_package.tools.get_weather

The import path should point to a LangChain/LangGraph @tool-decorated function.

Skills

skills:
  - id: weather
    version: "1.0.0"
    skill_card_path: ../skills/weather/skill-card.json  # Relative to YAML file
    # url: https://example.com/skill-card.json  # Or remote URL

See Skills for details on skill cards and progressive disclosure.

Hooks

hooks:
  - import_path: my_package.hooks.LoggingHook
  - import_path: my_package.hooks.GuardrailHook

Hooks can also be passed programmatically via the DeclarativeAgent constructor. See Hooks.

Sub-Agents

Declare other agents as sub-agents to enable multi-agent orchestration. Sub-agents are automatically wrapped as LangGraph tools that the supervisor LLM can invoke. See Multi-Agent for the full guide.

sub_agents:
  - id: weather-agent
    version: "1.0.0"
    yaml_path: weather-agent.yaml               # Relative to this YAML file

  - id: search-agent
    version: "1.0.0"
    import_path: my_agents.search_agent          # From a Python module

  - id: remote-agent
    version: "1.0.0"
    url: https://remote-agent.example.com        # Remote A2A agent

  - id: pre-registered-agent
    version: "1.0.0"
    # No source -- expects the agent to already be in the registry

Checkpointer

The checkpointer enables state persistence across graph invocations, which is required for features like interrupt nodes (human-in-the-loop). By default, DeclarativeAgent uses an in-memory checkpointer (MemorySaver), so you don’t need to configure anything for basic usage.

To explicitly declare a checkpointer in YAML:

checkpointer:
  type: memory    # In-memory checkpointer (currently the only supported type)

You can also pass a checkpointer programmatically via the constructor:

from langgraph.checkpoint.memory import MemorySaver

agent = DeclarativeAgent(
    id="my-agent",
    version="1.0.0",
    yaml_path="agent.yaml",
    checkpointer=MemorySaver(),
)

When a checkpointer is active, all graph invocations require a thread_id in the config to identify the conversation thread. The send_message method handles this automatically using context_id, task_id, or a generated UUID.

Default LLM

When multiple call_llm nodes use the same LLM, you can set a top-level default_llm instead of repeating the llm field on every node:

manifest_version: 1

default_llm:
  id: openai-gpt-4o-mini

llms:
  - id: openai-gpt-4o-mini
    version: "1.0.0"
    provider: openai
    model_name: gpt-4o-mini

agents:
  my-agent:
    state:
      fields:
        - name: messages
          type: list
          default: []
    graph:
      entry_point: agent
      nodes:
        - name: agent
          type: call_llm
          args:
            # No llm field -- inherits from default_llm
            prompt:
              - role: system
                content: '"You are helpful."'
              - role: messages
                content: 'messages'
      edges: []

A step-level llm always takes precedence over default_llm. If neither is set, graph construction raises an error.

Agent Definition

Each agent is defined under the agents key:

agents:
  my-agent:
    state:
      fields:
        - name: messages
          type: list
          default: []
        - name: counter
          type: int
          default: 0

    graph:
      entry_point: first_node

      nodes:
        - name: first_node
          type: call_llm
          args: ...

      edges:
        - source: first_node
          target: __end__

State Schema

The state section defines the agent’s state shape. Supported types: str, int, float, bool, list, dict.

If a field named messages is present (type list), sherma uses LangGraph’s MessagesState as the base class, which provides the standard message accumulation behavior.

State fields are available in all CEL expressions.

Node Types

call_llm

Calls an LLM with a prompt and optional tool bindings. The llm field can be omitted when a top-level default_llm is configured (see Default LLM).

- name: agent
  type: call_llm
  args:
    llm:                            # Optional when default_llm is set
      id: openai-gpt-4o-mini
      version: "1.0.0"
    prompt:
      - role: system
        content: 'prompts["my-prompt"]["instructions"]'
      - role: messages
        content: 'messages'
    tools:                          # Optional: bind specific tools
      - id: get_weather
        version: "1.0.0"
    # use_tools_from_registry: true       # Or: bind ALL registered tools
    # use_tools_from_loaded_skills: true   # Or: bind tools from loaded skills
    # use_sub_agents_as_tools: true        # Or: bind all sub-agents as tools
    # use_sub_agents_as_tools:              # Or: bind specific sub-agents
    #   - id: weather-agent
    #     version: "1.0.0"

Prompt Format

The prompt field is an array of message items. Each item has a role and a content (a CEL expression):

Role Behavior
system CEL evaluates to a string, wrapped as a SystemMessage
human CEL evaluates to a string, wrapped as a HumanMessage
ai CEL evaluates to a string, wrapped as an AIMessage
messages CEL evaluates to a list of messages, spliced in place preserving their original roles

The messages role is how you inject conversation history into the prompt. State messages are never auto-injected – you must explicitly include them with role: messages. This gives you full control over where conversation history appears relative to system instructions and other messages.

# Typical pattern: system prompt, then conversation history
prompt:
  - role: system
    content: 'prompts["my-prompt"]["instructions"]'
  - role: messages
    content: 'messages'

# Advanced: inject history in the middle, add a trailing instruction
prompt:
  - role: system
    content: 'prompts["sys"]["instructions"]'
  - role: messages
    content: 'messages'
  - role: human
    content: '"Now summarize the above conversation"'

# Few-shot examples via explicit roles
prompt:
  - role: system
    content: '"You classify sentiment as positive or negative."'
  - role: human
    content: '"I love this product!"'
  - role: ai
    content: '"positive"'
  - role: messages
    content: 'messages'

Tool binding modes:

Mode Description
tools (explicit list) Bind the listed tools (can be combined with any flag below)
use_tools_from_registry: true Bind all tools in the registry
use_tools_from_loaded_skills: true Bind only tools loaded via skill discovery
use_sub_agents_as_tools: true / all Bind all sub-agents declared in sub_agents as tools
use_sub_agents_as_tools: [list] Bind specific sub-agents by id/version

use_sub_agents_as_tools accepts three forms: true (or all) to bind all declared sub-agents, a list of RegistryRef objects (id + version) to bind a specific subset, or false (default) to disable. Each ref’s id must match a declared sub_agents entry.

The dynamic flags (use_tools_from_registry, use_tools_from_loaded_skills, use_sub_agents_as_tools) are mutually exclusive with each other. However, an explicit tools list can be combined with any single dynamic flag – the tools are merged additively and deduplicated by name.

Auto-injected tool_node: When a call_llm node has tools, sherma automatically injects a tool_node after it with the correct conditional edges. If the LLM responds with tool calls, execution routes to the tool node; otherwise it continues to the next edge. You don’t need to wire this manually.

tool_node

Executes tool calls from the last AIMessage. Usually auto-injected, but can be declared explicitly:

- name: tools
  type: tool_node
  args:
    tools:                  # Optional: restrict to specific tools
      - id: get_weather
        version: "1.0.0"

If no tools list is provided, the node resolves all tools from the registry.

call_agent

Invokes another registered agent:

- name: delegate
  type: call_agent
  args:
    agent:
      id: sub-agent
      version: "1.0.0"
    input: 'messages[size(messages) - 1]'  # CEL expression for input

The agent can be local or remote. The input is evaluated as a CEL expression against state, sent as an A2A message, and the response is added to messages.

data_transform

Transforms state using a CEL expression that returns a dict:

- name: update_stats
  type: data_transform
  args:
    expression: '{"query_count": query_count + 1, "status": "done"}'

The returned dict is merged into the state. Only include the keys you want to update.

set_state

Sets individual state variables via CEL expressions:

- name: init
  type: set_state
  args:
    values:
      counter: "0"
      status: '"ready"'     # Note: string literals need inner quotes

Each value is a CEL expression. String literals must be double-quoted inside the YAML string.

interrupt

Pauses graph execution to request human input:

- name: ask_user
  type: interrupt
  args: {}

The interrupt value sent to the A2A client is always the last AIMessage from the graph state — not the CEL value expression. This is a hard contract: every interrupt node must be preceded by a call_llm node that produces an AIMessage. If no AIMessage exists in state when the interrupt fires, a RuntimeError is raised.

This design ensures the user always sees the agent’s actual reply (e.g., a question, a summary, a status update) rather than a raw interrupt string.

When the user responds, execution resumes from this node. The user’s response is wrapped as a HumanMessage and appended to state.

Interrupt contract for tool-level interrupts

The same contract applies to tools that call interrupt() directly (e.g., a request_user_input tool). The interrupt value must be an AIMessage:

from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langgraph.types import interrupt

@tool
def request_user_input(question: str) -> str:
    """Ask the user for more information."""
    response = interrupt(AIMessage(content=question))
    return str(response)

This ensures send_message can always convert interrupt values to A2A messages without inspecting the graph’s message history.

Edges

Static Edges

edges:
  - source: node_a
    target: node_b

Use __end__ as the target to terminate the graph.

Conditional Edges

Use CEL expressions for dynamic routing:

edges:
  - source: reflect
    branches:
      - condition: 'messages[size(messages) - 1].contains("TASK_COMPLETE")'
        target: __end__
      - condition: 'retry_count < 3'
        target: retry
    default: summarize    # Fallback if no branch matches

Branches are evaluated in order. The first matching condition determines the target. If no branch matches and no default is set, the graph ends.

CEL Expressions

CEL is used throughout the YAML for dynamic behavior. Expressions have access to:

CEL supports standard operations: arithmetic, string manipulation, list operations (size(), indexing), map construction, comparisons, and boolean logic.

CEL can also handle Pydantic models, dataclasses, and any object with __dict__ – these are automatically converted to CEL maps, so you can access their fields with standard map syntax (e.g., obj.field or obj["field"]).

Examples

# Access last message content
'messages[size(messages) - 1]'

# Build a dict for state transformation
'{"count": count + 1, "status": "done"}'

# Conditional check
'messages[size(messages) - 1].contains("COMPLETE")'

# Reference a registered prompt
'prompts["my-prompt"]["instructions"]'

# String literal (note inner quotes)
'"hello world"'

# Integer literal
'42'

Loading a Declarative Agent

From a YAML file

from sherma import DeclarativeAgent

agent = DeclarativeAgent(
    id="my-agent",          # Must match an agent key in the YAML
    version="1.0.0",
    yaml_path="agent.yaml",
)

From a YAML string

agent = DeclarativeAgent(
    id="my-agent",
    version="1.0.0",
    yaml_content=yaml_string,
    base_path=Path("path/to/yaml/dir"),  # Required for relative file paths
)

When using yaml_content, relative file paths in the YAML (like skill_card_path or sub-agent yaml_path) cannot be resolved without a base_path. If your YAML references only absolute paths or Python import paths, base_path is not needed.

From a parsed config

from sherma import DeclarativeConfig, load_declarative_config

config = load_declarative_config(yaml_path="agent.yaml")
agent = DeclarativeAgent(
    id="my-agent",
    version="1.0.0",
    config=config,
    base_path=Path("path/to/yaml/dir"),  # Required for relative file paths
)

With hooks

from my_hooks import LoggingHook, GuardrailHook

agent = DeclarativeAgent(
    id="my-agent",
    version="1.0.0",
    yaml_path="agent.yaml",
    hooks=[LoggingHook(), GuardrailHook()],
)

Path Resolution

All file paths in a YAML config (skill_card_path, sub-agent yaml_path) are resolved against a base_path:

This ensures agents work correctly from any working directory, not just the project root.

# These paths are resolved relative to the YAML file's directory:
skills:
  - id: weather
    version: "1.0.0"
    skill_card_path: ../skills/weather/skill-card.json  # Relative to YAML dir

sub_agents:
  - id: weather-agent
    version: "1.0.0"
    yaml_path: weather_agent.yaml  # Relative to YAML dir

What is NOT affected by base_path:

Complete Example

A skill-aware agent that discovers skills, executes tasks, and reflects on results. Note the use of default_llm to avoid repeating the LLM reference on every node:

manifest_version: 1

prompts:
  - id: discover-skills
    version: "1.0.0"
    instructions: >
      You have access to a catalog of skills. Given the user's request:
      1. Call list_skills to see available skills.
      2. Call load_skill_md for the most relevant skill.
      3. Respond with a brief text summary.

  - id: plan-and-execute
    version: "1.0.0"
    instructions: >
      Based on the loaded skills, plan and execute the user's request.

  - id: reflect
    version: "1.0.0"
    instructions: >
      Review the results. If complete, respond with "TASK_COMPLETE"
      followed by the answer. Otherwise respond "NEEDS_MORE_WORK".

llms:
  - id: openai-gpt-4o-mini
    version: "1.0.0"
    provider: openai
    model_name: gpt-4o-mini

default_llm:
  id: openai-gpt-4o-mini

skills:
  - id: weather
    version: "1.0.0"
    skill_card_path: skills/weather/skill-card.json

agents:
  skill-agent:
    state:
      fields:
        - name: messages
          type: list
          default: []

    graph:
      entry_point: discover_skills

      nodes:
        - name: discover_skills
          type: call_llm
          args:
            prompt:
              - role: system
                content: 'prompts["discover-skills"]["instructions"]'
              - role: messages
                content: 'messages'
            tools:
              - id: list_skills
              - id: load_skill_md

        - name: execute
          type: call_llm
          args:
            prompt:
              - role: system
                content: 'prompts["plan-and-execute"]["instructions"]'
              - role: messages
                content: 'messages'
            use_tools_from_loaded_skills: true

        - name: reflect
          type: call_llm
          args:
            prompt:
              - role: system
                content: 'prompts["reflect"]["instructions"]'
              - role: messages
                content: 'messages'

      edges:
        - source: discover_skills
          target: execute

        - source: execute
          target: reflect

        - source: reflect
          branches:
            - condition: 'messages[size(messages) - 1].contains("TASK_COMPLETE")'
              target: __end__
          default: execute