Skip to content

Commit f1db830

Browse files
authored
feat(D3c): chaptered narrative (story arcs instead of FIFO trim) (#39)
* feat(D3c-1): add Chapter model + chapters field to AgentIdentity Additive, backward-compatible: legacy identities (no chapters key) load with an empty list. A Chapter is a compact sealed span of narrative -- groundwork for replacing the FIFO 800-char trim with story arcs. * feat(D3c-2): seal narrative into chapters instead of FIFO-dropping update_narrative now seals the open narrative into a Chapter (compact summary with date span + entry count) before it would overflow MAX_NARRATIVE, and the new entry starts a fresh chapter. Long-run history is preserved as arcs instead of silently losing the oldest lines. Chapter count is capped at MAX_CHAPTERS (oldest dropped); indices stay monotonic. Tests: no seal below threshold, overflow seals + conserves total entry count, open narrative stays bounded, indices monotonic under churn. * feat(D3c-3): render chapters as 'Story so far' in the preamble render_preamble now surfaces recent chapter summaries as a 'Story so far' section (above the current 'Recent history'), so an agent's long-run arc -- not just the last 400 chars -- informs goal generation and pursuit. Adds a Narrative & chapters note to docs/guide/persona.md. Tests: no Story section without chapters; sealed chapter summaries render. * fix(D3c): preserve full history in life summaries + strict narrative bound Review follow-ups: - Side effect: chaptering made identity.narrative the *open* chapter only, so life_summary.py silently dropped sealed history from biographies. Add AgentIdentity.full_narrative() (chapter summaries + open narrative) and use it in the life summary. - Copilot: a single entry longer than MAX_NARRATIVE bypassed sealing and bloated the open narrative. Cap an over-long entry so the open narrative is now strictly <= MAX_NARRATIVE. Tightened the bound test and added a lone-oversized-entry test + full_narrative tests. * feat(D3c): richer chapter summaries + unambiguous dates (Greptile review) - Greptile: chapter summaries were just 'Ch1 (dates): N entries' with no semantic content. Carry goal text -- the first goal (theme) and, if different, the last (arc) -- so 'Story so far' actually informs the agent. - Greptile: narrative entries now stamp %Y-%m-%d (was %m-%d), so a chapter span across a year boundary no longer renders reversed (12-31–01-01). - Test: chapter summary includes goal text. * fix(D3c): normalize newlines in entries + defensive seal clear + trim test Review follow-ups (PR for D3c): - Copilot (bug): goal_text/outcome with newlines (LLM output) split one entry across lines, inflating entry_count and breaking date/goal extraction. Normalize newlines to spaces so an entry is always one line. - Greptile: _seal_chapter now clears narrative on its empty early-return path, so a whitespace-only narrative can't grow past MAX_NARRATIVE. - Greptile: add a test for the MAX_CHAPTERS trim path (oldest dropped, indices stay monotonic) + a newline-normalization test.
1 parent 8bcffbb commit f1db830

4 files changed

Lines changed: 287 additions & 8 deletions

File tree

docs/guide/persona.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ Each daemon cycle, `persona.apply_suffering_effects()` reads the agent's sufferi
9999

100100
These changes are **not prompt text** -- they're actual parameter values that affect goal generation, tool selection, and decision-making.
101101

102+
## Narrative & chapters
103+
104+
Alongside the persona, each agent keeps an `AgentIdentity` with an evolving **narrative** --
105+
a dated log of goal outcomes. When the open narrative grows past its size limit it is
106+
**sealed into a chapter** (a compact summary with a date span and entry count) rather than
107+
dropping the oldest lines, so long-run history survives as a story arc. During goal pursuit
108+
the agent's prompt includes a "Story so far" section (recent chapter summaries) plus the
109+
current "Recent history", so the agent's own past informs its work.
110+
102111
## Checkpointing
103112

104113
Persona state is included in checkpoints:

src/hive/agents/identity.py

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,35 @@
6464
MAX_NARRATIVE = 800
6565
MAX_OPINIONS = 20
6666
MAX_QUESTIONS = 12
67+
MAX_CHAPTERS = 20
68+
69+
70+
def _entry_date(line: str) -> str:
71+
"""Extract the bracketed date prefix from a ``[date] ...`` narrative line."""
72+
if line.startswith("[") and "]" in line:
73+
return line[1 : line.index("]")]
74+
return ""
75+
76+
77+
def _entry_goal(line: str) -> str:
78+
"""Extract the goal text from a ``[date] goal: outcome`` narrative line."""
79+
body = line.split("] ", 1)[-1]
80+
return body.rsplit(": ", 1)[0].strip()[:48]
81+
82+
83+
class Chapter(BaseModel):
84+
"""A sealed span of an agent's narrative.
85+
86+
When the open narrative grows past ``MAX_NARRATIVE`` it is sealed into a
87+
Chapter (a compact summary) rather than FIFO-dropping its oldest lines, so
88+
long-run history is preserved as a story arc instead of being lost.
89+
"""
90+
91+
index: int
92+
summary: str
93+
entry_count: int
94+
started: str = ""
95+
ended: str = ""
6796

6897

6998
class AgentIdentity(BaseModel):
@@ -72,11 +101,25 @@ class AgentIdentity(BaseModel):
72101
traits: list[str] = []
73102
domains: list[str] = []
74103
narrative: str = ""
104+
chapters: list[Chapter] = Field(default_factory=list)
75105
worldview: str = ""
76106
opinions: list[dict[str, Any]] = Field(default_factory=list)
77107
open_questions: list[str] = Field(default_factory=list)
78108
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
79109

110+
def full_narrative(self) -> str:
111+
"""The whole story: sealed chapter summaries + the current open narrative.
112+
113+
``narrative`` alone holds only the current (unsealed) chapter, so callers
114+
that want the agent's complete history (e.g. life summaries) must use this.
115+
"""
116+
parts: list[str] = []
117+
if self.chapters:
118+
parts.append("\n".join(f"- {c.summary}" for c in self.chapters))
119+
if self.narrative:
120+
parts.append(self.narrative)
121+
return "\n\n".join(parts)
122+
80123

81124
class IdentityManager:
82125
"""Creates, loads, saves, and builds LLM preambles from agent identities."""
@@ -142,19 +185,66 @@ def save(self, identity: AgentIdentity) -> None:
142185
tmp.rename(path)
143186

144187
def update_narrative(self, agent_id: str, goal_text: str, outcome: str) -> None:
145-
"""Append goal outcome to the agent's narrative."""
188+
"""Append a goal outcome to the agent's narrative.
189+
190+
When the open narrative would overflow ``MAX_NARRATIVE``, it is sealed
191+
into a Chapter first (preserving the history as a summary) and the new
192+
entry starts a fresh chapter -- rather than FIFO-dropping old lines.
193+
"""
146194
identity = self.load(agent_id)
147195
if not identity:
148196
return
149-
entry = f"[{datetime.now(UTC).strftime('%m-%d')}] {goal_text}: {outcome}"
197+
# Normalize newlines: a multi-line goal/outcome (e.g. LLM text) would
198+
# otherwise split one entry across lines and break chapter sealing
199+
# (entry_count / date / goal extraction all operate per-line).
200+
goal_text = goal_text.replace("\n", " ").replace("\r", " ")
201+
outcome = outcome.replace("\n", " ").replace("\r", " ")
202+
# Full date (%Y-%m-%d) so chapter spans are unambiguous across year boundaries.
203+
entry = f"[{datetime.now(UTC).strftime('%Y-%m-%d')}] {goal_text}: {outcome}"
204+
# Cap a single pathological entry so the open narrative can never exceed
205+
# MAX_NARRATIVE (a lone over-long entry would otherwise bypass sealing).
206+
if len(entry) > MAX_NARRATIVE:
207+
entry = entry[: MAX_NARRATIVE - 1] + "…"
208+
if identity.narrative and len(identity.narrative) + len(entry) + 1 > MAX_NARRATIVE:
209+
self._seal_chapter(identity)
150210
identity.narrative = (identity.narrative + "\n" + entry).strip()
151-
if len(identity.narrative) > MAX_NARRATIVE:
152-
lines = identity.narrative.splitlines()
153-
while len(identity.narrative) > MAX_NARRATIVE and len(lines) > 1:
154-
lines.pop(0)
155-
identity.narrative = "\n".join(lines)
156211
self.save(identity)
157212

213+
@staticmethod
214+
def _seal_chapter(identity: AgentIdentity) -> None:
215+
"""Roll the open narrative into a sealed Chapter and clear it."""
216+
lines = [ln for ln in identity.narrative.splitlines() if ln.strip()]
217+
if not lines:
218+
identity.narrative = "" # defensive: clear a whitespace-only narrative
219+
return
220+
started = _entry_date(lines[0])
221+
ended = _entry_date(lines[-1])
222+
index = identity.chapters[-1].index + 1 if identity.chapters else 1
223+
if started and ended and started != ended:
224+
span = f" ({started}{ended})"
225+
elif started:
226+
span = f" ({started})"
227+
else:
228+
span = ""
229+
# Carry goal text so the summary is semantically useful, not just a count:
230+
# the first goal (theme) and, if different, the last (arc).
231+
first_goal = _entry_goal(lines[0])
232+
last_goal = _entry_goal(lines[-1])
233+
theme = first_goal if first_goal == last_goal else f"{first_goal}{last_goal}"
234+
suffix = f" — {theme}" if theme else ""
235+
identity.chapters.append(
236+
Chapter(
237+
index=index,
238+
summary=f"Ch{index}{span}: {len(lines)} entries{suffix}",
239+
entry_count=len(lines),
240+
started=started,
241+
ended=ended,
242+
)
243+
)
244+
if len(identity.chapters) > MAX_CHAPTERS:
245+
identity.chapters = identity.chapters[-MAX_CHAPTERS:]
246+
identity.narrative = ""
247+
158248
def add_opinion(self, agent_id: str, domain: str, opinion: str) -> None:
159249
"""Record an opinion the agent has formed."""
160250
identity = self.load(agent_id)
@@ -203,6 +293,11 @@ def render_preamble(identity: AgentIdentity) -> str:
203293
if identity.domains:
204294
parts.append(f"Expertise: {', '.join(identity.domains)}")
205295

296+
if identity.chapters:
297+
recent_chapters = identity.chapters[-5:]
298+
chapter_lines = "\n".join(f" - {c.summary}" for c in recent_chapters)
299+
parts.append(f"\nStory so far:\n{chapter_lines}")
300+
206301
if identity.narrative:
207302
recent = identity.narrative[-400:]
208303
parts.append(f"\nRecent history:\n{recent}")

src/hive/world/life_summary.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def generate(
114114
peak_happiness_cycle=stats.cycles_alive,
115115
lowest_happiness=stats.happiness,
116116
lowest_happiness_cycle=0,
117-
narrative=identity.narrative if identity else "",
117+
narrative=identity.full_narrative() if identity else "",
118118
)
119119

120120
return summary

tests/agents/test_identity.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Tests for AgentIdentity chaptered narrative (D3c)."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from hive.agents.identity import (
8+
MAX_CHAPTERS,
9+
MAX_NARRATIVE,
10+
AgentIdentity,
11+
Chapter,
12+
IdentityManager,
13+
)
14+
15+
16+
class TestChapterModel:
17+
def test_chapters_default_empty(self) -> None:
18+
ident = AgentIdentity(agent_id="a1", display_name="Atlas")
19+
assert ident.chapters == []
20+
21+
def test_legacy_json_without_chapters_loads_empty(self) -> None:
22+
"""An identity serialized before chapters existed still loads."""
23+
legacy = '{"agent_id": "a1", "display_name": "Atlas", "narrative": "old"}'
24+
ident = AgentIdentity.model_validate_json(legacy)
25+
assert ident.chapters == []
26+
assert ident.narrative == "old"
27+
28+
def test_chapter_round_trip(self) -> None:
29+
ident = AgentIdentity(
30+
agent_id="a1",
31+
display_name="Atlas",
32+
chapters=[Chapter(index=1, summary="Ch1: 5 entries", entry_count=5)],
33+
)
34+
restored = AgentIdentity.model_validate_json(ident.model_dump_json())
35+
assert len(restored.chapters) == 1
36+
assert restored.chapters[0].index == 1
37+
assert restored.chapters[0].entry_count == 5
38+
39+
def test_manager_persists_chapters(self, tmp_path: Path) -> None:
40+
idm = IdentityManager(tmp_path)
41+
ident = AgentIdentity(
42+
agent_id="a1",
43+
display_name="Atlas",
44+
chapters=[Chapter(index=1, summary="Ch1", entry_count=3)],
45+
)
46+
idm.save(ident)
47+
loaded = idm.load("a1")
48+
assert loaded is not None
49+
assert len(loaded.chapters) == 1
50+
51+
52+
class TestSealing:
53+
def _idm_with_agent(self, tmp_path: Path) -> IdentityManager:
54+
idm = IdentityManager(tmp_path)
55+
idm.save(AgentIdentity(agent_id="a1", display_name="Atlas"))
56+
return idm
57+
58+
def test_no_chapter_below_threshold(self, tmp_path: Path) -> None:
59+
idm = self._idm_with_agent(tmp_path)
60+
idm.update_narrative("a1", "small goal", "done")
61+
ident = idm.load("a1")
62+
assert ident is not None
63+
assert ident.chapters == []
64+
assert "small goal" in ident.narrative
65+
66+
def test_overflow_seals_chapter_and_preserves_count(self, tmp_path: Path) -> None:
67+
idm = self._idm_with_agent(tmp_path)
68+
# Many entries; each ~40 chars, so we cross MAX_NARRATIVE (800) and seal.
69+
for i in range(40):
70+
idm.update_narrative("a1", f"goal number {i}", "completed ok")
71+
ident = idm.load("a1")
72+
assert ident is not None
73+
assert len(ident.chapters) >= 1, "no chapter sealed despite overflow"
74+
# Total entries are conserved across sealed chapters + the open narrative.
75+
sealed = sum(c.entry_count for c in ident.chapters)
76+
open_lines = len([ln for ln in ident.narrative.splitlines() if ln.strip()])
77+
assert sealed + open_lines == 40
78+
79+
def test_open_narrative_stays_bounded(self, tmp_path: Path) -> None:
80+
idm = self._idm_with_agent(tmp_path)
81+
for i in range(60):
82+
idm.update_narrative("a1", f"goal {i}", "done")
83+
ident = idm.load("a1")
84+
assert ident is not None
85+
# Strict bound: the open narrative never exceeds MAX_NARRATIVE.
86+
assert len(ident.narrative) <= MAX_NARRATIVE
87+
88+
def test_single_oversized_entry_is_bounded(self, tmp_path: Path) -> None:
89+
"""A lone entry longer than MAX_NARRATIVE must not bypass the bound."""
90+
idm = self._idm_with_agent(tmp_path)
91+
idm.update_narrative("a1", "x" * (MAX_NARRATIVE * 2), "done")
92+
ident = idm.load("a1")
93+
assert ident is not None
94+
assert len(ident.narrative) <= MAX_NARRATIVE
95+
96+
def test_chapter_indices_monotonic(self, tmp_path: Path) -> None:
97+
idm = self._idm_with_agent(tmp_path)
98+
for i in range(80):
99+
idm.update_narrative("a1", f"goal {i}", "done")
100+
ident = idm.load("a1")
101+
assert ident is not None
102+
indices = [c.index for c in ident.chapters]
103+
assert indices == sorted(indices)
104+
assert len(ident.chapters) <= MAX_CHAPTERS
105+
106+
def test_chapter_summary_carries_goal_text(self, tmp_path: Path) -> None:
107+
"""Summaries include goal text (theme/arc), not just a count + dates."""
108+
idm = self._idm_with_agent(tmp_path)
109+
for i in range(40):
110+
idm.update_narrative("a1", f"objective-{i}", "done")
111+
ident = idm.load("a1")
112+
assert ident is not None and ident.chapters
113+
# The first sealed chapter began with objective-0.
114+
assert "objective-0" in ident.chapters[0].summary
115+
116+
def test_newlines_in_entry_are_normalized(self, tmp_path: Path) -> None:
117+
"""A multi-line goal/outcome must stay one narrative line (sealing is per-line)."""
118+
idm = self._idm_with_agent(tmp_path)
119+
idm.update_narrative("a1", "multi\nline\ngoal", "did\r\nthings")
120+
ident = idm.load("a1")
121+
assert ident is not None
122+
assert len([ln for ln in ident.narrative.splitlines() if ln.strip()]) == 1
123+
assert "multi line goal" in ident.narrative
124+
125+
def test_max_chapters_trim_drops_oldest(self, tmp_path: Path) -> None:
126+
"""Past MAX_CHAPTERS, the oldest chapter is dropped so history stays bounded."""
127+
idm = self._idm_with_agent(tmp_path)
128+
# Large outcomes => each entry nearly fills a chapter, so the next entry
129+
# seals it; enough iterations to exceed the 20-chapter cap.
130+
big = "x" * (MAX_NARRATIVE - 60)
131+
for i in range(MAX_CHAPTERS + 6):
132+
idm.update_narrative("a1", f"goal-{i}", big)
133+
ident = idm.load("a1")
134+
assert ident is not None
135+
assert len(ident.chapters) == MAX_CHAPTERS # capped; oldest dropped
136+
assert ident.chapters[0].index > 1 # earliest chapters were trimmed
137+
assert [c.index for c in ident.chapters] == sorted(c.index for c in ident.chapters)
138+
139+
140+
class TestFullNarrative:
141+
def test_full_narrative_includes_chapters_and_open(self, tmp_path: Path) -> None:
142+
idm = IdentityManager(tmp_path)
143+
idm.save(AgentIdentity(agent_id="a1", display_name="Atlas"))
144+
for i in range(40):
145+
idm.update_narrative("a1", f"goal {i}", "done")
146+
ident = idm.load("a1")
147+
assert ident is not None and ident.chapters # at least one sealed chapter
148+
full = ident.full_narrative()
149+
# Both the sealed chapter summary and a current open line are present.
150+
assert ident.chapters[-1].summary in full
151+
assert ident.narrative in full
152+
153+
def test_full_narrative_empty_identity(self) -> None:
154+
assert AgentIdentity(agent_id="a1", display_name="Atlas").full_narrative() == ""
155+
156+
157+
class TestRenderPreamble:
158+
def test_no_story_section_without_chapters(self, tmp_path: Path) -> None:
159+
idm = IdentityManager(tmp_path)
160+
idm.save(AgentIdentity(agent_id="a1", display_name="Atlas", narrative="[01-01] x: y"))
161+
preamble = idm.build_preamble("a1")
162+
assert "Story so far" not in preamble
163+
assert "Recent history" in preamble
164+
165+
def test_chapters_render_as_story_so_far(self, tmp_path: Path) -> None:
166+
idm = IdentityManager(tmp_path)
167+
idm.save(AgentIdentity(agent_id="a1", display_name="Atlas"))
168+
for i in range(40):
169+
idm.update_narrative("a1", f"goal {i}", "done")
170+
preamble = idm.build_preamble("a1")
171+
assert "Story so far" in preamble
172+
# The most recent sealed chapter's summary appears.
173+
ident = idm.load("a1")
174+
assert ident is not None and ident.chapters
175+
assert ident.chapters[-1].summary in preamble

0 commit comments

Comments
 (0)