Merge branch '011-trust-claims-triggers' into main
This commit is contained in:
+49
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Se
|
||||
nil, // attachmentService
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
nil, // trustService
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.Mess
|
||||
nil, // attachmentService
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
nil, // trustService
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+11
-11
@@ -11,30 +11,30 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.X5wzyl3b.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CPFJ4jrM.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.DrNjyUiQ.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CVNqI7da.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/D2RS6Hlp.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C-40UA0L.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/4Fzv7G3k.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C0W-VSVk.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/ChDFVqbV.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.DrjvX3b5.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C7XpcGuz.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BAuKwN5S.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B_Y_DuQO.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/rMDGPMLC.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CghLeKxs.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.MzJRUHQS.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_fae453 = {
|
||||
__sveltekit_12fzvv6 = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.X5wzyl3b.js"),
|
||||
import("/_app/immutable/entry/app.DrjvX3b5.js")
|
||||
import("/_app/immutable/entry/start.DrNjyUiQ.js"),
|
||||
import("/_app/immutable/entry/app.MzJRUHQS.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Feature Specification: Trust Scores, Claim Semantics & State-Change Webhooks
|
||||
|
||||
**Feature Branch**: `011-trust-claims-triggers`
|
||||
**Created**: 2026-03-18
|
||||
**Status**: Draft
|
||||
**Input**: Platform architecture design from `docs/superpowers/specs/2026-03-18-agent-platform-architecture-design.md`
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Trust scores are stored per (agent_name, action_type) pair in a new `agent_trust` table
|
||||
- Action types are a flexible string enum: "research", "publish", "comment", "approve", "operate" — not hardcoded, extensible
|
||||
- Default trust score for a new (agent, action) pair is 0.0
|
||||
- Trust increments: +0.05 on human approval (reaction approve/published on agent's work), -0.1 on rejection
|
||||
- Trust range: 0.0 to 1.0, clamped
|
||||
- Autonomy thresholds are per-channel settings (e.g., `publish_threshold: 0.8`)
|
||||
- Claim semantics: only one `in_progress` reaction per message enforced at DB level (first agent wins)
|
||||
- Webhook state-change triggers reuse existing webhook infrastructure (internal/webhooks/)
|
||||
- A new event type `workflow.state_changed` fires when a reaction changes the derived workflow state
|
||||
- Migration number: 014_trust_claims.sql
|
||||
- Trust score API is read-only for agents (they can query their scores but not set them)
|
||||
- Trust adjustments happen automatically when a human reacts to agent work (approve = +trust, reject = -trust)
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Trust Score Tracking (Priority: P1)
|
||||
|
||||
When a human approves an agent's blog post (reacts "approve" to a message from an AI agent), the agent's trust score for the "publish" action type increases. When rejected, it decreases. Over time, agents earn autonomy.
|
||||
|
||||
**Why this priority**: Trust is the foundation of graduated autonomy. Without tracking, all agents stay fully supervised forever.
|
||||
|
||||
**Independent Test**: Have agent post content, human reacts approve, verify trust score increased.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent "research-mcpproxy" with no trust history, **When** querying trust, **Then** all action scores return 0.0.
|
||||
2. **Given** agent posted a message, **When** a human reacts with "approve", **Then** the agent's trust for "publish" increases by 0.05.
|
||||
3. **Given** agent with trust 0.95 for "publish", **When** approved again, **Then** trust is clamped to 1.0.
|
||||
4. **Given** agent with trust 0.3 for "comment", **When** human reacts "reject", **Then** trust decreases by 0.1 to 0.2.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Claim Semantics (Priority: P1)
|
||||
|
||||
When an agent reacts with "in_progress" to claim a work item, no other agent can claim the same item. First agent wins.
|
||||
|
||||
**Why this priority**: Without claim semantics, multiple agents could work on the same task simultaneously, wasting resources.
|
||||
|
||||
**Independent Test**: Two agents try to react in_progress on the same message, second one gets an error.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a proposed message, **When** agent A reacts "in_progress", **Then** the claim succeeds.
|
||||
2. **Given** a message already claimed by agent A, **When** agent B reacts "in_progress", **Then** agent B gets an error "already claimed by agent-a".
|
||||
3. **Given** a claimed message, **When** agent A removes their "in_progress" reaction, **Then** the message becomes claimable again.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Webhook on State Change (Priority: P2)
|
||||
|
||||
When a reaction changes a message's workflow state (e.g., proposed -> approved), SynapBus fires a webhook with the event details. This enables event-driven agent activation.
|
||||
|
||||
**Why this priority**: Webhooks replace polling. Agents can be triggered immediately when work is available.
|
||||
|
||||
**Independent Test**: Register a webhook for workflow.state_changed, add a reaction that changes state, verify webhook fires.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a registered webhook for "workflow.state_changed", **When** a message transitions from proposed to approved, **Then** a webhook is delivered with message_id, old_state, new_state, channel.
|
||||
2. **Given** no webhook registered, **When** a state change occurs, **Then** no error — the change proceeds normally.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Trust Query via MCP (Priority: P2)
|
||||
|
||||
Agents can query their own trust scores via MCP tools to understand their autonomy level.
|
||||
|
||||
**Why this priority**: Agents need to know if they can act autonomously or must request approval.
|
||||
|
||||
**Independent Test**: Agent calls get_trust MCP action, receives trust scores.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent with trust scores, **When** it calls `get_trust`, **Then** it receives a map of action_type -> score.
|
||||
2. **Given** a channel with publish_threshold=0.8, **When** agent has publish trust 0.9, **Then** the response indicates autonomous publishing is allowed.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Agent reacts to its own message: trust adjustment skipped (can't self-approve)
|
||||
- Human reacts to human message: no trust adjustment (only applies to AI agent messages)
|
||||
- Multiple humans approve same message: trust increases once per unique approval
|
||||
- Trust score requested for unknown agent: returns empty map (all zeros)
|
||||
- Webhook delivery fails: standard retry logic from existing webhook system
|
||||
- Message with no channel (DM): claim semantics still apply, trust adjustments still apply
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST store trust scores per (agent_name, action_type) pair
|
||||
- **FR-002**: System MUST automatically adjust trust when a human reacts to an AI agent's message (approve: +0.05, reject: -0.1)
|
||||
- **FR-003**: System MUST clamp trust scores to range [0.0, 1.0]
|
||||
- **FR-004**: System MUST prevent duplicate "in_progress" claims on a message (first agent wins)
|
||||
- **FR-005**: System MUST return a clear error when a claim attempt is blocked
|
||||
- **FR-006**: System MUST fire a "workflow.state_changed" webhook event when reactions change a message's derived workflow state
|
||||
- **FR-007**: System MUST expose trust scores via MCP `get_trust` action
|
||||
- **FR-008**: System MUST expose trust scores via REST API for the web UI
|
||||
- **FR-009**: System MUST skip trust adjustments for self-reactions (agent reacts to own message)
|
||||
- **FR-010**: System MUST support per-channel autonomy thresholds (publish_threshold, approve_threshold)
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **TrustScore**: Per (agent_name, action_type) pair. Fields: score (float), adjustments_count, last_adjusted_at.
|
||||
- **Claim**: Implicit via in_progress reaction uniqueness constraint. No separate entity needed.
|
||||
- **WorkflowStateChange Event**: Webhook payload with message_id, channel_id, old_state, new_state, triggered_by agent.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Trust scores update within 1 second of a reaction
|
||||
- **SC-002**: 100% of duplicate claim attempts are rejected with clear error
|
||||
- **SC-003**: Webhook events fire within 2 seconds of a state change
|
||||
- **SC-004**: Agents can query their trust scores in under 1 second
|
||||
- **SC-005**: Trust adjustments are idempotent — same human approving twice doesn't double-increment
|
||||
@@ -0,0 +1,37 @@
|
||||
# Tasks: Trust Scores, Claim Semantics & State-Change Webhooks
|
||||
|
||||
## Phase 1: Setup
|
||||
- [ ] T001 Verify `make test` passes
|
||||
- [ ] T002 Create migration 014_trust_claims.sql
|
||||
|
||||
## Phase 2: Trust Score Backend
|
||||
- [ ] T003 Create internal/trust/model.go (TrustScore struct, constants, AdjustTrust logic)
|
||||
- [ ] T004 Create internal/trust/store.go (SQLite CRUD: Get, Upsert, GetAll, AdjustScore)
|
||||
- [ ] T005 Create internal/trust/service.go (business logic: RecordApproval, RecordRejection, GetScores)
|
||||
- [ ] T006 [P] Write tests for trust model + store
|
||||
- [ ] T007 Wire trust service into main.go
|
||||
|
||||
## Phase 3: Claim Semantics
|
||||
- [ ] T008 Add UNIQUE constraint for in_progress claims in reactions store
|
||||
- [ ] T009 Update reactions service Toggle to check for existing in_progress claims
|
||||
- [ ] T010 Write tests for claim prevention
|
||||
|
||||
## Phase 4: Trust Auto-Adjustment on Reactions
|
||||
- [ ] T011 Hook trust adjustment into reaction creation (when human reacts to AI message)
|
||||
- [ ] T012 Add logic to detect human-reacting-to-AI-message pattern
|
||||
- [ ] T013 Write tests for auto-adjustment
|
||||
|
||||
## Phase 5: Webhook State-Change Triggers
|
||||
- [ ] T014 Add workflow.state_changed event type to dispatcher
|
||||
- [ ] T015 Fire event from reactions service when state changes
|
||||
- [ ] T016 Write tests for webhook trigger
|
||||
|
||||
## Phase 6: MCP + REST API
|
||||
- [ ] T017 Register get_trust MCP action in bridge + registry
|
||||
- [ ] T018 Add GET /api/trust/{agent} REST endpoint
|
||||
- [ ] T019 Add channel threshold fields (publish_threshold, approve_threshold)
|
||||
|
||||
## Phase 7: Polish
|
||||
- [ ] T020 Run go test ./...
|
||||
- [ ] T021 Run make build
|
||||
- [ ] T022 Run make web
|
||||
@@ -126,7 +126,7 @@ func setupEnv(t *testing.T) *testEnv {
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
// Create MCP server with 4 hybrid tools
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
t.Cleanup(func() {
|
||||
mcpSrv.Shutdown(context.Background())
|
||||
})
|
||||
|
||||
@@ -263,4 +263,10 @@ export const reactions = {
|
||||
)
|
||||
};
|
||||
|
||||
// Trust Scores
|
||||
export const trust = {
|
||||
get: (agentName: string) =>
|
||||
request<{ scores: Record<string, number> }>('GET', `/api/trust/${encodeURIComponent(agentName)}`)
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { agents as agentsApi } from '$lib/api/client';
|
||||
import { agents as agentsApi, trust as trustApi } from '$lib/api/client';
|
||||
import TraceViewer from '$lib/components/TraceViewer.svelte';
|
||||
|
||||
let agent = $state<any>(null);
|
||||
@@ -18,6 +18,11 @@
|
||||
let savingName = $state(false);
|
||||
let nameError = $state('');
|
||||
|
||||
// Trust Scores state
|
||||
let trustScores = $state<Record<string, number>>({});
|
||||
let trustLoading = $state(false);
|
||||
let trustEntries = $derived(Object.entries(trustScores).sort(([a], [b]) => a.localeCompare(b)));
|
||||
|
||||
// Access Rights state
|
||||
let allowedChannels = $state('');
|
||||
let readOnly = $state(false);
|
||||
@@ -44,6 +49,8 @@
|
||||
allowedChannels = (caps.allowed_channels || []).join(', ');
|
||||
readOnly = caps.read_only ?? false;
|
||||
maxRate = caps.max_rate ?? 60;
|
||||
// Load trust scores
|
||||
loadTrustScores();
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
@@ -51,6 +58,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrustScores() {
|
||||
trustLoading = true;
|
||||
try {
|
||||
const res = await trustApi.get(agentName);
|
||||
trustScores = res.scores || {};
|
||||
} catch {
|
||||
trustScores = {};
|
||||
} finally {
|
||||
trustLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
@@ -286,6 +305,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trust Scores -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">Trust Scores</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
{#if trustLoading}
|
||||
<div class="space-y-3">
|
||||
<div class="skeleton h-4 w-1/2"></div>
|
||||
<div class="skeleton h-4 w-2/3"></div>
|
||||
</div>
|
||||
{:else if trustEntries.length === 0}
|
||||
<p class="text-xs text-text-secondary">No trust scores recorded yet.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each trustEntries as [actionType, score]}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-medium text-text-primary">{actionType}</span>
|
||||
<span class="text-xs text-text-secondary">{Math.round(score * 100)}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300 {score >= 0.7 ? 'bg-accent-green' : score >= 0.4 ? 'bg-accent-yellow' : 'bg-accent-red'}"
|
||||
style="width: {Math.round(score * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Rights -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
|
||||
Reference in New Issue
Block a user