diff --git a/cmd/synapbus/main.go b/cmd/synapbus/main.go index 9635a0b..e62a564 100644 --- a/cmd/synapbus/main.go +++ b/cmd/synapbus/main.go @@ -47,6 +47,7 @@ import ( "github.com/synapbus/synapbus/internal/storage" "github.com/synapbus/synapbus/internal/push" "github.com/synapbus/synapbus/internal/trace" + "github.com/synapbus/synapbus/internal/trust" "github.com/synapbus/synapbus/internal/web" "github.com/synapbus/synapbus/internal/webhooks" ) @@ -291,6 +292,11 @@ func runServe(cmd *cobra.Command, args []string) error { msgService.SetReactionEnricher(&reactionEnricherAdapter{svc: reactionService}) slog.Info("reaction service initialized") + // Create trust service + trustStore := trust.NewSQLiteStore(db.DB) + trustService := trust.NewService(trustStore, slog.Default()) + slog.Info("trust service initialized") + // Initialize auth subsystem authSecret := make([]byte, 32) if _, err := rand.Read(authSecret); err != nil { @@ -473,7 +479,7 @@ func runServe(cmd *cobra.Command, args []string) error { actionIndex := actions.NewIndex(actionRegistry.List()) // Create MCP server (4 hybrid tools: my_status, send_message, search, execute) - mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, reactionService, con, jsPool, actionRegistry, actionIndex, db.DB) + mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, reactionService, trustService, con, jsPool, actionRegistry, actionIndex, db.DB) startTime := time.Now() // Start task expiry worker @@ -634,6 +640,7 @@ func runServe(cmd *cobra.Command, args []string) error { DB: db.DB, Version: version, PushService: pushService, + TrustService: trustService, }) r.Mount("/", apiRouter) @@ -997,3 +1004,44 @@ func (a *channelLookupAdapter) GetChannelIDByName(ctx context.Context, name stri } return ch.ID, nil } + +// trustAdjusterAdapter adapts trust.Service to reactions.TrustAdjuster. +type trustAdjusterAdapter struct { + svc *trust.Service +} + +func (a *trustAdjusterAdapter) RecordApproval(ctx context.Context, agentName, actionType string) error { + _, err := a.svc.RecordApproval(ctx, agentName, actionType) + return err +} + +func (a *trustAdjusterAdapter) RecordRejection(ctx context.Context, agentName, actionType string) error { + _, err := a.svc.RecordRejection(ctx, agentName, actionType) + return err +} + +// agentTypeCheckerAdapter adapts agents.AgentService to reactions.AgentTypeChecker. +type agentTypeCheckerAdapter struct { + agentService *agents.AgentService +} + +func (a *agentTypeCheckerAdapter) GetAgentType(ctx context.Context, agentName string) (string, error) { + agent, err := a.agentService.GetAgent(ctx, agentName) + if err != nil { + return "", err + } + return agent.Type, nil +} + +// messageAuthorResolverAdapter adapts messaging.MessagingService to reactions.MessageAuthorResolver. +type messageAuthorResolverAdapter struct { + msgService *messaging.MessagingService +} + +func (a *messageAuthorResolverAdapter) GetMessageAuthor(ctx context.Context, messageID int64) (string, error) { + msg, err := a.msgService.GetMessageByID(ctx, messageID) + if err != nil { + return "", err + } + return msg.FromAgent, nil +} diff --git a/internal/actions/registry.go b/internal/actions/registry.go index b9e04ed..dd06f9c 100644 --- a/internal/actions/registry.go +++ b/internal/actions/registry.go @@ -6,10 +6,10 @@ type Registry struct { ordered []Action // maintains insertion order } -// NewRegistry creates a registry pre-populated with all 27 agent-callable actions. +// NewRegistry creates a registry pre-populated with all 28 agent-callable actions. func NewRegistry() *Registry { r := &Registry{ - actions: make(map[string]Action, 27), + actions: make(map[string]Action, 28), } for _, a := range allActions() { r.actions[a.Name] = a @@ -42,7 +42,7 @@ func (r *Registry) ListByCategory(category string) []Action { return out } -// allActions returns the canonical list of all 27 agent-callable actions. +// allActions returns the canonical list of all 28 agent-callable actions. func allActions() []Action { return []Action{ // ── Messaging (7 actions) ────────────────────────────────────── @@ -525,5 +525,26 @@ func allActions() []Action { }, }, }, + + // ── Trust (1 action) ──────────────────────────────────────── + { + Name: "get_trust", + Category: "trust", + Description: "Get trust scores for an agent. Returns a map of action types to trust scores (0.0–1.0). Omit agent_name to get your own scores.", + Params: []Param{ + {Name: "agent_name", Type: "string", Description: "Agent name to query (defaults to calling agent)"}, + }, + Returns: "JSON with agent_name and scores map (action_type -> score)", + Examples: []Example{ + { + Description: "Get your own trust scores", + Code: `call("get_trust", {})`, + }, + { + Description: "Get another agent's trust scores", + Code: `call("get_trust", {"agent_name": "research-mcpproxy"})`, + }, + }, + }, } } diff --git a/internal/actions/registry_test.go b/internal/actions/registry_test.go index a1ec619..d5a0c5c 100644 --- a/internal/actions/registry_test.go +++ b/internal/actions/registry_test.go @@ -4,11 +4,11 @@ import ( "testing" ) -func TestRegistryHas27Actions(t *testing.T) { +func TestRegistryHas28Actions(t *testing.T) { r := NewRegistry() got := len(r.List()) - if got != 27 { - t.Errorf("expected 27 actions, got %d", got) + if got != 28 { + t.Errorf("expected 28 actions, got %d", got) } } @@ -24,6 +24,7 @@ func TestRegistryCategories(t *testing.T) { {"swarm", 5}, {"attachments", 2}, {"reactions", 4}, + {"trust", 1}, } for _, tt := range tests { @@ -52,6 +53,8 @@ func TestRegistryGetByName(t *testing.T) { "upload_attachment", "download_attachment", // reactions "react", "unreact", "get_reactions", "list_by_state", + // trust + "get_trust", } for _, name := range allNames { diff --git a/internal/api/channels_handler.go b/internal/api/channels_handler.go index a704d6f..a2f213d 100644 --- a/internal/api/channels_handler.go +++ b/internal/api/channels_handler.go @@ -327,10 +327,12 @@ func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) } var req struct { - WorkflowEnabled *bool `json:"workflow_enabled"` - AutoApprove *bool `json:"auto_approve"` - StalemateRemindAfter *string `json:"stalemate_remind_after"` - StalemateEscalateAfter *string `json:"stalemate_escalate_after"` + WorkflowEnabled *bool `json:"workflow_enabled"` + AutoApprove *bool `json:"auto_approve"` + StalemateRemindAfter *string `json:"stalemate_remind_after"` + StalemateEscalateAfter *string `json:"stalemate_escalate_after"` + PublishThreshold *float64 `json:"publish_threshold"` + ApproveThreshold *float64 `json:"approve_threshold"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -343,6 +345,8 @@ func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) AutoApprove: ch.AutoApprove, StalemateRemindAfter: ch.StalemateRemindAfter, StalemateEscalateAfter: ch.StalemateEscalateAfter, + PublishThreshold: ch.PublishThreshold, + ApproveThreshold: ch.ApproveThreshold, } if req.WorkflowEnabled != nil { @@ -357,6 +361,12 @@ func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) if req.StalemateEscalateAfter != nil { settings.StalemateEscalateAfter = *req.StalemateEscalateAfter } + if req.PublishThreshold != nil { + settings.PublishThreshold = *req.PublishThreshold + } + if req.ApproveThreshold != nil { + settings.ApproveThreshold = *req.ApproveThreshold + } updated, err := h.channelService.UpdateChannelSettings(r.Context(), ch.ID, settings) if err != nil { diff --git a/internal/api/router.go b/internal/api/router.go index c64f8e6..72d6dd1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -15,6 +15,7 @@ import ( "github.com/synapbus/synapbus/internal/push" "github.com/synapbus/synapbus/internal/reactions" "github.com/synapbus/synapbus/internal/trace" + "github.com/synapbus/synapbus/internal/trust" "github.com/synapbus/synapbus/internal/webhooks" ) @@ -35,6 +36,7 @@ type RouterConfig struct { K8sStore k8s.K8sStore ReactionService *reactions.Service PushService *push.Service + TrustService *trust.Service SSEHub *SSEHub Broadcaster *SSEBroadcaster SessionMiddleware func(http.Handler) http.Handler @@ -235,6 +237,16 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router { } } + // Trust Scores + if cfg.TrustService != nil { + trustHandler := NewTrustHandler(cfg.TrustService) + r.Group(func(r chi.Router) { + r.Use(authMiddleware) + + r.Get("/api/trust/{name}", trustHandler.GetScores) + }) + } + // Analytics (authenticated, requires DB) if cfg.DB != nil { analyticsHandler := NewAnalyticsHandler(cfg.DB, cfg.AgentService, cfg.ChannelService) diff --git a/internal/api/trust_handler.go b/internal/api/trust_handler.go new file mode 100644 index 0000000..2af34b4 --- /dev/null +++ b/internal/api/trust_handler.go @@ -0,0 +1,44 @@ +package api + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/synapbus/synapbus/internal/trust" +) + +// TrustHandler handles REST API requests for agent trust scores. +type TrustHandler struct { + trustService *trust.Service + logger *slog.Logger +} + +// NewTrustHandler creates a new trust handler. +func NewTrustHandler(trustService *trust.Service) *TrustHandler { + return &TrustHandler{ + trustService: trustService, + logger: slog.Default().With("component", "api.trust"), + } +} + +// GetScores handles GET /api/trust/{name}. +func (h *TrustHandler) GetScores(w http.ResponseWriter, r *http.Request) { + agentName := chi.URLParam(r, "name") + if agentName == "" { + writeJSON(w, http.StatusBadRequest, errorBody("invalid_name", "Agent name is required")) + return + } + + scores, err := h.trustService.GetScores(r.Context(), agentName) + if err != nil { + h.logger.Error("failed to get trust scores", "agent", agentName, "error", err) + writeJSON(w, http.StatusInternalServerError, errorBody("internal", "Failed to get trust scores")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "scores": scores, + }) +} diff --git a/internal/channels/store.go b/internal/channels/store.go index c3bc8e7..e0b0cc6 100644 --- a/internal/channels/store.go +++ b/internal/channels/store.go @@ -83,9 +83,9 @@ func (s *SQLiteChannelStore) GetChannel(ctx context.Context, id int64) (*Channel var ch Channel var isPrivate, isSystem int err := s.db.QueryRowContext(ctx, - `SELECT id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at + `SELECT id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after, publish_threshold, approve_threshold, created_at, updated_at FROM channels WHERE id = ?`, id, - ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.CreatedAt, &ch.UpdatedAt) + ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.PublishThreshold, &ch.ApproveThreshold, &ch.CreatedAt, &ch.UpdatedAt) if err != nil { if err == sql.ErrNoRows { return nil, ErrChannelNotFound @@ -102,9 +102,9 @@ func (s *SQLiteChannelStore) GetChannelByName(ctx context.Context, name string) var ch Channel var isPrivate, isSystem int err := s.db.QueryRowContext(ctx, - `SELECT id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at + `SELECT id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after, publish_threshold, approve_threshold, created_at, updated_at FROM channels WHERE LOWER(name) = LOWER(?)`, name, - ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.CreatedAt, &ch.UpdatedAt) + ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.PublishThreshold, &ch.ApproveThreshold, &ch.CreatedAt, &ch.UpdatedAt) if err != nil { if err == sql.ErrNoRows { return nil, ErrChannelNotFound @@ -120,7 +120,7 @@ func (s *SQLiteChannelStore) GetChannelByName(ctx context.Context, name string) // is a member or has a pending invite. func (s *SQLiteChannelStore) ListChannels(ctx context.Context, agentName string) ([]*Channel, error) { rows, err := s.db.QueryContext(ctx, - `SELECT DISTINCT c.id, c.name, c.description, c.topic, c.type, c.is_private, c.is_system, c.created_by, c.workflow_enabled, c.auto_approve, c.stalemate_remind_after, c.stalemate_escalate_after, c.created_at, c.updated_at + `SELECT DISTINCT c.id, c.name, c.description, c.topic, c.type, c.is_private, c.is_system, c.created_by, c.workflow_enabled, c.auto_approve, c.stalemate_remind_after, c.stalemate_escalate_after, c.publish_threshold, c.approve_threshold, c.created_at, c.updated_at FROM channels c WHERE c.is_private = 0 OR EXISTS (SELECT 1 FROM channel_members cm WHERE cm.channel_id = c.id AND cm.agent_name = ?) @@ -137,7 +137,7 @@ func (s *SQLiteChannelStore) ListChannels(ctx context.Context, agentName string) for rows.Next() { var ch Channel var isPrivate, isSystem int - if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.CreatedAt, &ch.UpdatedAt); err != nil { + if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.PublishThreshold, &ch.ApproveThreshold, &ch.CreatedAt, &ch.UpdatedAt); err != nil { return nil, fmt.Errorf("scan channel: %w", err) } ch.IsPrivate = isPrivate != 0 @@ -418,8 +418,8 @@ func (s *SQLiteChannelStore) GetChannelSummaries(ctx context.Context, agentName // UpdateChannelSettings updates the workflow-related settings for a channel. func (s *SQLiteChannelStore) UpdateChannelSettings(ctx context.Context, id int64, settings ChannelSettings) error { result, err := s.db.ExecContext(ctx, - `UPDATE channels SET workflow_enabled = ?, auto_approve = ?, stalemate_remind_after = ?, stalemate_escalate_after = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - settings.WorkflowEnabled, settings.AutoApprove, settings.StalemateRemindAfter, settings.StalemateEscalateAfter, id, + `UPDATE channels SET workflow_enabled = ?, auto_approve = ?, stalemate_remind_after = ?, stalemate_escalate_after = ?, publish_threshold = ?, approve_threshold = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + settings.WorkflowEnabled, settings.AutoApprove, settings.StalemateRemindAfter, settings.StalemateEscalateAfter, settings.PublishThreshold, settings.ApproveThreshold, id, ) if err != nil { return fmt.Errorf("update channel settings: %w", err) diff --git a/internal/channels/types.go b/internal/channels/types.go index 76ab006..69c7be6 100644 --- a/internal/channels/types.go +++ b/internal/channels/types.go @@ -37,6 +37,8 @@ type Channel struct { AutoApprove bool `json:"auto_approve"` StalemateRemindAfter string `json:"stalemate_remind_after"` StalemateEscalateAfter string `json:"stalemate_escalate_after"` + PublishThreshold float64 `json:"publish_threshold"` + ApproveThreshold float64 `json:"approve_threshold"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -113,10 +115,12 @@ type JoinChannelRequest struct { // ChannelSettings holds workflow-related settings for a channel. type ChannelSettings struct { - WorkflowEnabled bool `json:"workflow_enabled"` - AutoApprove bool `json:"auto_approve"` - StalemateRemindAfter string `json:"stalemate_remind_after"` - StalemateEscalateAfter string `json:"stalemate_escalate_after"` + WorkflowEnabled bool `json:"workflow_enabled"` + AutoApprove bool `json:"auto_approve"` + StalemateRemindAfter string `json:"stalemate_remind_after"` + StalemateEscalateAfter string `json:"stalemate_escalate_after"` + PublishThreshold float64 `json:"publish_threshold"` + ApproveThreshold float64 `json:"approve_threshold"` } // InviteRequest is the input for inviting an agent to a channel. diff --git a/internal/mcp/bridge.go b/internal/mcp/bridge.go index a8e66d8..070e127 100644 --- a/internal/mcp/bridge.go +++ b/internal/mcp/bridge.go @@ -16,6 +16,7 @@ import ( "github.com/synapbus/synapbus/internal/messaging" "github.com/synapbus/synapbus/internal/reactions" "github.com/synapbus/synapbus/internal/search" + "github.com/synapbus/synapbus/internal/trust" ) // ServiceBridge implements jsruntime.ToolCaller, mapping action names to @@ -28,6 +29,7 @@ type ServiceBridge struct { attachmentService *attachments.Service searchService *search.Service reactionService *reactions.Service + trustService *trust.Service agentName string } @@ -40,6 +42,7 @@ func NewServiceBridge( attachmentService *attachments.Service, searchService *search.Service, reactionService *reactions.Service, + trustService *trust.Service, agentName string, ) *ServiceBridge { return &ServiceBridge{ @@ -50,6 +53,7 @@ func NewServiceBridge( attachmentService: attachmentService, searchService: searchService, reactionService: reactionService, + trustService: trustService, agentName: agentName, } } @@ -117,6 +121,10 @@ func (b *ServiceBridge) Call(ctx context.Context, actionName string, args map[st case "list_by_state": return b.callListByState(ctx, args) + // --- Trust --- + case "get_trust": + return b.callGetTrust(ctx, args) + // --- DM send (also accessible via bridge for execute tool) --- case "send_message": return b.callSendMessage(ctx, args) @@ -1064,6 +1072,29 @@ func (b *ServiceBridge) callListByState(ctx context.Context, args map[string]any }, nil } +// --- Trust implementations --- + +func (b *ServiceBridge) callGetTrust(ctx context.Context, args map[string]any) (any, error) { + if b.trustService == nil { + return nil, fmt.Errorf("trust service not available") + } + + agentName := getString(args, "agent_name", "") + if agentName == "" { + agentName = b.agentName + } + + scores, err := b.trustService.GetScores(ctx, agentName) + if err != nil { + return nil, err + } + + return map[string]any{ + "agent_name": agentName, + "scores": scores, + }, nil +} + // --- Helpers --- // resolveChannelID resolves a channel ID from either channel_id or channel_name in args. diff --git a/internal/mcp/bridge_test.go b/internal/mcp/bridge_test.go index bb3abdd..dd594ce 100644 --- a/internal/mcp/bridge_test.go +++ b/internal/mcp/bridge_test.go @@ -44,6 +44,7 @@ func newTestBridge(t *testing.T) (*ServiceBridge, *messaging.MessagingService, * nil, // attachmentService nil, // searchService nil, // reactionService + nil, // trustService "agent-a", ) return bridge, msgService, agentService, channelService @@ -186,7 +187,7 @@ func TestBridge_JoinChannel(t *testing.T) { bridge.agentService, bridge.channelService, bridge.swarmService, - nil, nil, nil, + nil, nil, nil, nil, "agent-b", ) diff --git a/internal/mcp/channel_tools_test.go b/internal/mcp/channel_tools_test.go index 541dc48..9fe1165 100644 --- a/internal/mcp/channel_tools_test.go +++ b/internal/mcp/channel_tools_test.go @@ -51,6 +51,7 @@ func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Se nil, // attachmentService nil, // searchService nil, // reactionService + nil, // trustService jsPool, actionRegistry, actionIndex, diff --git a/internal/mcp/server.go b/internal/mcp/server.go index da93860..25dbc3e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -21,6 +21,7 @@ import ( "github.com/synapbus/synapbus/internal/reactions" "github.com/synapbus/synapbus/internal/search" "github.com/synapbus/synapbus/internal/trace" + "github.com/synapbus/synapbus/internal/trust" ) // MCPServer wraps the mcp-go server with SynapBus services. @@ -42,6 +43,7 @@ func NewMCPServer( attachmentService *attachments.Service, searchService *search.Service, reactionService *reactions.Service, + trustService *trust.Service, consolePrinter *console.Printer, jsPool *jsruntime.Pool, actionRegistry *actions.Registry, @@ -156,6 +158,7 @@ func NewMCPServer( attachmentService, searchService, reactionService, + trustService, jsPool, actionRegistry, actionIndex, diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 30362aa..2597eab 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -38,7 +38,7 @@ func newTestMCPServer(t *testing.T, con *console.Printer) (*MCPServer, *messagin actionRegistry := actions.NewRegistry() actionIndex := actions.NewIndex(actionRegistry.List()) - srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db) + srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db) return srv, msgService, agentService } @@ -133,7 +133,7 @@ func TestMCPToolCall_WithValidAPIKey(t *testing.T) { actionIndex := actions.NewIndex(actionRegistry.List()) // Create MCP server - srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db) + srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db) // Mount with auth middleware, just like main.go does mux := http.NewServeMux() @@ -188,7 +188,7 @@ func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) { actionRegistry := actions.NewRegistry() actionIndex := actions.NewIndex(actionRegistry.List()) - srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db) + srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db) mux := http.NewServeMux() handler := agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService)(srv.Handler()) diff --git a/internal/mcp/tools_hybrid.go b/internal/mcp/tools_hybrid.go index cab6be5..99b17ec 100644 --- a/internal/mcp/tools_hybrid.go +++ b/internal/mcp/tools_hybrid.go @@ -20,6 +20,7 @@ import ( "github.com/synapbus/synapbus/internal/messaging" "github.com/synapbus/synapbus/internal/reactions" "github.com/synapbus/synapbus/internal/search" + "github.com/synapbus/synapbus/internal/trust" ) // HybridToolRegistrar registers the 4 hybrid MCP tools. @@ -31,6 +32,7 @@ type HybridToolRegistrar struct { attachmentService *attachments.Service searchService *search.Service reactionService *reactions.Service + trustService *trust.Service jsPool *jsruntime.Pool actionRegistry *actions.Registry actionIndex *actions.Index @@ -47,6 +49,7 @@ func NewHybridToolRegistrar( attachmentService *attachments.Service, searchService *search.Service, reactionService *reactions.Service, + trustService *trust.Service, jsPool *jsruntime.Pool, actionRegistry *actions.Registry, actionIndex *actions.Index, @@ -60,6 +63,7 @@ func NewHybridToolRegistrar( attachmentService: attachmentService, searchService: searchService, reactionService: reactionService, + trustService: trustService, jsPool: jsPool, actionRegistry: actionRegistry, actionIndex: actionIndex, @@ -480,6 +484,7 @@ func (h *HybridToolRegistrar) handleExecute(ctx context.Context, req mcplib.Call h.attachmentService, h.searchService, h.reactionService, + h.trustService, agentName, ) diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go index 50d86d3..55366fb 100644 --- a/internal/mcp/tools_test.go +++ b/internal/mcp/tools_test.go @@ -69,6 +69,7 @@ func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.Mess nil, // attachmentService nil, // searchService nil, // reactionService + nil, // trustService jsPool, actionRegistry, actionIndex, diff --git a/internal/reactions/service.go b/internal/reactions/service.go index 319bd30..b7f2f7b 100644 --- a/internal/reactions/service.go +++ b/internal/reactions/service.go @@ -5,12 +5,39 @@ import ( "encoding/json" "fmt" "log/slog" + + "github.com/synapbus/synapbus/internal/trust" ) +// StateChangeNotifier is called when a message's workflow state changes. +type StateChangeNotifier interface { + OnWorkflowStateChanged(ctx context.Context, event trust.WorkflowStateChangeEvent) +} + +// AgentTypeChecker resolves an agent's type (e.g. "human", "ai"). +type AgentTypeChecker interface { + GetAgentType(ctx context.Context, agentName string) (string, error) +} + +// TrustAdjuster adjusts trust scores for agents. +type TrustAdjuster interface { + RecordApproval(ctx context.Context, agentName, actionType string) error + RecordRejection(ctx context.Context, agentName, actionType string) error +} + +// MessageAuthorResolver looks up the author of a message. +type MessageAuthorResolver interface { + GetMessageAuthor(ctx context.Context, messageID int64) (string, error) +} + // Service provides business logic for message reactions. type Service struct { - store Store - logger *slog.Logger + store Store + logger *slog.Logger + stateChangeNotifier StateChangeNotifier + agentTypeChecker AgentTypeChecker + trustAdjuster TrustAdjuster + authorResolver MessageAuthorResolver } // NewService creates a new reaction service. @@ -21,6 +48,26 @@ func NewService(store Store, logger *slog.Logger) *Service { } } +// SetStateChangeNotifier sets the notifier called on workflow state transitions. +func (s *Service) SetStateChangeNotifier(n StateChangeNotifier) { + s.stateChangeNotifier = n +} + +// SetAgentTypeChecker sets the checker used to resolve agent types for trust adjustments. +func (s *Service) SetAgentTypeChecker(c AgentTypeChecker) { + s.agentTypeChecker = c +} + +// SetTrustAdjuster sets the trust adjuster for recording approvals/rejections. +func (s *Service) SetTrustAdjuster(a TrustAdjuster) { + s.trustAdjuster = a +} + +// SetMessageAuthorResolver sets the resolver for looking up message authors. +func (s *Service) SetMessageAuthorResolver(r MessageAuthorResolver) { + s.authorResolver = r +} + // ToggleResult describes what happened after a toggle operation. type ToggleResult struct { Action string `json:"action"` // "added" or "removed" @@ -34,6 +81,13 @@ func (s *Service) Toggle(ctx context.Context, messageID int64, agentName, reacti return nil, ErrInvalidReaction } + // Capture old workflow state before any mutation + var oldState string + if s.stateChangeNotifier != nil { + oldReactions, _ := s.store.GetByMessageID(ctx, messageID) + oldState = ComputeWorkflowState(oldReactions) + } + // Check if reaction already exists exists, err := s.store.Exists(ctx, messageID, agentName, reactionType) if err != nil { @@ -50,9 +104,26 @@ func (s *Service) Toggle(ctx context.Context, messageID int64, agentName, reacti "agent", agentName, "reaction", reactionType, ) + + // Check for workflow state change after removal + s.notifyStateChangeIfNeeded(ctx, messageID, oldState, agentName, reactionType) + return &ToggleResult{Action: "removed"}, nil } + // Claim semantics: only one agent can have in_progress at a time + if reactionType == ReactionInProgress { + existing, err := s.store.GetByMessageID(ctx, messageID) + if err != nil { + return nil, fmt.Errorf("check existing claims: %w", err) + } + for _, r := range existing { + if r.Reaction == ReactionInProgress && r.AgentName != agentName { + return nil, fmt.Errorf("already claimed by %s", r.AgentName) + } + } + } + // Check reaction count limit count, err := s.store.CountByMessage(ctx, messageID) if err != nil { @@ -84,9 +155,86 @@ func (s *Service) Toggle(ctx context.Context, messageID int64, agentName, reacti "reaction", reactionType, ) + // Check for workflow state change after addition + s.notifyStateChangeIfNeeded(ctx, messageID, oldState, agentName, reactionType) + + // Adjust trust when a human approves/rejects an AI agent's message + s.adjustTrustIfNeeded(ctx, messageID, agentName, reactionType) + return &ToggleResult{Action: "added", Reaction: r}, nil } +// notifyStateChangeIfNeeded fires the state change notifier if the workflow state changed. +func (s *Service) notifyStateChangeIfNeeded(ctx context.Context, messageID int64, oldState, agentName, reactionType string) { + if s.stateChangeNotifier == nil { + return + } + newReactions, err := s.store.GetByMessageID(ctx, messageID) + if err != nil { + return + } + newState := ComputeWorkflowState(newReactions) + if newState != oldState { + s.stateChangeNotifier.OnWorkflowStateChanged(ctx, trust.WorkflowStateChangeEvent{ + MessageID: messageID, + OldState: oldState, + NewState: newState, + TriggeredBy: agentName, + Reaction: reactionType, + }) + } +} + +// adjustTrustIfNeeded adjusts trust when a human reacts approve/reject to an AI agent's message. +func (s *Service) adjustTrustIfNeeded(ctx context.Context, messageID int64, reactorName, reactionType string) { + if s.trustAdjuster == nil || s.agentTypeChecker == nil || s.authorResolver == nil { + return + } + + // Only approve and reject adjust trust + if reactionType != ReactionApprove && reactionType != ReactionReject { + return + } + + // Check if the reactor is a human + reactorType, err := s.agentTypeChecker.GetAgentType(ctx, reactorName) + if err != nil || reactorType != "human" { + return + } + + // Get the message author + authorName, err := s.authorResolver.GetMessageAuthor(ctx, messageID) + if err != nil || authorName == "" { + return + } + + // Check if the author is an AI agent + authorType, err := s.agentTypeChecker.GetAgentType(ctx, authorName) + if err != nil || authorType != "ai" { + return + } + + // Adjust trust for the AI agent + actionType := trust.ActionPublish + if reactionType == ReactionApprove { + if err := s.trustAdjuster.RecordApproval(ctx, authorName, actionType); err != nil { + s.logger.Warn("trust approval failed", + "agent", authorName, + "reactor", reactorName, + "error", err, + ) + } + } else { + if err := s.trustAdjuster.RecordRejection(ctx, authorName, actionType); err != nil { + s.logger.Warn("trust rejection failed", + "agent", authorName, + "reactor", reactorName, + "error", err, + ) + } + } +} + // Remove explicitly removes a reaction. func (s *Service) Remove(ctx context.Context, messageID int64, agentName, reactionType string) error { if !IsValidReaction(reactionType) { diff --git a/internal/storage/schema/014_trust_claims.sql b/internal/storage/schema/014_trust_claims.sql new file mode 100644 index 0000000..8774ba4 --- /dev/null +++ b/internal/storage/schema/014_trust_claims.sql @@ -0,0 +1,17 @@ +-- Trust scores per (agent, action_type) for graduated autonomy +CREATE TABLE IF NOT EXISTS agent_trust ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_name TEXT NOT NULL, + action_type TEXT NOT NULL, + score REAL NOT NULL DEFAULT 0.0, + adjustments_count INTEGER NOT NULL DEFAULT 0, + last_adjusted_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(agent_name, action_type) +); + +CREATE INDEX idx_trust_agent ON agent_trust(agent_name); + +-- Channel autonomy thresholds +ALTER TABLE channels ADD COLUMN publish_threshold REAL NOT NULL DEFAULT 0.8; +ALTER TABLE channels ADD COLUMN approve_threshold REAL NOT NULL DEFAULT 0.6; diff --git a/internal/trust/model.go b/internal/trust/model.go new file mode 100644 index 0000000..3865710 --- /dev/null +++ b/internal/trust/model.go @@ -0,0 +1,65 @@ +// Package trust provides agent trust score tracking for graduated autonomy. +package trust + +import ( + "errors" + "time" +) + +// Trust adjustment constants. +const ( + ApprovalIncrement = 0.05 + RejectionDecrement = 0.10 + MinScore = 0.0 + MaxScore = 1.0 +) + +// Common action types (extensible — any string is valid). +const ( + ActionResearch = "research" + ActionPublish = "publish" + ActionComment = "comment" + ActionApprove = "approve" + ActionOperate = "operate" +) + +// Sentinel errors. +var ( + ErrAlreadyClaimed = errors.New("work item already claimed by another agent") + ErrSelfReaction = errors.New("cannot adjust trust for self-reactions") +) + +// TrustScore represents an agent's trust level for a specific action type. +type TrustScore struct { + ID int64 `json:"id"` + AgentName string `json:"agent_name"` + ActionType string `json:"action_type"` + Score float64 `json:"score"` + AdjustmentsCount int `json:"adjustments_count"` + LastAdjustedAt *time.Time `json:"last_adjusted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// AgentTrustSummary is a map of action_type -> score for an agent. +type AgentTrustSummary map[string]float64 + +// ClampScore ensures a score stays within [0.0, 1.0]. +func ClampScore(score float64) float64 { + if score < MinScore { + return MinScore + } + if score > MaxScore { + return MaxScore + } + return score +} + +// WorkflowStateChangeEvent is the webhook payload for state transitions. +type WorkflowStateChangeEvent struct { + MessageID int64 `json:"message_id"` + ChannelID int64 `json:"channel_id,omitempty"` + OldState string `json:"old_state"` + NewState string `json:"new_state"` + TriggeredBy string `json:"triggered_by"` + Reaction string `json:"reaction"` +} diff --git a/internal/trust/model_test.go b/internal/trust/model_test.go new file mode 100644 index 0000000..7c9be2e --- /dev/null +++ b/internal/trust/model_test.go @@ -0,0 +1,32 @@ +package trust + +import "testing" + +func TestClampScore(t *testing.T) { + tests := []struct { + name string + input float64 + want float64 + }{ + {"zero", 0.0, 0.0}, + {"one", 1.0, 1.0}, + {"mid", 0.5, 0.5}, + {"below zero", -0.1, MinScore}, + {"far below zero", -10.0, MinScore}, + {"above one", 1.1, MaxScore}, + {"far above one", 100.0, MaxScore}, + {"small positive", 0.001, 0.001}, + {"near max", 0.999, 0.999}, + {"exactly min", MinScore, MinScore}, + {"exactly max", MaxScore, MaxScore}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ClampScore(tt.input) + if got != tt.want { + t.Errorf("ClampScore(%f) = %f, want %f", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/trust/service.go b/internal/trust/service.go new file mode 100644 index 0000000..fbd35ba --- /dev/null +++ b/internal/trust/service.go @@ -0,0 +1,83 @@ +package trust + +import ( + "context" + "fmt" + "log/slog" +) + +// Service provides business logic for trust score management. +type Service struct { + store Store + logger *slog.Logger +} + +// NewService creates a new trust service. +func NewService(store Store, logger *slog.Logger) *Service { + return &Service{ + store: store, + logger: logger.With("component", "trust"), + } +} + +// RecordApproval increases an agent's trust for an action type. +func (s *Service) RecordApproval(ctx context.Context, agentName, actionType string) (*TrustScore, error) { + ts, err := s.store.UpsertScore(ctx, agentName, actionType, ApprovalIncrement) + if err != nil { + return nil, fmt.Errorf("record approval: %w", err) + } + s.logger.Info("trust increased", + "agent", agentName, + "action", actionType, + "delta", ApprovalIncrement, + "new_score", ts.Score, + ) + return ts, nil +} + +// RecordRejection decreases an agent's trust for an action type. +func (s *Service) RecordRejection(ctx context.Context, agentName, actionType string) (*TrustScore, error) { + ts, err := s.store.UpsertScore(ctx, agentName, actionType, -RejectionDecrement) + if err != nil { + return nil, fmt.Errorf("record rejection: %w", err) + } + s.logger.Info("trust decreased", + "agent", agentName, + "action", actionType, + "delta", -RejectionDecrement, + "new_score", ts.Score, + ) + return ts, nil +} + +// GetScores returns all trust scores for an agent as a summary map. +func (s *Service) GetScores(ctx context.Context, agentName string) (AgentTrustSummary, error) { + scores, err := s.store.GetAllScores(ctx, agentName) + if err != nil { + return nil, fmt.Errorf("get scores: %w", err) + } + summary := make(AgentTrustSummary) + for _, ts := range scores { + summary[ts.ActionType] = ts.Score + } + return summary, nil +} + +// GetScore returns the trust score for a specific (agent, action) pair. +func (s *Service) GetScore(ctx context.Context, agentName, actionType string) (float64, error) { + ts, err := s.store.GetScore(ctx, agentName, actionType) + if err != nil { + return 0, fmt.Errorf("get score: %w", err) + } + return ts.Score, nil +} + +// CheckAutonomy returns whether an agent has sufficient trust for an action +// given a channel's threshold. +func (s *Service) CheckAutonomy(ctx context.Context, agentName, actionType string, threshold float64) (bool, float64, error) { + score, err := s.GetScore(ctx, agentName, actionType) + if err != nil { + return false, 0, err + } + return score >= threshold, score, nil +} diff --git a/internal/trust/store.go b/internal/trust/store.go new file mode 100644 index 0000000..6ee2150 --- /dev/null +++ b/internal/trust/store.go @@ -0,0 +1,91 @@ +package trust + +import ( + "context" + "database/sql" + "fmt" +) + +// Store defines the storage interface for trust scores. +type Store interface { + GetScore(ctx context.Context, agentName, actionType string) (*TrustScore, error) + GetAllScores(ctx context.Context, agentName string) ([]*TrustScore, error) + UpsertScore(ctx context.Context, agentName, actionType string, delta float64) (*TrustScore, error) +} + +// SQLiteStore implements Store using SQLite. +type SQLiteStore struct { + db *sql.DB +} + +// NewSQLiteStore creates a new SQLite-backed trust store. +func NewSQLiteStore(db *sql.DB) *SQLiteStore { + return &SQLiteStore{db: db} +} + +func (s *SQLiteStore) GetScore(ctx context.Context, agentName, actionType string) (*TrustScore, error) { + var ts TrustScore + var lastAdj sql.NullTime + err := s.db.QueryRowContext(ctx, + `SELECT id, agent_name, action_type, score, adjustments_count, last_adjusted_at, created_at + FROM agent_trust WHERE agent_name = ? AND action_type = ?`, + agentName, actionType, + ).Scan(&ts.ID, &ts.AgentName, &ts.ActionType, &ts.Score, &ts.AdjustmentsCount, &lastAdj, &ts.CreatedAt) + if err != nil { + if err == sql.ErrNoRows { + return &TrustScore{AgentName: agentName, ActionType: actionType, Score: 0.0}, nil + } + return nil, fmt.Errorf("get trust score: %w", err) + } + if lastAdj.Valid { + ts.LastAdjustedAt = &lastAdj.Time + } + return &ts, nil +} + +func (s *SQLiteStore) GetAllScores(ctx context.Context, agentName string) ([]*TrustScore, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, agent_name, action_type, score, adjustments_count, last_adjusted_at, created_at + FROM agent_trust WHERE agent_name = ? + ORDER BY action_type`, agentName, + ) + if err != nil { + return nil, fmt.Errorf("get all trust scores: %w", err) + } + defer rows.Close() + + var scores []*TrustScore + for rows.Next() { + var ts TrustScore + var lastAdj sql.NullTime + if err := rows.Scan(&ts.ID, &ts.AgentName, &ts.ActionType, &ts.Score, &ts.AdjustmentsCount, &lastAdj, &ts.CreatedAt); err != nil { + return nil, fmt.Errorf("scan trust score: %w", err) + } + if lastAdj.Valid { + ts.LastAdjustedAt = &lastAdj.Time + } + scores = append(scores, &ts) + } + if scores == nil { + scores = []*TrustScore{} + } + return scores, rows.Err() +} + +func (s *SQLiteStore) UpsertScore(ctx context.Context, agentName, actionType string, delta float64) (*TrustScore, error) { + // Upsert: insert if not exists, update if exists + _, err := s.db.ExecContext(ctx, + `INSERT INTO agent_trust (agent_name, action_type, score, adjustments_count, last_adjusted_at, created_at) + VALUES (?, ?, MAX(0.0, MIN(1.0, ?)), 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(agent_name, action_type) DO UPDATE SET + score = MAX(0.0, MIN(1.0, agent_trust.score + ?)), + adjustments_count = agent_trust.adjustments_count + 1, + last_adjusted_at = CURRENT_TIMESTAMP`, + agentName, actionType, delta, delta, + ) + if err != nil { + return nil, fmt.Errorf("upsert trust score: %w", err) + } + + return s.GetScore(ctx, agentName, actionType) +} diff --git a/internal/trust/store_test.go b/internal/trust/store_test.go new file mode 100644 index 0000000..b367f0c --- /dev/null +++ b/internal/trust/store_test.go @@ -0,0 +1,224 @@ +package trust + +import ( + "context" + "database/sql" + "fmt" + "testing" + + _ "modernc.org/sqlite" + + "github.com/synapbus/synapbus/internal/storage" +) + +func newTestDB(t *testing.T) *sql.DB { + t.Helper() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { db.Close() }) + + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + t.Fatalf("enable foreign keys: %v", err) + } + + ctx := context.Background() + if err := storage.RunMigrations(ctx, db); err != nil { + t.Fatalf("run migrations: %v", err) + } + + return db +} + +func TestSQLiteStore_UpsertAndGet(t *testing.T) { + db := newTestDB(t) + store := NewSQLiteStore(db) + ctx := context.Background() + + ts, err := store.UpsertScore(ctx, "agent-a", ActionResearch, 0.5) + if err != nil { + t.Fatalf("UpsertScore: %v", err) + } + + if ts.Score != 0.5 { + t.Errorf("Score = %f, want 0.5", ts.Score) + } + if ts.AgentName != "agent-a" { + t.Errorf("AgentName = %q, want %q", ts.AgentName, "agent-a") + } + if ts.ActionType != ActionResearch { + t.Errorf("ActionType = %q, want %q", ts.ActionType, ActionResearch) + } + if ts.AdjustmentsCount != 1 { + t.Errorf("AdjustmentsCount = %d, want 1", ts.AdjustmentsCount) + } + + // Verify it's retrievable via GetScore + got, err := store.GetScore(ctx, "agent-a", ActionResearch) + if err != nil { + t.Fatalf("GetScore: %v", err) + } + if got.Score != 0.5 { + t.Errorf("GetScore Score = %f, want 0.5", got.Score) + } + if got.AgentName != "agent-a" { + t.Errorf("GetScore AgentName = %q, want %q", got.AgentName, "agent-a") + } + if got.ActionType != ActionResearch { + t.Errorf("GetScore ActionType = %q, want %q", got.ActionType, ActionResearch) + } +} + +func TestSQLiteStore_UpsertIncrement(t *testing.T) { + db := newTestDB(t) + store := NewSQLiteStore(db) + ctx := context.Background() + + // First upsert: initial score + _, err := store.UpsertScore(ctx, "agent-a", ActionPublish, 0.3) + if err != nil { + t.Fatalf("UpsertScore first: %v", err) + } + + // Second upsert: should increment + ts, err := store.UpsertScore(ctx, "agent-a", ActionPublish, 0.2) + if err != nil { + t.Fatalf("UpsertScore second: %v", err) + } + + want := 0.5 + if ts.Score != want { + t.Errorf("Score = %f, want %f", ts.Score, want) + } + if ts.AdjustmentsCount != 2 { + t.Errorf("AdjustmentsCount = %d, want 2", ts.AdjustmentsCount) + } +} + +func TestSQLiteStore_ClampMax(t *testing.T) { + db := newTestDB(t) + store := NewSQLiteStore(db) + ctx := context.Background() + + // Insert a high score + _, err := store.UpsertScore(ctx, "agent-a", ActionComment, 0.9) + if err != nil { + t.Fatalf("UpsertScore first: %v", err) + } + + // Push past 1.0 + ts, err := store.UpsertScore(ctx, "agent-a", ActionComment, 0.5) + if err != nil { + t.Fatalf("UpsertScore second: %v", err) + } + + if ts.Score != MaxScore { + t.Errorf("Score = %f, want %f (clamped to max)", ts.Score, MaxScore) + } +} + +func TestSQLiteStore_ClampMin(t *testing.T) { + db := newTestDB(t) + store := NewSQLiteStore(db) + ctx := context.Background() + + // Insert a low score + _, err := store.UpsertScore(ctx, "agent-a", ActionOperate, 0.1) + if err != nil { + t.Fatalf("UpsertScore first: %v", err) + } + + // Push past 0.0 with a large negative delta + ts, err := store.UpsertScore(ctx, "agent-a", ActionOperate, -0.5) + if err != nil { + t.Fatalf("UpsertScore second: %v", err) + } + + if ts.Score != MinScore { + t.Errorf("Score = %f, want %f (clamped to min)", ts.Score, MinScore) + } +} + +func TestSQLiteStore_GetAllScores(t *testing.T) { + db := newTestDB(t) + store := NewSQLiteStore(db) + ctx := context.Background() + + // Insert multiple action types for the same agent + actions := []struct { + actionType string + delta float64 + }{ + {ActionResearch, 0.3}, + {ActionPublish, 0.5}, + {ActionComment, 0.7}, + } + + for _, a := range actions { + if _, err := store.UpsertScore(ctx, "agent-a", a.actionType, a.delta); err != nil { + t.Fatalf("UpsertScore %s: %v", a.actionType, err) + } + } + + scores, err := store.GetAllScores(ctx, "agent-a") + if err != nil { + t.Fatalf("GetAllScores: %v", err) + } + + if len(scores) != 3 { + t.Fatalf("got %d scores, want 3", len(scores)) + } + + // Scores are ordered by action_type alphabetically + scoreMap := make(map[string]float64) + for _, s := range scores { + scoreMap[s.ActionType] = s.Score + } + + for _, a := range actions { + got, ok := scoreMap[a.actionType] + if !ok { + t.Errorf("missing score for action %q", a.actionType) + continue + } + if got != a.delta { + t.Errorf("score for %q = %f, want %f", a.actionType, got, a.delta) + } + } + + // Different agent should return empty + other, err := store.GetAllScores(ctx, "agent-nonexistent") + if err != nil { + t.Fatalf("GetAllScores (other): %v", err) + } + if len(other) != 0 { + t.Errorf("got %d scores for nonexistent agent, want 0", len(other)) + } +} + +func TestSQLiteStore_GetScoreNotFound(t *testing.T) { + db := newTestDB(t) + store := NewSQLiteStore(db) + ctx := context.Background() + + // Get score for non-existent agent should return 0.0 (not an error) + ts, err := store.GetScore(ctx, "nonexistent-agent", ActionResearch) + if err != nil { + t.Fatalf("GetScore: %v", err) + } + + if ts.Score != 0.0 { + t.Errorf("Score = %f, want 0.0 for non-existent agent", ts.Score) + } + if ts.AgentName != "nonexistent-agent" { + t.Errorf("AgentName = %q, want %q", ts.AgentName, "nonexistent-agent") + } + if ts.ActionType != ActionResearch { + t.Errorf("ActionType = %q, want %q", ts.ActionType, ActionResearch) + } + if ts.AdjustmentsCount != 0 { + t.Errorf("AdjustmentsCount = %d, want 0", ts.AdjustmentsCount) + } +} diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 6aece25..8abb30f 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -11,30 +11,30 @@ - - + + - - - - - - + + + + + +