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:
0
fellowship/core/__init__.py
Normal file
0
fellowship/core/__init__.py
Normal file
42
fellowship/core/context.py
Normal file
42
fellowship/core/context.py
Normal 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
66
fellowship/core/loop.py
Normal 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
|
||||
92
fellowship/core/orchestrator.py
Normal file
92
fellowship/core/orchestrator.py
Normal 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
43
fellowship/core/queue.py
Normal 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()
|
||||
41
fellowship/core/rectifier.py
Normal file
41
fellowship/core/rectifier.py
Normal 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
|
||||
76
fellowship/core/session.py
Normal file
76
fellowship/core/session.py
Normal 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
|
||||
48
fellowship/core/turn_engine.py
Normal file
48
fellowship/core/turn_engine.py
Normal 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
|
||||
Reference in New Issue
Block a user