Participant base class + participation contractIntroduce 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.
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.
Every participant must:
config/ and src/local/defaults/)CONFIG_NAME — the key passed to get_config()id: in that yaml — the identity used as sender_id on all bus messagesIf id: is absent, self.id returns "[set id in yaml]" — no startup failure, visible in logs and UI.
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__.
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: ...
File: src/local/agents/base_agent.py (modified)
Participant instead of ABCAGENT_ID: ClassVar[str] — replaced by Participant.idself.AGENT_ID references → self.idFile: src/local/tools/base_tool.py (modified)
Participant instead of ABCTOOL_ID: ClassVar[str] — replaced by Participant.idself.TOOL_ID references → self.idCONFIG_NAME already declared — no change needed thereFile: src/local/services/compaction_service.py (modified)
BaseServiceAGENT_ID = "compaction_service"CONFIG_NAME = "compaction"BaseService.run(); rename _check → _handleself.AGENT_ID → self.idFile: src/local/services/reward_service.py (modified)
BaseServiceCONFIG_NAME = "reward"BaseService.run() if not already compatible| Participant | CONFIG_NAME | Change |
|---|---|---|
| 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 |
# config/datetime.yaml
id: datetime_tool
# config/compaction.yaml
id: compaction_service
# config/reward.yaml
id: reward_service
| File | id: value |
|---|---|
| generator.yaml | generator |
| critic.yaml | critic |
| memory.yaml | memory_agent |
| web_search.yaml | web_search_tool |
| web_fetch.yaml | web_fetch_tool |
| search_memory.yaml | search_memory_tool |
| location.yaml | location_tool |
| semantic_scholar.yaml | semantic_scholar_tool |
| documents.yaml | search_library_tool |
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 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 | Change |
|---|---|
src/local/participants/__init__.py | new module |
src/local/participants/participant.py | Participant ABC |
src/local/participants/base_service.py | BaseService |
src/local/agents/base_agent.py | inherit Participant; remove AGENT_ID; self.id |
src/local/tools/base_tool.py | inherit Participant; remove TOOL_ID; self.id |
src/local/agents/generator_agent.py | add CONFIG_NAME; remove AGENT_ID |
src/local/agents/critic_agent.py | add CONFIG_NAME; remove AGENT_ID |
src/local/agents/memory_agent.py | add CONFIG_NAME; remove AGENT_ID |
src/local/tools/datetime_tool.py | add CONFIG_NAME |
src/local/tools/*.py (6 remaining tools) | remove TOOL_ID |
src/local/services/compaction_service.py | inherit BaseService; add CONFIG_NAME; remove AGENT_ID |
src/local/services/reward_service.py | inherit BaseService; add CONFIG_NAME; remove hardcoded id |
config/generator.yaml + defaults | add id: generator |
config/critic.yaml + defaults | add id: critic |
config/memory.yaml + defaults | add id: memory_agent |
config/web_search.yaml + defaults | add id: web_search_tool |
config/web_fetch.yaml + defaults | add id: web_fetch_tool |
config/search_memory.yaml + defaults | add id: search_memory_tool |
config/location.yaml + defaults | add id: location_tool |
config/semantic_scholar.yaml + defaults | add id: semantic_scholar_tool |
config/documents.yaml + defaults | add id: search_library_tool |
config/datetime.yaml + defaults | id: datetime_tool |
config/compaction.yaml + defaults | id: compaction_service |
config/reward.yaml + defaults | id: reward_service |
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.
self.id; no AGENT_ID or TOOL_ID class constants remainid:id: from a yaml produces "[set id in yaml]" in logs and bus messages — no exceptionBaseService