Plan — Participant base class + participation contract

Introduce a common Participant ABC above BaseAgent and BaseTool. Add BaseService for lightweight bus participants. Formalize the participation contract: every participant owns a config yaml with an id: field.

Motivation

Agents, tools, and services all live on the bus, but they share no common base. AGENT_ID and TOOL_ID are hardcoded class constants rather than config values. Services (CompactionService, RewardService) use AGENT_ID even though they are not agents. There is no stated contract for what it means to participate in LoCAL2.

Participation Contract

Every participant must:

  1. Have a config yaml (under config/ and src/local/defaults/)
  2. Declare CONFIG_NAME — the key passed to get_config()
  3. Set id: in that yaml — the identity used as sender_id on all bus messages

If id: is absent, self.id returns "[set id in yaml]" — no startup failure, visible in logs and UI.

New Class Hierarchy

Participant (ABC) ← new CONFIG_NAME: ClassVar[str] ← abstract; each subclass names its yaml @property id → str ← reads id: from config; placeholder if absent ├── BaseAgent(Participant) ← gains CONFIG_NAME requirement; AGENT_ID removed │ ├── GeneratorAgent CONFIG_NAME = "generator" │ ├── CriticAgent CONFIG_NAME = "critic" │ └── MemoryAgent CONFIG_NAME = "memory" │ ├── BaseTool(Participant) ← gains CONFIG_NAME requirement; TOOL_ID removed │ ├── WebSearchTool CONFIG_NAME = "web_search" │ ├── WebFetchTool CONFIG_NAME = "web_fetch" │ ├── SearchMemoryTool CONFIG_NAME = "search_memory" │ ├── DateTimeTool CONFIG_NAME = "datetime" │ ├── LocationTool CONFIG_NAME = "location" │ ├── SemanticScholarTool CONFIG_NAME = "semantic_scholar" │ └── SearchLibraryTool CONFIG_NAME = "documents" │ └── BaseService(Participant) ← new; lightweight subscribe loop ├── CompactionService CONFIG_NAME = "compaction" └── RewardService CONFIG_NAME = "reward"

Participant ABC

File: src/local/participants/participant.py (new)

"""Participant — root base class for all LoCAL2 bus participants."""
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import ClassVar

from local.config_loader import get_config

_UNSET = "[set id in yaml]"


class Participant(ABC):
    """Root base for every LoCAL2 bus participant.

    Contract:
      - Declare CONFIG_NAME pointing to the participant's own yaml
      - Set id: in that yaml
    """

    CONFIG_NAME: ClassVar[str]

    @property
    def id(self) -> str:
        cfg = get_config(self.CONFIG_NAME) or {}
        return cfg.get("id") or _UNSET
get_config() is already cached by ConfigManager — reading it on every property access is cheap. No need to store in __init__.

BaseService (new)

File: src/local/participants/base_service.py (new)

CompactionService and RewardService share the same pattern: subscribe to one or more subjects, loop receiving envelopes, publish back. BaseService captures that skeleton.

"""BaseService — lightweight bus participant with a subscribe loop."""
from __future__ import annotations

import logging
from abc import abstractmethod

from local.participants.participant import Participant

logger = logging.getLogger(__name__)


class BaseService(Participant):
    """Participant with a simple subscribe-and-dispatch run loop.

    Subclasses implement _handle(envelope).
    """

    def run(self) -> None:
        logger.info("%s ready", self.id)
        while True:
            try:
                envelope = self._sub.receive()
            except Exception as exc:
                logger.error("%s: receive error: %s", self.id, exc)
                continue
            try:
                self._handle(envelope)
            except Exception as exc:
                logger.error("%s: handler error: %s", self.id, exc, exc_info=True)

    @abstractmethod
    def _handle(self, envelope) -> None: ...

BaseAgent changes

File: src/local/agents/base_agent.py (modified)

BaseTool changes

File: src/local/tools/base_tool.py (modified)

CompactionService changes

File: src/local/services/compaction_service.py (modified)

RewardService changes

File: src/local/services/reward_service.py (modified)

Per-participant CONFIG_NAME additions

ParticipantCONFIG_NAMEChange
GeneratorAgent"generator"add CONFIG_NAME class var; remove AGENT_ID
CriticAgent"critic"add CONFIG_NAME class var; remove AGENT_ID
MemoryAgent"memory"add CONFIG_NAME class var; remove AGENT_ID
WebSearchTool"web_search"already has CONFIG_NAME; remove TOOL_ID
WebFetchTool"web_fetch"already has CONFIG_NAME; remove TOOL_ID
SearchMemoryTool"search_memory"already has CONFIG_NAME; remove TOOL_ID
DateTimeTool"datetime"add CONFIG_NAME; new yaml required
LocationTool"location"already has CONFIG_NAME; remove TOOL_ID
SemanticScholarTool"semantic_scholar"already has CONFIG_NAME; remove TOOL_ID
SearchLibraryTool"documents"already has CONFIG_NAME; remove TOOL_ID
CompactionService"compaction"new CONFIG_NAME; new yaml required
RewardService"reward"new CONFIG_NAME; new yaml required

New and Updated Config Files

New yamls (both config/ and src/local/defaults/)

# config/datetime.yaml
id: datetime_tool

# config/compaction.yaml
id: compaction_service

# config/reward.yaml
id: reward_service

id: added to all existing yamls

Fileid: value
generator.yamlgenerator
critic.yamlcritic
memory.yamlmemory_agent
web_search.yamlweb_search_tool
web_fetch.yamlweb_fetch_tool
search_memory.yamlsearch_memory_tool
location.yamllocation_tool
semantic_scholar.yamlsemantic_scholar_tool
documents.yamlsearch_library_tool

Module structure

New directory src/local/participants/ houses the base classes shared across agents, tools, and services:

src/local/participants/
  __init__.py
  participant.py      ← Participant ABC
  base_service.py     ← BaseService

BaseAgent stays in src/local/agents/. BaseTool stays in src/local/tools/. Both import from local.participants.

GeneratorAgent special case

GeneratorAgent has both a participant id (from generator.yaml) and an instance_id (from system.yaml, used for peer identification in distributed mode). These remain distinct — self.id identifies the participant type; self._instance_id identifies the specific running instance.

File Map

FileChange
src/local/participants/__init__.pynew module
src/local/participants/participant.pyParticipant ABC
src/local/participants/base_service.pyBaseService
src/local/agents/base_agent.pyinherit Participant; remove AGENT_ID; self.id
src/local/tools/base_tool.pyinherit Participant; remove TOOL_ID; self.id
src/local/agents/generator_agent.pyadd CONFIG_NAME; remove AGENT_ID
src/local/agents/critic_agent.pyadd CONFIG_NAME; remove AGENT_ID
src/local/agents/memory_agent.pyadd CONFIG_NAME; remove AGENT_ID
src/local/tools/datetime_tool.pyadd CONFIG_NAME
src/local/tools/*.py (6 remaining tools)remove TOOL_ID
src/local/services/compaction_service.pyinherit BaseService; add CONFIG_NAME; remove AGENT_ID
src/local/services/reward_service.pyinherit BaseService; add CONFIG_NAME; remove hardcoded id
config/generator.yaml + defaultsadd id: generator
config/critic.yaml + defaultsadd id: critic
config/memory.yaml + defaultsadd id: memory_agent
config/web_search.yaml + defaultsadd id: web_search_tool
config/web_fetch.yaml + defaultsadd id: web_fetch_tool
config/search_memory.yaml + defaultsadd id: search_memory_tool
config/location.yaml + defaultsadd id: location_tool
config/semantic_scholar.yaml + defaultsadd id: semantic_scholar_tool
config/documents.yaml + defaultsadd id: search_library_tool
config/datetime.yaml + defaultsid: datetime_tool
config/compaction.yaml + defaultsid: compaction_service
config/reward.yaml + defaultsid: reward_service

Test Impact

Any test that instantiates a participant and accesses AGENT_ID or TOOL_ID directly will need updating. Tests that check sender_id on published envelopes will continue to work — the value is unchanged, just the source moves from class constant to config.

Acceptance Criteria