| """Context builder β assembles a compact, role-scoped prompt per agent turn. |
| |
| Layering order (innermost β outermost, smallest β largest prompt budget): |
| |
| 1. IDENTITY β pinned persona (permanent cost, never drops) |
| 2. SHARED GOAL β the scenario objective (only when set; from the projection) |
| 3. CURRENT SCENE β world state from the stage projection |
| 4. THE DISCUSSION β role-aware (ADR-0023 + follow-up): |
| Β· workers see WHAT'S BEEN SAID β the recent table to react to; |
| Β· judges see THE EXCHANGE TO JUDGE β the *complete* ordered |
| transcript, so a ruling weighs the whole discussion, not its tail. |
| 5. YOUR MEMORY β episodic/salience recall (the earlier arc + world beats/verdicts; |
| deduped against block 4 so no line is printed twice) |
| 6. VISITOR β recent user injections (always salient) |
| [7. EXTRA] β injected by ManifestAgent subclass for scenario-specific context |
| [8. OUTPUT FORMAT] β JSON constraint block (appended by structured.py) |
| |
| The builder owns the structure. Agents own only the persona and the action. |
| Changing the prompt strategy for all agents is a one-file edit here. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from src import observability as obs |
| from src.core.events import Event |
| from src.core.memory import EpisodicMemory |
| from src.core.projections import StageProjection |
|
|
| |
| |
| |
| _PUBLIC_SPEECH_KINDS = frozenset({"agent.spoke", "oracle.spoke"}) |
|
|
|
|
| class ContextBuilder: |
| """Assembles a compact, role-scoped prompt for a single agent turn.""" |
|
|
| |
| |
| |
| _BLACKBOARD_WINDOW = 8 |
|
|
| |
| |
| |
| _TRANSCRIPT_LIMIT = 80 |
|
|
| def build( |
| self, |
| *, |
| agent_name: str, |
| persona: str, |
| projection: StageProjection, |
| all_events: tuple[Event, ...], |
| memory_window: int = 8, |
| memory_text: str | None = None, |
| role: str = "worker", |
| ) -> str: |
| """Build a prompt string from layered context. |
| |
| Args: |
| agent_name: Used to filter visible events for memory. |
| persona: Fixed identity text (IDENTITY block). |
| projection: Current world-state view. |
| all_events: Full ledger tail (this run) β drives memory recall AND a judge's |
| full transcript. |
| memory_window: How many events to include (for EpisodicMemory). |
| memory_text: Pre-computed memory string (pass to override the default |
| EpisodicMemory computation, e.g. when using SalienceMemory). |
| role: The agent's role. ``"judge"`` gets the complete exchange to rule |
| on; everyone else gets the recent blackboard to react to. |
| """ |
| if memory_text is None: |
| memory_text = EpisodicMemory(agent_name, max_recent=memory_window).format_for_prompt(all_events) |
|
|
| |
| |
| |
| |
| |
| discussion_block, shown_texts = self._discussion(role, projection, all_events) |
| shown_texts = set(shown_texts) | {(projection.current_scene or "").strip()} |
| memory_text = self._dedup_memory(memory_text, shown_texts) |
|
|
| visitor_lines = "\n".join(f"- {a}" for a in projection.user_artifacts[-3:]) or "(quiet)" |
|
|
| goal_block = f"SHARED GOAL\n{projection.goal}\n\n" if projection.goal else "" |
|
|
| |
| |
| memory_block = ( |
| f"YOUR MEMORY (recent events you witnessed)\n{memory_text}" |
| if memory_text.strip() |
| else "YOUR MEMORY\n(nothing beyond the exchange above)" |
| ) |
|
|
| prompt = ( |
| f"IDENTITY\n{persona}\n\n" |
| f"{goal_block}" |
| f"CURRENT SCENE\n{projection.current_scene}\n\n" |
| f"{discussion_block}" |
| f"{memory_block}\n\n" |
| f"VISITOR DISTURBANCES\n{visitor_lines}" |
| ) |
| |
| |
| discussion_section = "TRANSCRIPT" if role == "judge" else "BLACKBOARD" |
| sections = ["IDENTITY", "CURRENT SCENE", discussion_section, "MEMORY", "VISITOR"] |
| if goal_block: |
| sections.insert(1, "SHARED GOAL") |
| obs.log( |
| "context.build", |
| level="debug", |
| agent=agent_name, |
| role=role, |
| sections=sections, |
| prompt_chars=len(prompt), |
| memory_chars=len(memory_text), |
| ) |
| return prompt |
|
|
| |
|
|
| def _discussion( |
| self, role: str, projection: StageProjection, all_events: tuple[Event, ...] |
| ) -> tuple[str, set[str]]: |
| """The discussion block + the set of texts it shows (for memory dedup). |
| |
| Judges get the full ordered transcript (rule on everything); everyone else gets the |
| recent blackboard (react to the table).""" |
| if role == "judge": |
| lines = self._public_lines(all_events) |
| return self._transcript_block(lines), {self._note_text(line) for line in lines} |
| shown_notes = [n for n in projection.agent_notes if n][-self._BLACKBOARD_WINDOW :] |
| return self._blackboard_block(shown_notes), {self._note_text(n) for n in shown_notes} |
|
|
| @staticmethod |
| def _public_lines(all_events: tuple[Event, ...]) -> list[str]: |
| """Every public spoken line in the run, oldest β newest, as ``"actor: text"``. |
| |
| This is the discussion a judge rules on. Private thoughts are excluded (see |
| ``_PUBLIC_SPEECH_KINDS``); a line with no text is skipped.""" |
| out: list[str] = [] |
| for e in all_events: |
| if e.kind in _PUBLIC_SPEECH_KINDS: |
| text = str(e.payload.get("text", "")).strip() |
| if text: |
| out.append(f"{e.actor}: {text}") |
| return out |
|
|
| @classmethod |
| def _transcript_block(cls, lines: list[str]) -> str: |
| """A judge's view: the complete exchange, in order, with a 'weigh all of it' nudge.""" |
| if not lines: |
| return "THE EXCHANGE TO JUDGE\n(no one has spoken yet)\n\n" |
| shown = lines[-cls._TRANSCRIPT_LIMIT :] |
| head = ( |
| f"(showing the last {cls._TRANSCRIPT_LIMIT} of {len(lines)} lines)\n" |
| if len(lines) > cls._TRANSCRIPT_LIMIT |
| else "" |
| ) |
| body = "\n".join(f"- {line}" for line in shown) |
| return ( |
| "THE EXCHANGE TO JUDGE (every spoken line, in order β weigh ALL of it, not just the last few):\n" |
| f"{head}{body}\n\n" |
| ) |
|
|
| @staticmethod |
| def _note_text(note: str) -> str: |
| """The spoken text of a discussion line, stripped of its ``actor:`` prefix. |
| |
| Lines read ``"actor: text"``, ``"actor [kind]: text"``, or ``"π actor believes: |
| text"`` β all carry the content after the first ``": "``. Used to match the same |
| line where it appears in a memory entry (``[turn NNN][kind] text``).""" |
| _, _, text = note.partition(": ") |
| return (text or note).strip() |
|
|
| @staticmethod |
| def _dedup_memory(memory_text: str, shown_texts: set[str]) -> str: |
| """Drop memory lines already shown in the discussion block or CURRENT SCENE. |
| |
| Memory lines are ``[turn NNN][kind] <text>``. The most-recent ``world.observed`` is |
| also the CURRENT SCENE, and (now that peers' spoken lines are recallable) the |
| discussion lines also appear in block 4. We drop any memory line ending with one of |
| those already-shown texts β so the agent reads each line once: the blackboard/ |
| transcript holds the discussion, memory holds the earlier arc + world/verdict beats. |
| Returns the empty string if every line was a duplicate β the caller then shows a |
| short pointer rather than a blank or a re-print of the discussion.""" |
| shown = {s for s in shown_texts if s} |
| if not shown: |
| return memory_text |
| kept = [line for line in memory_text.splitlines() if not any(line.rstrip().endswith(s) for s in shown)] |
| return "\n".join(kept) |
|
|
| @staticmethod |
| def _blackboard_block(shown_notes: list[str]) -> str: |
| """The shared blackboard: what the rest of the cast just said or did. |
| |
| Without this an agent sees only the world text and its own past lines, so a |
| small model loops on the same clue and never reacts to anyone (the "shared |
| blackboard isn't shared" gap β ADR-0023). ``shown_notes`` is the already-sliced |
| recent tail and carries only the public ``text`` of each peer event (never their |
| private thought), so surfacing it shares the conversation without leaking minds. |
| """ |
| notes = [n for n in shown_notes if n] |
| if not notes: |
| return "WHAT'S BEEN SAID\n(you go first β set the tone)\n\n" |
| lines = "\n".join(f"- {n}" for n in notes) |
| return ( |
| "WHAT'S BEEN SAID (the table so far β react to it)\n" |
| f"{lines}\n" |
| "Do NOT echo or rephrase any line above. Add a GENUINELY NEW angle β a different " |
| "sense, detail, or suspicion β that moves the conversation forward.\n\n" |
| ) |
|
|