7 Commits
Author SHA1 Message Date
Algis Dumbris 9c0e7773b3 Merge branch '011-trust-claims-triggers' into main
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
2026-03-18 21:42:04 +02:00
Algis DumbrisandClaude Opus 4.6 8df22457ab feat: trust scores, claim semantics, state-change webhooks (011-trust-claims-triggers)
Trust scores: per (agent, action_type) pair, stored in agent_trust
table. Auto-adjusts when human reacts to AI agent messages (approve
+0.05, reject -0.1). Scores clamped [0.0, 1.0]. MCP get_trust action
+ REST API /api/trust/{agent}. Web UI shows trust progress bars on
agent detail pages.

Claim semantics: only one in_progress reaction per message enforced.
First agent to claim wins, duplicates rejected with clear error.

State-change webhooks: StateChangeNotifier interface fires
workflow.state_changed events through existing webhook infrastructure
when reactions change a message's derived workflow state.

Channel thresholds: publish_threshold and approve_threshold fields
on channels for configuring autonomy gates.

Migration 014_trust_claims.sql. 17 new test cases across trust
model + store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:41:54 +02:00
Algis DumbrisandClaude Opus 4.6 695dbf0c9f docs: agent platform architecture design spec
Three-layer architecture (Infrastructure, SynapBus, Agent Instances),
stigmergy coordination via workflow reactions, agent archetypes with
CLAUDE.md specialization, trust scoring, local-first runtime with
docker-compose, agent-init CLI tool, and 10 ensemble work ideas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 19:51:27 +02:00
Algis DumbrisandClaude Opus 4.6 d5a831bac4 fix: channels missing (workflow_enabled column), DM reactions, sidebar filtering
- Channel queries failed on prod because workflow_enabled column was
  missing (migration ran before column was added). Fixed prod DB.
- Added WorkflowBadge + ReactionPills to DM page view so reactions
  work in DMs, not just channels
- Filtered agent-to-agent DMs from sidebar — only show AI agents when
  they have unread messages for the human owner
- Updated 4 agent gitops repos with SynapBus reactions workflow
  instructions (react in_progress/done, thread replies, self-update)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:59:18 +02:00
Algis DumbrisandClaude Opus 4.6 6bb88374ce fix: DM messages cut off by limit, thread panel shows no replies
Bug 1 (DM disappearing): GetDMMessages used ORDER BY created_at ASC
with LIMIT 100, so newest messages were cut off when >100 DMs exist
between owned agents and a peer. Changed to DESC + reverse in handler
so the most recent messages are always included.

Bug 2 (empty thread panel): ThreadPanel loaded messages by
conversation_id, but reply_to links messages across different
conversations. Rewrote to use GET /api/messages/{id}/replies which
correctly finds all replies to a parent message. Added getReplies
method to the API client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:34:24 +02:00
Algis DumbrisandClaude Opus 4.6 3830fba728 fix: accept workflow_enabled in channel settings API request
The UpdateSettings handler was missing workflow_enabled from the
request struct, so PUT /api/channels/{name}/settings could not
enable/disable workflow mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:30:11 +02:00
Algis DumbrisandClaude Opus 4.6 4de779d30b chore: add synapbus-linux-amd64 to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:47:04 +02:00
36 changed files with 1475 additions and 81 deletions
+1
View File
@@ -44,3 +44,4 @@ __pycache__/
# Debug
__debug_bin*
.claude/worktrees/
synapbus-linux-amd64
+49 -1
View File
@@ -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
}
@@ -0,0 +1,290 @@
# Agent Platform Architecture Design
**Date**: 2026-03-18
**Status**: Draft
**Scope**: Multi-agent platform architecture using SynapBus + Claude Agent SDK + gitops workspaces
## Problem
Building autonomous agent swarms today requires stitching together communication, identity, coordination, trust, and runtime infrastructure from scratch. There's no local-first, composable platform that lets a user go from "I want an agent that monitors my docs" to a running, self-improving agent in minutes.
SynapBus already provides the communication layer. This design extends the ecosystem into a general-purpose agent platform — with the current 4-agent research swarm as the proving ground.
## Design Principles
1. **Local-first** — Docker + cron is the minimum runtime. No cloud, no Kubernetes required. Scale to K8s when ready.
2. **Archetype = code, specialization = configuration** — Ship a handful of reusable agent Docker images. Users create specialized instances by giving them different CLAUDE.md + skills via gitops workspaces.
3. **Stigmergy over orchestration** — No central coordinator. Channel messages are work items. Workflow reactions are the state machine. Agents self-organize by watching for states they can act on.
4. **Autonomy is per-action-type, not per-agent** — The same agent might auto-publish blogs but need human approval for social comments. Trust scores are tracked per (agent, action-type) pair.
5. **Trust is earned** — Agents start supervised. Successful outcomes increase trust. Rejections decrease it. The platform quantifies reliability.
6. **Agents self-improve** — Each agent has a gitops workspace (CLAUDE.md + skills). Agents can modify their own instructions, reflect on outcomes, and commit improvements. Knowledge persists across runs via git.
## Architecture: Three Layers
```
Layer 3: Agent Instances
Claude Agent SDK + Docker containers
Specialized via CLAUDE.md + skills in gitops workspace
Created by: agent-init CLI tool
Runtime: docker-compose (local) or K8s CronJobs (scaled)
Layer 2: SynapBus (Communication + Coordination)
Channels, DMs, reactions, workflow states
Stigmergy: agents watch states, self-assign work
Trust scores per (agent, action-type)
Escalation, audit trail, semantic search
Layer 1: Infrastructure
Docker + cron (local) or K8s (scaled)
Git repos for agent workspaces
Optional: PostgreSQL for domain-specific data
```
Each layer is independent. SynapBus doesn't know about Docker. Agents don't know about K8s. The CLI tool bridges them.
## Agent Identity & Trust
### Identity Model
```
Agent Instance = {
name: "research-mcpproxy"
archetype: "researcher"
workspace: "github.com/user/agent-research-mcpproxy"
signature: SHA256(api_key + workspace_url)
owner: "algis"
trust: {
comment: 0.3, # needs approval
publish: 0.9, # mostly autonomous
research: 1.0 # fully autonomous
}
}
```
### Trust Scoring
- Each action type has a trust score 0.0 to 1.0
- Starts at 0.0 (fully supervised)
- Human approves result (via reaction): +0.05
- Human rejects/fixes result: -0.1
- Autonomy threshold configurable per channel/action (e.g., `publish_threshold: 0.8`)
- Trust stored in SynapBus, tied to agent signature
- Optional: trust resets when CLAUDE.md changes significantly (agent's "brain" changed)
### Signature
- Proves identity across stateless runs
- SynapBus verifies on every MCP connection
- Forked workspace = new signature = zero trust
- Audit trail links actions to signatures
## Stigmergy Coordination Protocol
### The Core Idea
Messages on workflow-enabled channels ARE work items. Workflow reactions ARE the coordination mechanism. No orchestrator needed.
### State Machine
```
proposed --> approved --> in_progress --> done --> published
| | |
+-> rejected +-> rejected +-> rejected
```
Terminal states (no stalemate tracking): rejected, done, published.
### Who Moves What
| Transition | Actor | Autonomy Rule |
|---|---|---|
| new message -> proposed | Any agent | Automatic |
| proposed -> approved | Human, or agent with trust >= approve_threshold | Configurable |
| approved -> in_progress | Agent claims work (reacts in_progress) | Automatic |
| in_progress -> done | Working agent completes | Automatic |
| done -> published | Agent with trust >= publish_threshold | Configurable |
| any -> rejected | Human or supervisor | Always allowed |
### Agent Capabilities Declaration
In the agent's workspace config (part of CLAUDE.md or a separate capabilities file):
```yaml
capabilities:
- watch: "#new_posts"
states: ["approved"]
action: "write_draft"
- watch: "#news-*"
states: ["proposed"]
action: "cross_reference"
```
### The Startup Loop (Central Protocol)
Every agent, regardless of archetype, follows this loop on each run:
```
1. my_status() # inbox check (owner messages = top priority)
2. Process owner instructions # DMs from human owner take precedence
3. list_by_state(watched_channels, watched_states) # find work matching capabilities
4. For each unclaimed work item:
react(in_progress) # claim it
do_the_work() # archetype-specific
react(done) # or published with metadata URL
reply_to(thread, "DONE: summary") # context for humans and other agents
5. Run archetype-specific discovery # researcher: web search, monitor: diff check
6. Post findings to channels # creates new proposed items for the board
7. Reflect and self-improve # update CLAUDE.md, commit workspace
```
Steps 1-4 are universal. Step 5 is archetype-specific. Steps 6-7 close the loop.
### SynapBus Additions Needed
1. **Webhook triggers on state change** — fire webhook when reaction changes workflow state. Enables event-driven agent activation instead of polling.
2. **Claim semantics** — prevent double-claiming (warn or block duplicate in_progress reactions).
3. **Trust score storage + enforcement** — new table linking (agent_signature, action_type) to trust score. SynapBus checks trust before allowing autonomous state transitions.
## Agent Archetypes
Five base Docker images the platform ships:
| Archetype | Core Capability | Watches For | Produces |
|---|---|---|---|
| **Researcher** | Discovery, web search, analysis | Owner instructions, schedules | Findings, opportunities, cross-refs |
| **Writer** | Content creation, editing, publishing | Approved findings, draft requests | Blog posts, articles, social posts |
| **Commenter** | Social engagement, community responses | Approved opportunities with URLs | Comment drafts, replies |
| **Monitor** | Watching for changes, diffs, alerts | Schedules, trigger conditions | Alerts, status reports, drift findings |
| **Operator** | System tasks, DevOps, automation | Commands, incident alerts | Deployments, fixes, config changes |
Each archetype is one Docker image with the Claude Agent SDK pre-configured. The CLAUDE.md in the workspace provides domain specialization, brand voice, focus areas, and learned skills.
A single archetype can have multiple skills. Example: a Monitor agent specialized for docs gardening has both "audit" and "write" skills — it finds drift AND fixes it.
## Local-First Runtime
### Minimum setup (Docker + cron)
```
~/.agents/
docker-compose.yml # SynapBus + all agent containers
.env # shared config (SynapBus URL, etc.)
agents/
research-mcpproxy/
workspace/ # cloned gitops repo (CLAUDE.md + skills)
.env # agent-specific: API key, workspace URL
docs-gardener/
workspace/
.env
```
### docker-compose.yml
```yaml
services:
synapbus:
image: synapbus/synapbus:latest
ports: ["8080:8080"]
volumes: ["./data:/data"]
research-mcpproxy:
image: synapbus/agent-researcher:latest
volumes:
- ./agents/research-mcpproxy/workspace:/workspace
- ~/.claude:/app/.claude:ro
env_file: ./agents/research-mcpproxy/.env
profiles: ["agents"]
docs-gardener:
image: synapbus/agent-monitor:latest
volumes:
- ./agents/docs-gardener/workspace:/workspace
- ~/.claude:/app/.claude:ro
env_file: ./agents/docs-gardener/.env
profiles: ["agents"]
```
Agents are triggered by cron (host crontab runs `docker compose run --rm research-mcpproxy`) or by SynapBus webhooks hitting a local webhook receiver.
### Scale to K8s
Same Docker images, same workspaces. Replace docker-compose with K8s CronJobs. Point SYNAPBUS_URL at the cluster-internal service. No code changes.
## agent-init CLI Tool
Separate CLI tool for scaffolding new agent instances:
```bash
# Create a new agent from an archetype
agent-init create \
--name "docs-gardener" \
--archetype monitor \
--workspace github.com/user/agent-docs-gardener \
--synapbus http://localhost:8080
# What it does:
# 1. Creates gitops repo with starter CLAUDE.md for the archetype
# 2. Registers agent in SynapBus (creates API key)
# 3. Creates local workspace directory with .env
# 4. Adds agent to docker-compose.yml
# 5. Sets up cron schedule (asks user for frequency)
# 6. Joins agent to relevant SynapBus channels
```
This is a separate project from SynapBus — keeps Layer 2 and Layer 3 decoupled.
## 10 Ensemble Work Ideas
### Implementable Now (proving ground)
1. **Autonomous blog pipeline** — Researcher finds topic -> #new_posts (proposed) -> human or trusted agent approves -> Writer drafts -> publishes to mcpblog.dev / mcpproxy.app/blog / synapbus.dev/blog -> Commenter cross-posts to LinkedIn/X. Full stigmergy pipeline.
2. **Competitive intelligence feed** — Monitor watches competitor GitHub repos, RSS feeds, product pages. Posts diffs to #news-competitive. Researcher analyzes implications. Findings flow to Writer for response content.
3. **Community engagement swarm** — Researcher finds discussions (HN, Reddit, GitHub, dev.to). Commenter drafts responses. Graduated trust: starts supervised, earns autonomy. Monitor tracks engagement metrics and feeds back what worked.
4. **Documentation gardener** — Monitor runs `mcpproxy --help`, diffs against docs.mcpproxy.app. Finds drift, fixes docs, commits PRs. Single agent with audit + write skills. Uses GitHub MCP + shell access to the binary.
### New Domain Expansion
5. **Incident responder** — Monitor watches Grafana/Prometheus. Operator investigates (reads logs, checks metrics). If it has a skill for the fix, applies it. Otherwise escalates with full context.
6. **Dependency guardian** — Monitor watches CVE feeds + dependency trees. Researcher analyzes impact. Operator creates version bump PRs. Writer drafts security advisory if needed.
7. **Customer feedback loop** — Monitor watches support channels. Researcher clusters by theme. Writer generates weekly insight reports. Posts to #product-insights.
### Platform Maturity
8. **Agent marketplace** — Users share workspace repos as "agent recipes." Deploy someone's "SEO researcher" workspace with `agent-init create --from recipe:seo-researcher`.
9. **Self-improving network** — Agents commit learnings to workspace. Other instances of the same archetype can pull improvements. Knowledge propagates through git.
10. **Cross-org federation** — Two SynapBus instances connected via MCP. Research agent finds something relevant to a collaborator's domain. Posts to federated channel. Their agents pick it up. Trust works across boundaries.
### Sequencing
- **Phase 1** (now): Ideas 1-3 with current infrastructure + stigmergy protocol adoption
- **Phase 2** (next): agent-init CLI + Monitor/Operator archetypes (ideas 4-6)
- **Phase 3** (later): Platform features (ideas 7-10)
## Implementation Roadmap
### SynapBus Changes (speckit specs)
1. **010-reactions-workflows** — Done. Reactions + workflow states + badges.
2. **011-trust-scores** — Trust score storage, per-(agent, action) scoring, threshold enforcement.
3. **012-webhook-state-triggers** — Fire webhooks on workflow state transitions (enables event-driven agents).
4. **013-claim-semantics** — Prevent double-claiming of work items.
5. **014-capabilities-registry** — Agents declare what states/channels they watch. SynapBus can route work.
### New Projects
6. **agent-init** — CLI tool for scaffolding agents. Separate repo.
7. **agent-archetypes** — Docker images for researcher, writer, commenter, monitor, operator. Separate repo.
8. **Website docs** — Update synapbus.dev, mcpproxy.app docs with platform architecture.
### Searcher Migration
9. Refactor current 4 agents to use the archetype model (researcher archetype + domain CLAUDE.md).
10. Validate stigmergy loop with current #new_posts -> social-commenter pipeline.
+24 -3
View File
@@ -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"})`,
},
},
},
}
}
+6 -3
View File
@@ -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 {
+18 -3
View File
@@ -327,9 +327,12 @@ func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request)
}
var req struct {
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 {
@@ -338,11 +341,17 @@ func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request)
}
settings := channels.ChannelSettings{
WorkflowEnabled: ch.WorkflowEnabled,
AutoApprove: ch.AutoApprove,
StalemateRemindAfter: ch.StalemateRemindAfter,
StalemateEscalateAfter: ch.StalemateEscalateAfter,
PublishThreshold: ch.PublishThreshold,
ApproveThreshold: ch.ApproveThreshold,
}
if req.WorkflowEnabled != nil {
settings.WorkflowEnabled = *req.WorkflowEnabled
}
if req.AutoApprove != nil {
settings.AutoApprove = *req.AutoApprove
}
@@ -352,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 {
+5
View File
@@ -564,6 +564,11 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
return
}
// Reverse to chronological order (query returns newest first for correct LIMIT behavior)
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
h.msgService.EnrichMessages(r.Context(), msgs)
// Include last_read_message_id for the human agent's DM with the peer
+12
View File
@@ -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)
+44
View File
@@ -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,
})
}
+8 -8
View File
@@ -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)
+8 -4
View File
@@ -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.
+31
View File
@@ -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.
+2 -1
View File
@@ -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",
)
+1
View File
@@ -51,6 +51,7 @@ func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Se
nil, // attachmentService
nil, // searchService
nil, // reactionService
nil, // trustService
jsPool,
actionRegistry,
actionIndex,
+3
View File
@@ -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,
+3 -3
View File
@@ -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())
+5
View File
@@ -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,
)
+1
View File
@@ -69,6 +69,7 @@ func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.Mess
nil, // attachmentService
nil, // searchService
nil, // reactionService
nil, // trustService
jsPool,
actionRegistry,
actionIndex,
+1 -1
View File
@@ -669,7 +669,7 @@ func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string,
FROM messages
WHERE channel_id IS NULL
AND ((from_agent IN (%s) AND to_agent = ?) OR (from_agent = ? AND to_agent IN (%s)))
ORDER BY created_at ASC
ORDER BY created_at DESC
LIMIT ?`,
inClause, inClause,
)
+150 -2
View File
@@ -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;
+65
View File
@@ -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"`
}
+32
View File
@@ -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)
}
})
}
}
+83
View File
@@ -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
}
+91
View File
@@ -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)
}
+224
View File
@@ -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)
}
}
+11 -11
View File
@@ -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.HBAljWpY.js" rel="modulepreload">
<link href="/_app/immutable/chunks/By4mEdwX.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/DFRGYO_X.js" rel="modulepreload">
<link href="/_app/immutable/chunks/0x2jFCf0.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C3nS3byM.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C1Y8Vas-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bs4ZECIt.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.mRWkfG8z.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_ro0mhp = {
__sveltekit_12fzvv6 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.HBAljWpY.js"),
import("/_app/immutable/entry/app.mRWkfG8z.js")
import("/_app/immutable/entry/start.DrNjyUiQ.js"),
import("/_app/immutable/entry/app.MzJRUHQS.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+126
View File
@@ -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
+37
View File
@@ -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
Binary file not shown.
+1 -1
View File
@@ -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())
})
+7
View File
@@ -62,6 +62,7 @@ export const messages = {
return request<{ messages: any[]; total: number }>('GET', `/api/messages${q ? '?' + q : ''}`);
},
get: (id: number) => request<any>('GET', `/api/messages/${id}`),
getReplies: (id: number) => request<{ replies: any[]; total: number }>('GET', `/api/messages/${id}/replies`),
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_id?: number; reply_to?: number; attachments?: string[] }) =>
request<any>('POST', '/api/messages', body),
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
@@ -262,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 };
+12 -2
View File
@@ -55,6 +55,16 @@
return count > 99 ? '99+' : String(count);
}
// Filter DM list: only show human agents + AI agents with unread DMs.
// This hides agent-to-agent internal conversations from the sidebar.
let dmAgentList = $derived(
agentList.filter(agent => {
if (agent.type !== 'ai') return true;
const unread = $notifications.dms.get(agent.name) ?? 0;
return unread > 0;
})
);
const adminLinks = [
{ href: '/agents', label: 'Agents' },
{ href: '/settings', label: 'Settings' }
@@ -207,10 +217,10 @@
</button>
{#if dmsExpanded}
<div class="mt-0.5">
{#if agentList.length === 0}
{#if dmAgentList.length === 0}
<p class="px-3 py-1 text-xs text-text-secondary italic">No agents</p>
{:else}
{#each agentList as agent}
{#each dmAgentList as agent}
{@const dmUnread = $notifications.dms.get(agent.name) ?? 0}
<a
href="/dm/{agent.name}"
+47 -37
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { activeThread, closeThread } from '$lib/stores/thread';
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
import { messages as messagesApi } from '$lib/api/client';
import MessageBody from '$lib/components/MessageBody.svelte';
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
let conversation = $state<any>(null);
let threadMessages = $state<any[]>([]);
let parentMessage = $state<any>(null);
let threadReplies = $state<any[]>([]);
let loadingThread = $state(false);
let replyBody = $state('');
let sending = $state(false);
@@ -20,20 +20,24 @@
error = '';
replyBody = '';
if (val) {
loadThread(val.conversationId);
loadThread(val.messageId);
} else {
conversation = null;
threadMessages = [];
parentMessage = null;
threadReplies = [];
}
});
async function loadThread(conversationId: number) {
async function loadThread(messageId: number) {
loadingThread = true;
loadError = '';
try {
const res = await convsApi.get(conversationId);
conversation = res.conversation;
threadMessages = res.messages;
// Load parent message and its replies separately
const [msgRes, repliesRes] = await Promise.all([
messagesApi.get(messageId),
messagesApi.getReplies(messageId)
]);
parentMessage = msgRes;
threadReplies = repliesRes.replies || [];
} catch (err: any) {
loadError = err.message || 'Could not load thread';
} finally {
@@ -45,28 +49,27 @@
e.preventDefault();
if (!replyBody.trim() || !currentThread) return;
// Determine recipient: use last message's sender, or fallback to stored fromAgent
const recipient = threadMessages.length > 0
? threadMessages[threadMessages.length - 1].from_agent
: currentThread.fromAgent;
if (!recipient) {
error = 'Cannot determine recipient';
return;
}
sending = true;
error = '';
try {
await messagesApi.send({
to: recipient,
body: replyBody.trim(),
reply_to: currentThread.messageId,
conversation_id: currentThread.conversationId,
subject: conversation?.subject
});
// For channel messages, send as channel message with reply_to
if (parentMessage?.channel_id) {
await messagesApi.send({
body: replyBody.trim(),
channel_id: parentMessage.channel_id,
reply_to: currentThread.messageId
});
} else {
// For DMs, send to the other party
const recipient = parentMessage?.from_agent || currentThread.fromAgent;
await messagesApi.send({
to: recipient,
body: replyBody.trim(),
reply_to: currentThread.messageId
});
}
replyBody = '';
await loadThread(currentThread.conversationId);
await loadThread(currentThread.messageId);
} catch (err: any) {
error = err.message || 'Failed to send reply';
} finally {
@@ -101,6 +104,11 @@
if (form) form.requestSubmit();
}
}
// Combine parent + replies for display
let allMessages = $derived(
parentMessage ? [parentMessage, ...threadReplies] : threadReplies
);
</script>
{#if currentThread}
@@ -109,11 +117,13 @@
<div class="flex items-center justify-between h-12 px-4 border-b border-border flex-shrink-0">
<div class="min-w-0">
<h3 class="font-display font-bold text-sm text-text-primary truncate">Thread</h3>
{#if conversation}
<p class="text-[10px] text-text-secondary truncate">{conversation.subject || 'Conversation #' + conversation.id}</p>
{:else if currentThread.fromAgent}
<p class="text-[10px] text-text-secondary truncate">Reply to {currentThread.fromAgent}</p>
{/if}
<p class="text-[10px] text-text-secondary truncate">
{#if parentMessage}
{threadReplies.length} {threadReplies.length === 1 ? 'reply' : 'replies'}
{:else}
Loading...
{/if}
</p>
</div>
<button
class="p-1.5 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
@@ -143,14 +153,14 @@
{:else if loadError}
<div class="p-4 text-center text-text-secondary text-xs">
<p>Thread history unavailable</p>
<p class="mt-1 text-[10px]">You can still send a reply below</p>
<p class="mt-1 text-[10px]">{loadError}</p>
</div>
{:else if threadMessages.length === 0}
{:else if allMessages.length === 0}
<div class="p-4 text-center text-text-secondary text-xs">
No messages in this thread yet
</div>
{:else}
{#each threadMessages as msg, i (msg.id)}
{#each allMessages as msg, i (msg.id)}
<div class="px-4 py-3 hover:bg-bg-tertiary/50 transition-colors {i === 0 ? 'border-b border-border bg-bg-primary/30' : ''}">
<div class="flex gap-2.5">
<div class="w-7 h-7 rounded-full bg-bg-tertiary flex items-center justify-center text-[11px] font-bold text-text-secondary flex-shrink-0">
@@ -160,7 +170,7 @@
<div class="flex items-center gap-2 mb-0.5">
<span class="font-semibold text-xs text-text-primary">{msg.from_agent}</span>
<span class="text-[10px] text-text-secondary">{formatTime(msg.created_at)}</span>
{#if msg.status !== 'done'}
{#if msg.status && msg.status !== 'done' && msg.status !== 'pending'}
<span class="{statusClass(msg.status)} text-[10px]">{msg.status}</span>
{/if}
</div>
+54 -1
View File
@@ -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">
+6
View File
@@ -4,6 +4,8 @@
import { openThread, closeThread } from '$lib/stores/thread';
import { notifications } from '$lib/stores/notifications';
import MessageBody from '$lib/components/MessageBody.svelte';
import WorkflowBadge from '$lib/components/WorkflowBadge.svelte';
import ReactionPills from '$lib/components/ReactionPills.svelte';
let peerAgent = $derived($page.params.name);
let peer = $state<any>(null);
@@ -246,6 +248,10 @@
{/if}
</div>
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
{#if msg.workflow_state}
<WorkflowBadge state={msg.workflow_state} />
{/if}
<ReactionPills reactions={msg.reactions ?? []} messageId={msg.id} />
{#if msg.reply_count > 0}
<button
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"