Files
CHORUS/internal/council/manager.go

447 lines
14 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package council
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"math/rand"
"net/http"
"strings"
"sync"
"time"
"chorus/internal/persona"
)
// CouncilOpportunity represents a council formation opportunity from WHOOSH.
type CouncilOpportunity struct {
CouncilID string `json:"council_id"`
ProjectName string `json:"project_name"`
Repository string `json:"repository"`
ProjectBrief string `json:"project_brief"`
CoreRoles []CouncilRole `json:"core_roles"`
OptionalRoles []CouncilRole `json:"optional_roles"`
UCXLAddress string `json:"ucxl_address"`
FormationDeadline time.Time `json:"formation_deadline"`
CreatedAt time.Time `json:"created_at"`
Metadata map[string]interface{} `json:"metadata"`
}
// CouncilRole represents a single role available within a council.
type CouncilRole struct {
RoleName string `json:"role_name"`
AgentName string `json:"agent_name"`
Required bool `json:"required"`
RequiredSkills []string `json:"required_skills"`
Description string `json:"description"`
}
// RoleProfile mirrors WHOOSH role profile metadata included in claim responses.
type RoleProfile struct {
RoleName string `json:"role_name"`
DisplayName string `json:"display_name"`
PromptKey string `json:"prompt_key"`
PromptPack string `json:"prompt_pack"`
Capabilities []string `json:"capabilities"`
BriefRoutingHint string `json:"brief_routing_hint"`
DefaultBriefOwner bool `json:"default_brief_owner"`
}
// CouncilBrief carries the high-level brief metadata for an activated council.
type CouncilBrief struct {
CouncilID string `json:"council_id"`
RoleName string `json:"role_name"`
ProjectName string `json:"project_name"`
Repository string `json:"repository"`
Summary string `json:"summary"`
BriefURL string `json:"brief_url"`
IssueID *int64 `json:"issue_id"`
UCXLAddress string `json:"ucxl_address"`
ExpectedArtifacts []string `json:"expected_artifacts"`
HMMMTopic string `json:"hmmm_topic"`
}
// RoleAssignment keeps track of the agent's current council engagement.
type RoleAssignment struct {
CouncilID string
RoleName string
UCXLAddress string
AssignedAt time.Time
Profile RoleProfile
Brief *CouncilBrief
Persona *persona.Persona
PersonaHash string
}
var ErrRoleConflict = errors.New("council role already claimed")
const defaultModelProvider = "ollama"
// Manager handles council opportunity evaluation, persona preparation, and brief handoff.
type Manager struct {
agentID string
agentName string
endpoint string
p2pAddr string
capabilities []string
httpClient *http.Client
personaLoader *persona.Loader
mu sync.Mutex
currentAssignment *RoleAssignment
}
// NewManager creates a new council manager.
func NewManager(agentID, agentName, endpoint, p2pAddr string, capabilities []string) *Manager {
loader, err := persona.NewLoader()
if err != nil {
fmt.Printf("⚠️ Persona loader initialisation failed: %v\n", err)
}
return &Manager{
agentID: agentID,
agentName: agentName,
endpoint: endpoint,
p2pAddr: p2pAddr,
capabilities: capabilities,
httpClient: &http.Client{Timeout: 10 * time.Second},
personaLoader: loader,
}
}
// AgentID returns the agent's identifier.
func (m *Manager) AgentID() string {
return m.agentID
}
// EvaluateOpportunity analyzes a council opportunity and decides whether to claim a role.
func (m *Manager) EvaluateOpportunity(opportunity *CouncilOpportunity, whooshEndpoint string) error {
fmt.Printf("\n🤔 Evaluating council opportunity for: %s\n", opportunity.ProjectName)
if current := m.currentAssignmentSnapshot(); current != nil {
fmt.Printf(" Agent already assigned to council %s as %s; skipping new claims\n", current.CouncilID, current.RoleName)
return nil
}
const maxAttempts = 10
const retryDelay = 3 * time.Second
var attemptedAtLeastOne bool
for attempt := 1; attempt <= maxAttempts; attempt++ {
assignment, attemptedCore, err := m.tryClaimRoles(opportunity.CoreRoles, opportunity, whooshEndpoint, "CORE")
attemptedAtLeastOne = attemptedAtLeastOne || attemptedCore
if assignment != nil {
m.setCurrentAssignment(assignment)
return nil
}
if err != nil && !errors.Is(err, ErrRoleConflict) {
return err
}
assignment, attemptedOptional, err := m.tryClaimRoles(opportunity.OptionalRoles, opportunity, whooshEndpoint, "OPTIONAL")
attemptedAtLeastOne = attemptedAtLeastOne || attemptedOptional
if assignment != nil {
m.setCurrentAssignment(assignment)
return nil
}
if err != nil && !errors.Is(err, ErrRoleConflict) {
return err
}
if !attemptedAtLeastOne {
fmt.Printf(" ✗ No suitable roles found for this agent\n\n")
return nil
}
fmt.Printf(" ↻ Attempt %d did not secure a council role; retrying in %s...\n", attempt, retryDelay)
time.Sleep(retryDelay)
}
return fmt.Errorf("exhausted council role claim attempts for council %s", opportunity.CouncilID)
}
func (m *Manager) tryClaimRoles(roles []CouncilRole, opportunity *CouncilOpportunity, whooshEndpoint string, roleType string) (*RoleAssignment, bool, error) {
var attempted bool
// Shuffle roles deterministically per agent+council to reduce herd on the first role
shuffled := append([]CouncilRole(nil), roles...)
if len(shuffled) > 1 {
h := fnv.New64a()
_, _ = h.Write([]byte(m.agentID))
_, _ = h.Write([]byte(opportunity.CouncilID))
seed := int64(h.Sum64())
r := rand.New(rand.NewSource(seed))
r.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
}
for _, role := range shuffled {
if !m.shouldClaimRole(role, opportunity) {
continue
}
attempted = true
fmt.Printf(" ✓ Attempting to claim %s role: %s (%s)\n", roleType, role.AgentName, role.RoleName)
assignment, err := m.claimRole(opportunity, role, whooshEndpoint)
if assignment != nil {
return assignment, attempted, nil
}
if errors.Is(err, ErrRoleConflict) {
fmt.Printf(" ⚠️ Role %s already claimed by another agent, trying next role...\n", role.RoleName)
continue
}
if err != nil {
return nil, attempted, err
}
}
return nil, attempted, nil
}
func (m *Manager) shouldClaimRole(role CouncilRole, _ *CouncilOpportunity) bool {
if m.hasActiveAssignment() {
return false
}
// TODO: implement capability-based selection. For now, opportunistically claim any available role.
return true
}
func (m *Manager) claimRole(opportunity *CouncilOpportunity, role CouncilRole, whooshEndpoint string) (*RoleAssignment, error) {
claimURL := fmt.Sprintf("%s/api/v1/councils/%s/claims", strings.TrimRight(whooshEndpoint, "/"), opportunity.CouncilID)
claim := map[string]interface{}{
"agent_id": m.agentID,
"agent_name": m.agentName,
"role_name": role.RoleName,
"capabilities": m.capabilities,
"confidence": 0.75, // TODO: calculate based on capability match quality.
"reasoning": fmt.Sprintf("Agent has capabilities matching role: %s", role.RoleName),
"endpoint": m.endpoint,
"p2p_addr": m.p2pAddr,
}
payload, err := json.Marshal(claim)
if err != nil {
return nil, fmt.Errorf("failed to marshal claim: %w", err)
}
req, err := http.NewRequest(http.MethodPost, claimURL, bytes.NewBuffer(payload))
if err != nil {
return nil, fmt.Errorf("failed to create claim request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send claim: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
var errorResp map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
if resp.StatusCode == http.StatusConflict {
reason := "role already claimed"
if msg, ok := errorResp["error"].(string); ok && msg != "" {
reason = msg
}
return nil, fmt.Errorf("%w: %s", ErrRoleConflict, reason)
}
return nil, fmt.Errorf("claim rejected (status %d): %v", resp.StatusCode, errorResp)
}
var claimResp roleClaimResponse
if err := json.NewDecoder(resp.Body).Decode(&claimResp); err != nil {
return nil, fmt.Errorf("failed to decode claim response: %w", err)
}
assignment := &RoleAssignment{
CouncilID: opportunity.CouncilID,
RoleName: role.RoleName,
UCXLAddress: claimResp.UCXLAddress,
Profile: claimResp.RoleProfile,
}
if t, err := time.Parse(time.RFC3339, claimResp.AssignedAt); err == nil {
assignment.AssignedAt = t
}
if claimResp.CouncilBrief != nil {
assignment.Brief = claimResp.CouncilBrief
}
fmt.Printf("\n✅ ROLE CLAIM ACCEPTED!\n")
fmt.Printf(" Council ID: %s\n", opportunity.CouncilID)
fmt.Printf(" Role: %s (%s)\n", role.AgentName, role.RoleName)
fmt.Printf(" UCXL: %s\n", assignment.UCXLAddress)
fmt.Printf(" Assigned At: %s\n", claimResp.AssignedAt)
if err := m.preparePersonaAndAck(opportunity.CouncilID, role.RoleName, &assignment.Profile, claimResp.CouncilBrief, whooshEndpoint, assignment); err != nil {
fmt.Printf(" ⚠️ Persona preparation encountered an issue: %v\n", err)
}
fmt.Printf("\n")
return assignment, nil
}
func (m *Manager) preparePersonaAndAck(councilID, roleName string, profile *RoleProfile, brief *CouncilBrief, whooshEndpoint string, assignment *RoleAssignment) error {
if m.personaLoader == nil {
return m.sendPersonaAck(councilID, roleName, whooshEndpoint, nil, "", "failed", []string{"persona loader unavailable"})
}
promptKey := profile.PromptKey
if promptKey == "" {
promptKey = roleName
}
personaCapabilities := profile.Capabilities
personaCapabilities = append([]string{}, personaCapabilities...)
personaEntry, err := m.personaLoader.Compose(promptKey, profile.DisplayName, "", personaCapabilities)
if err != nil {
return m.sendPersonaAck(councilID, roleName, whooshEndpoint, nil, "", "failed", []string{err.Error()})
}
hash := sha256.Sum256([]byte(personaEntry.SystemPrompt))
personaHash := hex.EncodeToString(hash[:])
assignment.Persona = personaEntry
assignment.PersonaHash = personaHash
if err := m.sendPersonaAck(councilID, roleName, whooshEndpoint, personaEntry, personaHash, "loaded", nil); err != nil {
return err
}
return nil
}
func (m *Manager) sendPersonaAck(councilID, roleName, whooshEndpoint string, personaEntry *persona.Persona, personaHash string, status string, errs []string) error {
ackURL := fmt.Sprintf("%s/api/v1/councils/%s/roles/%s/personas", strings.TrimRight(whooshEndpoint, "/"), councilID, roleName)
payload := map[string]interface{}{
"agent_id": m.agentID,
"status": status,
"model_provider": defaultModelProvider,
"capabilities": m.capabilities,
"metadata": map[string]interface{}{
"endpoint": m.endpoint,
"p2p_addr": m.p2pAddr,
"agent_name": m.agentName,
},
}
if personaEntry != nil {
payload["system_prompt_hash"] = personaHash
payload["model_name"] = personaEntry.Model
if len(personaEntry.Capabilities) > 0 {
payload["capabilities"] = personaEntry.Capabilities
}
}
if len(errs) > 0 {
payload["errors"] = errs
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal persona ack: %w", err)
}
req, err := http.NewRequest(http.MethodPost, ackURL, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("create persona ack request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send persona ack: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return fmt.Errorf("persona ack rejected with status %d", resp.StatusCode)
}
fmt.Printf(" 📫 Persona status '%s' acknowledged by WHOOSH\n", status)
return nil
}
// HandleCouncilBrief records the design brief assigned to this agent once WHOOSH dispatches it.
func (m *Manager) HandleCouncilBrief(councilID, roleName string, brief *CouncilBrief) {
if brief == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.currentAssignment == nil {
fmt.Printf("⚠️ Received council brief for %s (%s) but agent has no active assignment\n", councilID, roleName)
return
}
if m.currentAssignment.CouncilID != councilID || !strings.EqualFold(m.currentAssignment.RoleName, roleName) {
fmt.Printf("⚠️ Received council brief for %s (%s) but agent is assigned to %s (%s)\n", councilID, roleName, m.currentAssignment.CouncilID, m.currentAssignment.RoleName)
return
}
brief.CouncilID = councilID
brief.RoleName = roleName
m.currentAssignment.Brief = brief
fmt.Printf("📦 Design brief received for council %s (%s)\n", councilID, roleName)
if brief.BriefURL != "" {
fmt.Printf(" Brief URL: %s\n", brief.BriefURL)
}
if brief.Summary != "" {
fmt.Printf(" Summary: %s\n", brief.Summary)
}
if len(brief.ExpectedArtifacts) > 0 {
fmt.Printf(" Expected Artifacts: %v\n", brief.ExpectedArtifacts)
}
if brief.HMMMTopic != "" {
fmt.Printf(" HMMM Topic: %s\n", brief.HMMMTopic)
}
}
func (m *Manager) hasActiveAssignment() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.currentAssignment != nil
}
func (m *Manager) setCurrentAssignment(assignment *RoleAssignment) {
m.mu.Lock()
defer m.mu.Unlock()
m.currentAssignment = assignment
}
func (m *Manager) currentAssignmentSnapshot() *RoleAssignment {
m.mu.Lock()
defer m.mu.Unlock()
return m.currentAssignment
}
// roleClaimResponse mirrors WHOOSH role claim response payload.
type roleClaimResponse struct {
Status string `json:"status"`
CouncilID string `json:"council_id"`
RoleName string `json:"role_name"`
UCXLAddress string `json:"ucxl_address"`
AssignedAt string `json:"assigned_at"`
RoleProfile RoleProfile `json:"role_profile"`
CouncilBrief *CouncilBrief `json:"council_brief"`
PersonaStatus string `json:"persona_status"`
}