Three Composite Objects
Octopus models the AI agent ecosystem with three graph-structured composite objects: AgentComposite, ConversationComposite, and UserComposite. Each is a rich domain object that aggregates all data needed for a specific concern.
The Graph-Structured Model
Octopus uses a composite object pattern — each composite aggregates a cluster of related domain objects into a single, cohesive entity. This avoids the N+1 query problem and ensures that all agent logic receives a fully-hydrated object, not a shallow database row.
The three composites form a graph:
- AgentComposite is the hub — it references LLM config, tool registry, memory config, plugins, and persona.
- ConversationComposite references one AgentComposite and one UserComposite — it is the "session" connecting a user to an agent.
- UserComposite contains a list of accessible agents and a list of past ConversationComposite IDs.
AgentComposite
The AgentComposite is the complete definition of an AI agent. It is created by a platform administrator or AI engineer in the agents-app micro-frontend and persisted to the Octopus_Agents SQL table.
public class AgentComposite
{
public Guid Id { get; init; }
public string Name { get; set; }
public string TenantId { get; init; }
// LLM Configuration
public LLMConfig LLMConfig { get; set; } // provider, model, temperature
public ILLMProvider LLMProvider { get; set; } // resolved at runtime
// Memory
public MemoryConfig MemoryConfig { get; set; } // which types enabled, TTLs
public MemoryOrchestrator MemoryOrchestrator { get; set; } // wired at startup
// Tools and Plugins
public ToolRegistry ToolRegistry { get; set; } // all MCP tools available
public IReadOnlyList<IOctopusPlugin> Plugins { get; set; }
// Behavior
public AgentPersona Persona { get; set; } // name, tone, language
public string SystemPrompt { get; set; } // assembled by SystemPromptBuilder
public AgentGoal Goal { get; set; } // structured goal definition
public BehavioralConstraints Constraints { get; set; } // what the agent must not do
}
AgentComposites are loaded and hydrated at application startup (or on first use) and cached in IAgentRepository. LLM providers and plugin instances are resolved once from DI — not on every request.
ConversationComposite
The ConversationComposite represents one active conversation session — from the first user message to the session end. It is the unit of episodic memory capture.
public class ConversationComposite
{
public Guid Id { get; init; } // = sessionId
public Guid AgentId { get; init; }
public Guid UserId { get; init; }
public string TenantId { get; init; }
// Message History (working memory)
public List<LLMMessage> Messages { get; set; }
// Tool Call History
public List<ToolCallRecord> ToolCalls { get; set; }
// Session Metadata
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? EndedAt { get; set; }
public ConversationStatus Status { get; set; } // Active, Closed, Expired
// Injected Context (assembled per turn by WorkingMemoryManager)
public WorkingMemoryContext CurrentContext { get; set; }
}
UserComposite
The UserComposite represents the human (or system) interacting with agents. It tracks which agents a user can access and links to their conversation history.
public class UserComposite
{
public Guid Id { get; init; }
public string ExternalUserId { get; set; } // from auth provider
public string TenantId { get; init; }
public string DisplayName { get; set; }
public UserPreferences Preferences { get; set; } // language, timezone
// Agent Access
public IReadOnlyList<Guid> AccessibleAgentIds { get; set; }
// Conversation History (references only — not loaded unless needed)
public IReadOnlyList<Guid> ConversationIds { get; set; }
}
Composite Relationships
| Relationship | Cardinality | Notes |
|---|---|---|
| User → Agents | Many-to-many | Access controlled via UserComposite.AccessibleAgentIds |
| User → Conversations | One-to-many | Each session creates a new ConversationComposite |
| Agent → Conversations | One-to-many | Many users can talk to the same agent simultaneously |
| Conversation → Agent | Many-to-one | A conversation is always with exactly one agent |
| Conversation → User | Many-to-one | A conversation is always with exactly one user |
Composite Loading Pattern
Composites are loaded via repository interfaces — never via direct DbContext access from application code:
// Loading an agent for a conversation
var agent = await _agentRepository.GetCompositeAsync(agentId, tenantId);
// Loading a conversation (creates if new session)
var conversation = await _conversationRepository
.GetOrCreateAsync(sessionId, agentId, userId, tenantId);
// Loading a user's accessible agents
var user = await _userRepository.GetCompositeAsync(userId, tenantId);
var accessibleAgents = user.AccessibleAgentIds;
Every repository method requires a tenantId parameter. The underlying queries always add a WHERE TenantId = @tenantId filter. Cross-tenant composite access is not possible through the standard repository layer.