# 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)](https://cel.dev/) expressions evaluated against the agent's state at runtime.

## YAML Structure

A declarative agent YAML has these top-level sections:

```yaml
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
```

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

## Entity Declarations

### Prompts

```yaml
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

```yaml
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:

```yaml
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

```yaml
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](skills.md) for details on skill cards and progressive disclosure.

### Hooks

```yaml
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](hooks.md).

### 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](multi-agent.md) for the full guide.

```yaml
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:

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

You can also pass a checkpointer programmatically via the constructor:

```python
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:

```yaml
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:

```yaml
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](#default-llm)).

```yaml
- 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.

```yaml
# 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:

```yaml
- 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:

```yaml
- 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:

```yaml
- 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:

```yaml
- 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:

```yaml
- 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`:

```python
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

```yaml
edges:
  - source: node_a
    target: node_b
```

Use `__end__` as the target to terminate the graph.

### Conditional Edges

Use CEL expressions for dynamic routing:

```yaml
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](https://cel.dev/) is used throughout the YAML for dynamic behavior. Expressions have access to:

- **State variables**: All fields in the state schema (`messages`, `counter`, etc.)
- **Prompts**: `prompts["prompt-id"]["instructions"]`
- **LLMs**: `llms["llm-id"]["model_name"]`

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

```yaml
# 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

```python
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

```python
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

```python
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

```python
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`**:

- **`yaml_path` provided**: `base_path` is automatically derived from the YAML file's parent directory. No manual setup needed.
- **`yaml_content` or `config` provided**: Set `base_path` explicitly if the YAML contains relative file paths.
- **Absolute paths**: Always work regardless of `base_path`.
- **Relative paths without `base_path`**: Raise a `DeclarativeConfigError`.

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

```yaml
# 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`:**
- `import_path` (tools, agents, hooks) -- uses Python's `importlib` and `sys.path`
- Skill card `base_uri` -- resolved relative to the skill card file's own location
- Remote URLs -- used as-is

## 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:

```yaml
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
```
