Initial project structure

Scaffold all modules, route stubs, data models, and config.
No logic implemented yet — all core methods raise NotImplementedError.
Establishes the full directory layout matching the architecture in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaroslav Benes
2026-04-08 14:48:48 +02:00
commit 083cbb1fa7
32 changed files with 1507 additions and 0 deletions

View File

View File

@@ -0,0 +1,42 @@
"""
Context manager — assembles the conversation history for a bot's prompt.
Ensures bots only see messages, never foreign system prompts.
Handles context summarization when enabled.
"""
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fellowship.core.session import Session, BotConfig
logger = logging.getLogger(__name__)
class ContextManager:
def __init__(self, session: "Session") -> None:
self.session = session
def build_context(self, bot: "BotConfig") -> list[dict]:
"""
Return the conversation history formatted as LLM messages for the given bot.
Uses shared_context or scoped_context based on session options.
Skips any slots with content=None (reserved but not yet filled).
"""
raise NotImplementedError
def estimate_tokens(self, messages: list[dict]) -> int:
"""
Estimate total token count for a list of messages.
Used to check against max_context_tokens.
"""
raise NotImplementedError
async def summarize(self) -> None:
"""
Summarize the older portion of history to reduce context size.
Retains a recent tail of messages intact.
Stores the summary in session state; future context builds use summary + tail.
Full history list is preserved unchanged.
"""
raise NotImplementedError

66
fellowship/core/loop.py Normal file
View File

@@ -0,0 +1,66 @@
"""
Session loop — the core driver for each active session.
One SessionLoop instance runs per session as an asyncio Task.
Never dispatches two LLM calls simultaneously.
Starts immediately for autonomous mode; waits for first talker message otherwise.
"""
import asyncio
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fellowship.core.session import Session
logger = logging.getLogger(__name__)
class SessionLoop:
def __init__(self, session: "Session") -> None:
self.session = session
self._task: asyncio.Task | None = None
self._pause_event = asyncio.Event()
self._pause_event.set() # Not paused by default
def start(self) -> None:
"""Start the loop as a background asyncio Task."""
self._task = asyncio.create_task(self._run(), name=f"loop-{self.session.token[:8]}")
def stop(self) -> None:
"""Cancel the loop task."""
if self._task:
self._task.cancel()
def pause(self) -> None:
"""Pause the loop. Clears the internal event so the loop blocks."""
self._pause_event.clear()
def resume(self) -> None:
"""Resume a paused loop."""
self._pause_event.set()
async def _run(self) -> None:
"""
Main loop body. Runs until the session ends or the task is cancelled.
Each iteration:
1. Wait if paused.
2. Check for pending talker messages (all modes).
3. Determine next bot speaker (round_robin or orchestrator).
4. Execute bot turn.
5. Check end conditions.
"""
raise NotImplementedError
async def _handle_talker_message(self) -> bool:
"""
Drain one message from the talker queue into history.
Returns True if a message was processed.
"""
raise NotImplementedError
async def _check_end_conditions(self) -> bool:
"""
Check all configured end conditions (max_turns, max_time, max_context_tokens).
Returns True if the session should end.
"""
raise NotImplementedError

View File

@@ -0,0 +1,92 @@
"""
Orchestrator — stateless LLM call that selects the next speaker or ends the session.
Called fresh each turn when turn_order is ORCHESTRATED.
Output is a tool call only; any text is discarded.
"""
import logging
from dataclasses import dataclass
from typing import Literal, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from fellowship.core.session import Session
logger = logging.getLogger(__name__)
@dataclass
class OrchestratorDecision:
action: Literal["select_speaker", "hold", "end_session"]
bot_name: Optional[str] = None # set when action == "select_speaker"
reason: Optional[str] = None # set when action == "end_session"
# Tool definitions sent to the LLM with every orchestrator call
ORCHESTRATOR_TOOLS = [
{
"type": "function",
"function": {
"name": "select_speaker",
"description": "Choose which bot should speak next.",
"parameters": {
"type": "object",
"properties": {
"bot_name": {"type": "string", "description": "Name of the bot to speak next"},
},
"required": ["bot_name"],
},
},
},
{
"type": "function",
"function": {
"name": "hold",
"description": (
"Do not prompt any bot this turn. "
"Use when the conversation implies bots should stay silent."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "end_session",
"description": "End the session. Only use when the session goal has been reached.",
"parameters": {
"type": "object",
"properties": {
"reason": {"type": "string", "description": "Why the session is ending"},
},
"required": ["reason"],
},
},
},
]
class Orchestrator:
def __init__(self, session: "Session") -> None:
self.session = session
async def decide(self) -> OrchestratorDecision:
"""
Build the orchestrator prompt, call the LLM, parse the tool call response.
Any text output from the LLM is ignored — only the tool call matters.
"""
raise NotImplementedError
def _build_system_prompt(self) -> str:
"""
Build the orchestrator system prompt including:
- Its role and instructions
- Overview of how Fellowship works
- Full bot roster (names, roles, system prompts)
- Session goal (if set)
- Instruction to always respond with a tool call
"""
raise NotImplementedError
def _parse_tool_call(self, response: dict) -> OrchestratorDecision:
"""Parse the LLM tool call response into an OrchestratorDecision."""
raise NotImplementedError

43
fellowship/core/queue.py Normal file
View File

@@ -0,0 +1,43 @@
"""
Talker message queue — holds incoming talker messages in arrival order.
The session loop drains this queue one message at a time.
"""
import asyncio
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class QueuedMessage:
talker_id: str
talker_name: str
content: str
class MessageQueue:
def __init__(self) -> None:
self._queue: asyncio.Queue[QueuedMessage] = asyncio.Queue()
def enqueue(self, message: QueuedMessage) -> None:
"""Add a talker message to the queue. Non-blocking."""
self._queue.put_nowait(message)
async def dequeue(self) -> QueuedMessage:
"""Wait for and return the next message. Blocks until one is available."""
return await self._queue.get()
def dequeue_nowait(self) -> QueuedMessage | None:
"""Return the next message without waiting, or None if the queue is empty."""
try:
return self._queue.get_nowait()
except asyncio.QueueEmpty:
return None
def is_empty(self) -> bool:
return self._queue.empty()
def size(self) -> int:
return self._queue.qsize()

View File

@@ -0,0 +1,41 @@
"""
History rectifier — manages slot reservation and message insertion ordering.
Ensures a bot's response appears at the correct logical position in history
even when talker messages arrive during LLM generation.
"""
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fellowship.core.session import Session, Message
logger = logging.getLogger(__name__)
class HistoryRectifier:
def __init__(self, session: "Session") -> None:
self.session = session
def reserve_slot(self, sender: str, turn: int) -> int:
"""
Append a placeholder Message (content=None) to history.
Returns the index of the reserved slot.
Called immediately before an LLM call is dispatched.
"""
raise NotImplementedError
def fill_slot(self, index: int, content: str, tokens: int) -> None:
"""
Fill the reserved slot at the given index with the completed response.
Called when the LLM call returns.
"""
raise NotImplementedError
def insert_after_slot(self, slot_index: int, message: "Message") -> None:
"""
Insert a talker message after the given slot index.
Called when a talker message arrives while a slot is reserved.
Subsequent messages increment their positions accordingly.
"""
raise NotImplementedError

View File

@@ -0,0 +1,76 @@
"""
Session data model — the single source of truth for a session's state.
All other core modules read from and write to a Session instance.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional
class ParticipationMode(str, Enum):
AUTONOMOUS = "autonomous"
REACTIVE = "reactive"
COLLABORATIVE = "collaborative"
class TurnOrder(str, Enum):
ROUND_ROBIN = "round_robin"
ORCHESTRATED = "orchestrated"
class SessionState(str, Enum):
WAITING = "waiting" # Waiting for first talker message (reactive/collaborative)
RUNNING = "running" # Loop is active
PAUSED = "paused" # Paused via API
ENDED = "ended" # Session is over
@dataclass
class BotConfig:
name: str
system_prompt: str
model: Optional[str] = None
temperature: Optional[float] = None
role: Optional[str] = None
@dataclass
class Message:
role: str # "bot", "talker", "system"
sender: str # bot name or talker display name
content: Optional[str] # None while a rectification slot is reserved
turn: int
tokens: Optional[int] = None
@dataclass
class SessionOptions:
participation_mode: ParticipationMode = ParticipationMode.AUTONOMOUS
turn_order: TurnOrder = TurnOrder.ROUND_ROBIN
max_talkers: int = 1
max_turns: Optional[int] = None
max_time: Optional[int] = None
max_context_tokens: Optional[int] = None
rectify_history: bool = True
summarize_context: bool = False
stream_tokens: bool = False
goal: Optional[str] = None
debug: bool = False
llm_base_url: Optional[str] = None
llm_api_key: Optional[str] = None
@dataclass
class Session:
token: str
bots: list[BotConfig]
options: SessionOptions
global_system_prompt: Optional[str] = None
state: SessionState = SessionState.WAITING
history: list[Message] = field(default_factory=list)
turn_count: int = 0
robin_index: int = 0 # Current position in round_robin order
created_at: float = 0.0 # Unix timestamp
talker_count: int = 0
observer_count: int = 0

View File

@@ -0,0 +1,48 @@
"""
Turn engine — constructs a bot's prompt and executes its LLM call.
Handles rectification slot reservation and filling.
"""
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fellowship.core.session import Session, BotConfig, Message
logger = logging.getLogger(__name__)
class TurnEngine:
def __init__(self, session: "Session") -> None:
self.session = session
async def execute_turn(self, bot: "BotConfig") -> "Message":
"""
Full turn pipeline for one bot:
1. Reserve a rectification slot in history.
2. Assemble the bot's prompt (global system + bot system + context).
3. Call the LLM.
4. Fill the reserved slot with the response.
5. Return the completed Message.
"""
raise NotImplementedError
def _reserve_slot(self, bot: "BotConfig") -> int:
"""
Append a placeholder Message (content=None) to history and return its index.
This is the rectification slot — talker messages arriving during generation
are inserted after this index.
"""
raise NotImplementedError
def _fill_slot(self, index: int, content: str, tokens: int) -> None:
"""Fill a previously reserved slot with the completed bot response."""
raise NotImplementedError
def _build_prompt(self, bot: "BotConfig") -> list[dict]:
"""
Assemble the messages list for the LLM call:
- System message: global_system_prompt + bot system_prompt
- User/assistant messages from context (messages only, no foreign system prompts)
"""
raise NotImplementedError