Skip to content

Commit 8e2a996

Browse files
committed
feat: wire simulation feedback loops (Phase 3 D1)
The simulation layers were modular but disconnected. Wire the three missing feedback loops with targeted, additive changes (no new event bus): Loop 1 -- world events -> stressors: - Choice gains optional stressor / stressor_severity / resolves_stressor; EventOutcome carries stressor_added / stressor_resolved (apply_choice sets them). - _process_life_events feeds the chosen outcome into the agent's SufferingState (add_stressor / resolve) -- add_stressor already tolerates new stressor names. - A few catalog events declare stressors (big_loss/burnout cause; windfall/rest resolve) so the loop is exercised by real content. Loop 2 -- stats -> goal generation (economy-gated): - GoalContext.agent_stats; ExistenceLoop takes optional stats and renders a 'Current condition' section (health/energy/happiness/reputation) in the prompt; the daemon passes self._stats.get(agent_id) into both goal-gen paths. Loop 3 -- outcomes -> narrative: - Goal abandonment now calls update_narrative (success already did); life events append a narrative entry. Reuses the existing 800-char update_narrative. Tests: outcome stressor fields, daemon event->stressor add + resolve + narrative, stats condition section present/absent. Docs: data-flow + feedback-loop note. 927 tests, ruff, format, mypy, mkdocs --strict all green. Fully additive.
1 parent 2988258 commit 8e2a996

8 files changed

Lines changed: 258 additions & 4 deletions

File tree

docs/guide/architecture.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,21 +146,28 @@ Each heartbeat (default 10s):
146146
- Pursue goal via Agent ReAct loop
147147
- Assess conditions (fire/resolve stressors)
148148
- On success: emit("goal_completed"), checkpoint, update narrative
149-
- On failure: emit("goal_abandoned"), record stressor
149+
- On failure: emit("goal_abandoned"), update narrative, record stressor
150150
f. If idle:
151151
- Check scheduled goals
152-
- If custom GoalStrategy: call generate_goal(GoalContext)
153-
- Else: run ExistenceLoop → LLM generates goal
152+
- Generate a goal (custom GoalStrategy or ExistenceLoop). The prompt
153+
includes the agent's stats (health/energy/happiness/reputation) so a
154+
drained or unwell agent steers toward recovery (D1)
154155
- emit("goal_generated")
155156
g. Log suffering state
156157
h. emit("suffering_changed")
157158
i. emit("cycle_end")
158159
3. Auto-kill expired sub-agents
159160
4. Every 5 cycles: swarm learning
160-
5. If economy: process payday + life events
161+
5. If economy: process payday + life events. A life-event Choice may declare a
162+
`stressor` it causes or `resolves_stressor` it relieves -- the daemon feeds
163+
these into the agent's suffering, and records the event in its narrative (D1)
161164
6. Sleep heartbeat seconds
162165
```
163166

167+
**Feedback loops (D1).** The world, suffering, goals, and identity influence each
168+
other: life events add/resolve stressors and append to the narrative; agent stats
169+
feed goal generation; and both goal *and* event outcomes shape the agent's story.
170+
164171
## Configuration
165172

166173
All config lives in `.hive/config.yaml` and env vars.

src/hive/agents/existence.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
if TYPE_CHECKING:
2020
from hive.runtime.persona import Persona
21+
from hive.world.stats import AgentStats
2122

2223
logger = logging.getLogger(__name__)
2324

@@ -40,6 +41,7 @@ def __init__(
4041
world_status: str = "",
4142
notepad_content: str = "",
4243
persona: Persona | None = None,
44+
stats: AgentStats | None = None,
4345
):
4446
self._agent_id = agent_id
4547
self._profile = profile
@@ -54,6 +56,7 @@ def __init__(
5456
self._world_status = world_status
5557
self._notepad_content = notepad_content
5658
self._persona = persona
59+
self._stats = stats
5760

5861
async def _emit(self, event_type: EventType, data: dict[str, Any]) -> None:
5962
event = HiveEvent(
@@ -217,6 +220,20 @@ def _build_prompt(
217220
if self._economy_enabled and self._world_status:
218221
sections.append(f"\n--- Your economic status ---\n{self._world_status}")
219222

223+
if self._stats is not None:
224+
s = self._stats
225+
condition = (
226+
f"- Health: {s.health:.0%}\n"
227+
f"- Energy: {s.energy:.0%}\n"
228+
f"- Happiness: {s.happiness:.0%}\n"
229+
f"- Reputation: {s.reputation:.0%}"
230+
)
231+
sections.append(
232+
"\n--- Your current condition ---\n"
233+
f"{condition}\n"
234+
"Let low stats steer your goal (rest when drained, recover when unwell)."
235+
)
236+
220237
suffering_frag = suffering.prompt_fragment()
221238
if suffering_frag:
222239
sections.append(f"\n--- Your current state ---\n{suffering_frag}")

src/hive/agents/goal_strategy.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from hive.agents.profile import AgentProfile
1010
from hive.agents.suffering import SufferingState
1111
from hive.runtime.persona import Persona
12+
from hive.world.stats import AgentStats
1213

1314

1415
@dataclass
@@ -31,6 +32,7 @@ class GoalContext:
3132
world_status: str = ""
3233
notepad_content: str = ""
3334
economy_enabled: bool = True
35+
agent_stats: AgentStats | None = None
3436
extra: dict[str, Any] = field(default_factory=dict)
3537

3638

src/hive/daemon/loop.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,13 @@ async def _run_agent_cycle_inner(self, agent: AgentState, suffering: SufferingSt
531531
await self._hooks.emit(
532532
"goal_abandoned", agent_id=agent.agent_id, goal_id=active_goal["goal_id"]
533533
)
534+
# D1: abandonment is part of the agent's story too (success path
535+
# already records narrative; this closes the gap).
536+
self._identity.update_narrative(
537+
agent.agent_id,
538+
active_goal["objective"],
539+
f"Abandoned: {outcome.summary}",
540+
)
534541
if persona is not None:
535542
persona.update_from_event("goal_abandoned", outcome.summary)
536543
self._specialization.record(
@@ -567,6 +574,9 @@ async def _run_agent_cycle_inner(self, agent: AgentState, suffering: SufferingSt
567574
if self._economy_enabled and self._ctx.world is not None:
568575
world_status = self._ctx.world.get_status(agent.agent_id)
569576

577+
# D1: feed structured stats into goal generation (economy-gated).
578+
agent_stats = self._stats.get(agent.agent_id) if self._stats else None
579+
570580
notepad_content = self._notepad.get_tail(agent.agent_id)
571581

572582
pending_a2a = await self._a2a_store.get_pending_requests(agent.agent_id, limit=3)
@@ -593,6 +603,7 @@ async def _run_agent_cycle_inner(self, agent: AgentState, suffering: SufferingSt
593603
world_status=world_status,
594604
notepad_content=notepad_content,
595605
economy_enabled=self._economy_enabled,
606+
agent_stats=agent_stats,
596607
)
597608
result_goal = await self._goal_strategy.generate_goal(ctx)
598609
if result_goal is not None:
@@ -621,6 +632,7 @@ async def _run_agent_cycle_inner(self, agent: AgentState, suffering: SufferingSt
621632
world_status=world_status,
622633
notepad_content=notepad_content,
623634
persona=persona,
635+
stats=agent_stats,
624636
)
625637
goal = await existence.generate_goal(suffering, peers, nudges)
626638

@@ -733,6 +745,30 @@ async def _process_life_events(self, agents: list[AgentState]) -> None:
733745
self._cycle_count,
734746
)
735747

748+
# D1: feed the chosen outcome back into the suffering system.
749+
suffering = self._get_suffering(agent.agent_id)
750+
if outcome.stressor_added:
751+
chosen = next((c for c in event.choices if c.id == outcome.choice_id), None)
752+
severity = chosen.stressor_severity if chosen else None
753+
suffering.add_stressor(
754+
outcome.stressor_added,
755+
description=f"Triggered by life event: {event.name}",
756+
observable_condition="Resolved by a positive life event or recovery",
757+
initial_severity=severity,
758+
)
759+
if outcome.stressor_resolved:
760+
suffering.resolve(
761+
outcome.stressor_resolved,
762+
note=f"Relieved by life event: {event.name}",
763+
)
764+
765+
# D1: record the event in the agent's narrative (not just memory).
766+
self._identity.update_narrative(
767+
agent.agent_id,
768+
f"Life event: {event.name}",
769+
outcome.choice_description,
770+
)
771+
736772
session_id = f"sess-{agent.agent_id}"
737773
await self._emit(
738774
agent.agent_id,
@@ -743,6 +779,8 @@ async def _process_life_events(self, agents: list[AgentState]) -> None:
743779
"choice": outcome.choice_description,
744780
"stat_changes": outcome.stat_changes,
745781
"follow_ups": outcome.follow_ups_triggered,
782+
"stressor_added": outcome.stressor_added,
783+
"stressor_resolved": outcome.stressor_resolved,
746784
},
747785
)
748786

src/hive/world/event_catalog.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,8 @@
333333
StatEffect(stat="money", change=-1000),
334334
StatEffect(stat="happiness", change=-25, change_type="percent"),
335335
],
336+
stressor="financial_strain",
337+
stressor_severity=0.4,
336338
),
337339
],
338340
),
@@ -406,6 +408,7 @@
406408
StatEffect(stat="money", change=300),
407409
StatEffect(stat="happiness", change=5, change_type="percent"),
408410
],
411+
resolves_stressor="financial_strain",
409412
),
410413
Choice(
411414
id="treat_yourself",
@@ -433,6 +436,7 @@
433436
StatEffect(stat="happiness", change=10, change_type="percent"),
434437
StatEffect(stat="money", change=-100),
435438
],
439+
resolves_stressor="burnout",
436440
),
437441
Choice(
438442
id="push_through",
@@ -441,6 +445,8 @@
441445
StatEffect(stat="energy", change=-0.2),
442446
StatEffect(stat="health", change=-0.1),
443447
],
448+
stressor="burnout",
449+
stressor_severity=0.35,
444450
),
445451
],
446452
),

src/hive/world/event_engine.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ def apply_choice(
127127
stat_changes=stat_changes,
128128
follow_ups_triggered=follow_ups,
129129
cycle=cycle,
130+
stressor_added=choice.stressor,
131+
stressor_resolved=choice.resolves_stressor,
130132
)
131133
self._history.append(outcome)
132134
self._persist_outcome(outcome)

src/hive/world/events.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ class Choice(BaseModel):
2020
description: str
2121
stat_effects: list[StatEffect] = []
2222
follow_up_events: list[ConditionalFollowUp] = []
23+
# Feedback into the suffering system (D1). Optional; default None leaves
24+
# existing events unchanged. ``stressor`` names a stressor this choice causes
25+
# (any string -- SufferingState tolerates unregistered names); ``resolves_stressor``
26+
# names one it relieves.
27+
stressor: str | None = None
28+
stressor_severity: float | None = None
29+
resolves_stressor: str | None = None
2330

2431

2532
class LifeEvent(BaseModel):
@@ -41,3 +48,5 @@ class EventOutcome(BaseModel):
4148
stat_changes: dict[str, float] = {}
4249
follow_ups_triggered: list[str] = []
4350
cycle: int = 0
51+
stressor_added: str | None = None
52+
stressor_resolved: str | None = None

0 commit comments

Comments
 (0)