Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,31 @@ Key points:
- **Agent creation is cheap** (~1ms) -- no disk I/O in the constructor
- **Toolkits are reusable** -- call `rebind(agent_id)` to switch context between requests. `bind()` raises `ToolkitAlreadyBoundError` if the toolkit is already bound to a different agent (prevents accidental state leakage in daemon mode)
- **Memory backends are pluggable** -- swap `TFIDFBackend` for `ChromaBackend` via `SemanticMemory(hive_dir, agent_id, backend=ChromaBackend(...))`

## 10. Custom World Content (Events & Jobs)

The simulation's life-event and job catalogs are registry-driven (mirroring `StressorRegistry`). Register your own content without editing the catalog modules, or pass a custom `EventRegistry` to an `EventEngine` for an isolated set.

```python
from hive.world.registry import EventRegistry, JobRegistry
from hive.world.events import Choice, LifeEvent, StatEffect
from hive.world.state import Job

EventRegistry.default().register(
LifeEvent(
event_id="found_wallet",
name="Found a Wallet",
description="You found a wallet on the street.",
category="luck",
choices=[
Choice(id="keep", description="Keep it", stat_effects=[StatEffect(stat="money", change=80)]),
],
)
)
JobRegistry.default().register(Job(job_id="pilot", title="Pilot", salary=180.0, required_skills=["flying"]))
```

Key points:
- **`EventRegistry`/`JobRegistry`** expose `register()`, `get()`, `all()`, and a singleton `default()` seeded from the built-in catalogs (`world/registry.py`)
- **`EventEngine(stats, world, events=my_registry)`** fires only the events in `my_registry`
- **`WorldState`** seeds its jobs from `JobRegistry.default()` at construction -- register custom jobs before creating it
45 changes: 45 additions & 0 deletions docs/extending/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,48 @@ def test_plugin_discovered():
names = [tk.__name__ for tk in toolkits]
assert "CalculatorToolkit" in names
```

## 12. Custom World Content (Events & Jobs)

The life-event and job catalogs are registry-driven (mirroring `StressorRegistry`).
Register your own without editing the catalog modules, or pass a custom registry to
an `EventEngine` / `WorldState` for an isolated content set.
Comment on lines +479 to +480

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The opening sentence of this section says a custom registry can be passed to WorldState, but WorldState.__init__ has no registry parameter — it always calls JobRegistry.default(). A reader following the "custom registry to ... WorldState" hint will search for a non-existent parameter. The later sentence in the same section correctly describes the actual contract (modify the default registry before constructing WorldState), but the contradiction in the intro will cause confusion.

Suggested change
Register your own without editing the catalog modules, or pass a custom registry to
an `EventEngine` / `WorldState` for an isolated content set.
Register your own without editing the catalog modules, or pass a custom `EventRegistry`
to an `EventEngine` for per-engine isolation, or pre-populate `JobRegistry.default()`
before constructing a `WorldState` to include custom jobs in every new world.


```python
from hive.world.registry import EventRegistry, JobRegistry
from hive.world.events import LifeEvent, Choice, StatEffect
from hive.world.state import Job

# Add a new life event to the default catalog
EventRegistry.default().register(
LifeEvent(
event_id="found_wallet",
name="Found a Wallet",
description="You found a wallet on the street.",
category="luck",
choices=[
Choice(id="keep", description="Keep it", stat_effects=[StatEffect(stat="money", change=80)]),
Choice(id="return", description="Return it", stat_effects=[StatEffect(stat="happiness", change=8, change_type="percent")]),
],
)
)

# Add a new job
JobRegistry.default().register(Job(job_id="pilot", title="Pilot", salary=180.0, required_skills=["flying"]))
```

```python
# Test
def test_custom_event_registered():
from hive.world.registry import EventRegistry
from hive.world.events import LifeEvent

EventRegistry._reset()
reg = EventRegistry.default()
reg.register(LifeEvent(event_id="lucky", name="Lucky", description="!", category="luck", choices=[]))
assert reg.get("lucky") is not None
```

An `EventEngine(stats, world, events=my_registry)` fires only the events in
`my_registry`; a `WorldState` seeds its jobs from `JobRegistry.default()` at
construction, so register custom jobs before creating it.
1 change: 1 addition & 0 deletions docs/guide/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ src/hive/
| Custom model provider | Subclass `BaseProvider` | `models/base.py` | See EXTENDING.md |
| Custom stressor | `StressorRegistry.default().register(...)` | `agents/suffering.py` | See EXTENDING.md |
| Custom A2A pattern | Subclass `A2APattern`, register via `PatternRegistry` | `interactions/registry.py` | See EXTENDING.md |
| Custom world content | `EventRegistry`/`JobRegistry` `.default().register(...)` | `world/registry.py` | See EXTENDING.md |
| Custom goal strategy | Implement `GoalStrategy` protocol | `agents/goal_strategy.py` | See EXTENDING.md |
| Daemon hooks | `daemon.hooks.on("event", callback)` | `daemon/hooks.py` | See EXTENDING.md |
| Custom agent profile | YAML file in `profiles/` | `agents/profile.py` | See EXTENDING.md |
Expand Down
15 changes: 11 additions & 4 deletions src/hive/world/event_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import random
from pathlib import Path

from hive.world.event_catalog import EVENT_MAP, EVENTS
from hive.world.events import EventOutcome, LifeEvent
from hive.world.registry import EventRegistry
from hive.world.state import WorldState
from hive.world.stats import AgentStats, StatsManager

Expand All @@ -24,9 +24,16 @@ def __init__(self, agent_id: str, event_id: str, fires_at_cycle: int):
class EventEngine:
"""Fires random life events and tracks follow-ups."""

def __init__(self, stats: StatsManager, world: WorldState, hive_dir: Path | None = None):
def __init__(
self,
stats: StatsManager,
world: WorldState,
hive_dir: Path | None = None,
events: EventRegistry | None = None,
):
self._stats = stats
self._world = world
self._events = events or EventRegistry.default()
self._pending: list[PendingFollowUp] = []
self._history: list[EventOutcome] = []
self._history_path = (hive_dir / "event_history.jsonl") if hive_dir else None
Expand Down Expand Up @@ -54,7 +61,7 @@ def roll_events(self, agent_id: str, cycle: int) -> list[LifeEvent]:

due = [p for p in self._pending if p.agent_id == agent_id and p.fires_at_cycle <= cycle]
for p in due:
ev = EVENT_MAP.get(p.event_id)
ev = self._events.get(p.event_id)
if ev:
events_to_fire.append(ev)
self._pending.remove(p)
Expand Down Expand Up @@ -140,7 +147,7 @@ def _get_eligible(self, agent_id: str, stats: AgentStats) -> list[LifeEvent]:
if outcome.agent_id == agent_id:
agent_recent.add(outcome.event_id)
count += 1
for ev in EVENTS:
for ev in self._events.all():
if ev.min_cycles_alive > stats.cycles_alive:
continue
if ev.event_id in agent_recent:
Expand Down
81 changes: 81 additions & 0 deletions src/hive/world/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Registries for world content -- life events and jobs (Phase 3 D2).

Mirror ``StressorRegistry`` / ``PatternRegistry``: a default singleton seeded
from the built-in catalogs, plus ``register()`` / ``get()`` / ``all()`` so
applications can add content without editing the catalog modules, and pass a
custom registry per ``EventEngine`` / ``WorldState`` for isolated content sets.
"""

from __future__ import annotations

from typing import ClassVar

from hive.world.event_catalog import EVENTS
from hive.world.events import LifeEvent
from hive.world.state import AVAILABLE_JOBS, Job


class EventRegistry:
"""Extensible registry of life events. ``default()`` is seeded with EVENTS."""

_instance: ClassVar[EventRegistry | None] = None

def __init__(self) -> None:
self._events: dict[str, LifeEvent] = {}

def register(self, event: LifeEvent) -> None:
"""Add or replace an event by its event_id."""
self._events[event.event_id] = event

def get(self, event_id: str) -> LifeEvent | None:
"""Return the event with this id, or None."""
return self._events.get(event_id)

def all(self) -> list[LifeEvent]:
"""Return all registered events."""
return list(self._events.values())

@classmethod
def default(cls) -> EventRegistry:
if cls._instance is None:
cls._instance = cls()
for event in EVENTS:
cls._instance.register(event)
return cls._instance
Comment on lines +38 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Partially-seeded singleton visible under concurrent access

Between cls._instance = cls() (line 41) and the completion of the seeding loop (lines 42-43), the GIL can be released. A second thread that calls default() at that moment will see _instance is not None and return an empty registry before any events are registered. The same window exists in JobRegistry.default(). This matches the pattern in the existing PatternRegistry, so it is a pre-existing concern in the codebase rather than a new regression — but worth noting since these registries are now used on hot paths (EventEngine construction, WorldState construction).


@classmethod
def _reset(cls) -> None:
cls._instance = None


class JobRegistry:
"""Extensible registry of jobs. ``default()`` is seeded with AVAILABLE_JOBS."""

_instance: ClassVar[JobRegistry | None] = None

def __init__(self) -> None:
self._jobs: dict[str, Job] = {}

def register(self, job: Job) -> None:
"""Add or replace a job by its job_id."""
self._jobs[job.job_id] = job

def get(self, job_id: str) -> Job | None:
"""Return the job with this id, or None."""
return self._jobs.get(job_id)

def all(self) -> list[Job]:
"""Return all registered jobs."""
return list(self._jobs.values())

@classmethod
def default(cls) -> JobRegistry:
if cls._instance is None:
cls._instance = cls()
for job in AVAILABLE_JOBS:
cls._instance.register(job)
return cls._instance

@classmethod
def _reset(cls) -> None:
cls._instance = None
5 changes: 4 additions & 1 deletion src/hive/world/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ class WorldState:
"""Manages the economy, jobs, skills, and agent finances."""

def __init__(self, hive_dir: Path):
# Local import: registry imports Job/AVAILABLE_JOBS from this module.
from hive.world.registry import JobRegistry

self._state_path = hive_dir / "world_state.json"
self._finances: dict[str, AgentFinances] = {}
self._jobs: list[Job] = [j.model_copy() for j in AVAILABLE_JOBS]
self._jobs: list[Job] = [j.model_copy() for j in JobRegistry.default().all()]
self._skills: dict[str, list[SkillProgress]] = {}
self._cycle_count = 0
self._load()
Expand Down
102 changes: 102 additions & 0 deletions tests/test_world_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Tests for registry-driven world catalogs -- events and jobs (Phase 3 D2)."""

from __future__ import annotations

from pathlib import Path

import pytest

from hive.world.event_catalog import EVENTS
from hive.world.event_engine import EventEngine
from hive.world.events import LifeEvent
from hive.world.registry import EventRegistry, JobRegistry
from hive.world.state import AVAILABLE_JOBS, Job, WorldState
from hive.world.stats import StatsManager


@pytest.fixture(autouse=True)
def _reset_registries():
EventRegistry._reset()
JobRegistry._reset()
yield
EventRegistry._reset()
JobRegistry._reset()


class TestEventRegistry:
def test_default_seeded_with_builtin_events(self) -> None:
reg = EventRegistry.default()
assert len(reg.all()) == len(EVENTS)
assert reg.get("rent_increase") is not None

def test_register_and_get_custom_event(self) -> None:
reg = EventRegistry()
ev = LifeEvent(
event_id="lottery",
name="Lottery Win",
description="You won!",
category="financial",
choices=[],
)
reg.register(ev)
assert reg.get("lottery") is ev
assert reg.get("missing") is None

def test_register_replaces_by_id(self) -> None:
reg = EventRegistry.default()
before = len(reg.all())
reg.register(
LifeEvent(
event_id="rent_increase",
name="X",
description="x",
category="financial",
choices=[],
)
)
assert len(reg.all()) == before # replaced, not appended

def test_default_is_singleton(self) -> None:
assert EventRegistry.default() is EventRegistry.default()


class TestJobRegistry:
def test_default_seeded_with_builtin_jobs(self) -> None:
reg = JobRegistry.default()
assert len(reg.all()) == len(AVAILABLE_JOBS)
assert reg.get("analyst") is not None

def test_register_custom_job(self) -> None:
reg = JobRegistry()
reg.register(Job(job_id="pilot", title="Pilot", salary=200.0))
assert reg.get("pilot").title == "Pilot"


class TestEngineUsesRegistry:
def test_engine_fires_only_registered_events(self, tmp_path: Path) -> None:
"""An EventEngine with a custom registry sees only that registry's events."""
reg = EventRegistry()
reg.register(
LifeEvent(
event_id="only_one", name="Only", description="d", category="misc", choices=[]
)
)
world = WorldState(tmp_path)
engine = EventEngine(StatsManager(tmp_path), world, hive_dir=tmp_path, events=reg)
assert engine._events.get("only_one") is not None
assert engine._events.get("rent_increase") is None # not in this registry

def test_engine_defaults_to_builtin_registry(self, tmp_path: Path) -> None:
world = WorldState(tmp_path)
engine = EventEngine(StatsManager(tmp_path), world, hive_dir=tmp_path)
assert engine._events.get("rent_increase") is not None


class TestWorldStateUsesJobRegistry:
def test_registered_job_appears_in_new_world(self, tmp_path: Path) -> None:
"""A job registered before WorldState construction is picked up."""
JobRegistry.default().register(Job(job_id="astronaut", title="Astronaut", salary=300.0))
world = WorldState(tmp_path / "a")
job_ids = {j.job_id for j in world._jobs}
assert "astronaut" in job_ids
assert "analyst" in job_ids # built-ins still present
Loading