Customizing Roles

This guide explains how to customize roles in the econagents framework, leveraging the flexible architecture of the Role class.

Role Architecture Overview

The Role class defines the decision policy an Agent uses in an experiment. At a minimum, you need to specify:

  • Role ID

  • Role name

  • LLM model

You can also control which phases the role participates in by listing them in task_phases or excluding them via task_phases_excluded.

If you want behavioral or demographic variation across agents that share the same role, attach a persona — see the Personas section. When a persona is attached, Role auto-appends a standard "About You" block to the system prompt; you can opt out with auto_render_persona = False and use {{ persona }} in Jinja for custom rendering.

When an agent enters a phase, its role checks:

  1. Phase eligibility (based on the above lists).

  2. Which handlers or prompts to use for that phase.

The system is designed so that you can provide both general default behaviors and more targeted, phase-specific custom logic.

How Phase Handling Works (High-Level Flow)

The handler is how the role decides what to do in a phase, while the prompt is what the role sends to the LLM. Here's the high-level flow:

  1. Check Phase Eligibility If a phase is in task_phases or is not in task_phases_excluded, the role attempts to handle it.

  2. Resolve a Phase Handler The role looks for a custom handler for that phase, either via explicitly registered handlers or naming conventions. If one is found, it executes that handler. - If no custom handler is found, it falls back to the default LLM-based handler (handle_phase_with_llm).

  3. (Default Handler Only) Prompt Resolution If using the default LLM handler, the role generates a system prompt and a user prompt for that phase. These prompts are resolved according to the prompt resolution logic described below, either from files, methods, or handlers.

  4. Send Prompts to LLM The default handler then calls the LLM with the resolved prompts.

  5. Response Parsing The role processes the LLM output through an explicit parser, a naming convention, or the parser supplied by the Agent.

  6. Return Phase Result The custom or default handler returns the final result for that phase.

Phase-Specific Customization Points

When you customize role behavior for specific phases, you can change up to four components:

  1. System Prompt – a role-defining, scenario-framing prompt.

  2. User Prompt – instructions or context for the specific phase.

  3. Response Parser – logic to interpret the LLM's response.

  4. Phase Handler – overarching logic for that phase (which may not even call the LLM).

You can customize these components by combining any of the following three approaches:

  1. Prompt Templates (Method 1)

  2. Phase-Specific Methods (Method 2)

  3. Explicit Registration (Method 3)

The sections below describe each approach in detail, followed by deeper dives into prompt resolution and handler resolution logic.

Customization Approaches

Method 1: Prompt Templates

The default and recommended approach is to define prompt templates in the prompts/ directory (or whichever directory is specified in your game runner configuration):

prompts/
├── your_role_system.jinja2                 # Default system prompt
├── your_role_system_phase_2.jinja2         # Phase-specific system prompt
├── your_role_user_phase_6.jinja2           # Phase-specific user prompt
└── all_user_phase_8.jinja2                 # Shared prompt for all agents

The Role class automatically discovers these templates and uses them to generate prompts according to a strict naming convention (explained in "Prompt Resolution Logic" below).

Using Partials for Reusability

To promote reusability and maintain consistency across your prompts, you can leverage Jinja's include tag to insert common snippets. For example, you might create a partial template containing standard game information:

prompts/_partials/_game_information.jinja2
1. **Game Information**:
   - Phase: {{ meta.phase }}
   - Your Role: {{ meta.role }} (Player #{{ meta.player_number }})
   - Name: {{ meta.player_name }}

You can then include this partial in your main prompt templates:

Example user prompt using include
{% include "_partials/_game_information.jinja2" %}

**Your Decision Options**:
... rest of the prompt ...

This approach helps keep your prompts organized and DRY (Don't Repeat Yourself), making maintenance easier. The Role's prompt rendering mechanism supports standard Jinja features, including includes.

Note: Prompt templates only control what text is sent to the LLM. If you want to customize the overall phase logic (e.g., do multiple calls to the LLM or skip the LLM entirely), you need to register or define a custom handler (see below).

Method 2: Phase-Specific Methods

Another way to customize prompts, parsers, or even the entire phase logic is by adding methods that follow a phase-based naming pattern in your Role subclass:

class YourRole(Role):
    role = 1
    name = "YourRole"
    task_phases = [2, 6, 8]
    llm = ChatOpenAI()

    # -- Custom system/user prompts --
    def get_phase_2_system_prompt(self, state):
        return "You are an economic agent in phase 2..."

    def get_phase_6_user_prompt(self, state):
        return "Current market state: ..."

    # -- Custom response parser --
    def parse_phase_8_llm_response(self, response, state):
        # parse the response
        return parsed_data

    # -- Custom phase handler --
    def handle_phase_3(self, phase, state):
        # bypass the LLM entirely if you want
        return {"custom": "logic for phase 3"}

Any method that matches these naming conventions is automatically detected and used in place of the default behavior. For example, if you define handle_phase_3(...), the role will use that method to handle phase 3 instead of the default LLM approach.

Method 3: Explicit Registration

Finally, you can manually register handlers in your role's __init__ method:

class YourRole(Role):
    role = 1
    name = "YourRole"
    task_phases = [2, 6, 8]
    llm = ChatOpenAI()

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        # Register custom handlers
        self.register_system_prompt_handler(2, self.custom_system_prompt)
        self.register_user_prompt_handler(6, self.custom_user_prompt)
        self.register_response_parser(8, self.custom_response_parser)
        self.register_phase_handler(2, self.custom_phase_handler)

    def custom_system_prompt(self, state):
        return "Custom system prompt for phase 2..."

    def custom_user_prompt(self, state):
        return "Custom user prompt for phase 6..."

    def custom_response_parser(self, response, state):
        return {"parsed": "data"}

    async def custom_phase_handler(self, phase, state):
        # entire custom logic for phase 2
        return {"result": "from custom phase handler"}

Either approach—naming conventions or explicit registration—lets you override the default prompts, parsers, or phase handling.

Prompt Resolution Logic

Prompt resolution applies only when the role uses the default LLM handler, meaning no custom phase handler is overriding the process. When the default LLM handler runs, it needs to generate:

  1. A system prompt

  2. A user prompt

To do this, it follows a cascading resolution order for each prompt type (system vs. user). Below is the resolution order for system prompts, with user prompts following the same pattern:

  1. Registered prompt handler A handler registered via register_system_prompt_handler (or register_user_prompt_handler).

  2. Phase-specific method A method with a matching pattern: - get_phase_{phase_number}_system_prompt(...) - get_phase_{phase_number}_user_prompt(...)

  3. Phase-specific role template A file named {role_name}_system_phase_{phase}.jinja2 (or {role_name}_user_phase_{phase}.jinja2).

  4. General role template A file named {role_name}_system.jinja2 (or {role_name}_user.jinja2).

  5. Phase-specific shared template A file named all_system_phase_{phase}.jinja2 (or all_user_phase_{phase}.jinja2).

  6. General shared template A file named all_system.jinja2 (or all_user.jinja2).

  7. Error Fallback If none of the above exist, a FileNotFoundError is raised.

Example (System Prompt, Phase 2)

For a role named "trader" handling phase 2, the role checks for a system prompt in this order:

1. Registered system prompt handler for phase 2?
2. get_phase_2_system_prompt(...) method?
3. prompts/trader_system_phase_2.jinja2 ?
4. prompts/trader_system.jinja2 ?
5. prompts/all_system_phase_2.jinja2 ?
6. prompts/all_system.jinja2 ?
7. FileNotFoundError if none are found.

This mechanism ensures you can define broad, reusable prompts while still allowing tailored prompts for specific phases.

Handler Resolution Logic

Handler resolution determines the overall logic used to handle a given phase. It is independent from (but often used alongside) prompt resolution.

  1. Phase Eligibility Check - If neither task_phases nor task_phases_excluded is set, the role attempts to handle all phases. - If task_phases is set, only those listed phases are handled. - If task_phases_excluded is set, all phases except those in the list are handled.

  2. Custom Handler Resolution If a custom phase handler is registered via register_phase_handler or detected by method naming convention, such as handle_phase_3, the role uses that handler.

  3. Default LLM Handler If no custom handler is found, the role uses the default implementation: 1. Prompt Resolution (for system/user prompts) 2. Call the LLM 3. Response Parsing (using parser resolution) 4. Return the final result.

Comparing Prompt vs. Handler Resolution

  • Prompt Resolution: Determines what text the role sends to the LLM (system + user prompts).

  • Handler Resolution: Determines how the phase is handled overall. This can include calls to the LLM (and hence prompt resolution) or skip the LLM entirely.

If you only need to change the prompts for a phase, you can rely on prompt resolution. If you need to change the entire logic for a phase (e.g., skipping the LLM, performing additional calculations), you must define or register a custom handler.