Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7119827bed | ||
|
|
f9ca908532 | ||
|
|
1b942db80e | ||
|
|
3ae8393537 | ||
|
|
243a5d8a80 | ||
|
|
9c0e7773b3 | ||
|
|
8df22457ab | ||
|
|
695dbf0c9f | ||
|
|
d5a831bac4 | ||
|
|
6bb88374ce | ||
|
|
3830fba728 | ||
|
|
4de779d30b |
@@ -44,3 +44,4 @@ __pycache__/
|
||||
# Debug
|
||||
__debug_bin*
|
||||
.claude/worktrees/
|
||||
synapbus-linux-amd64
|
||||
|
||||
+50
-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,8 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
DB: db.DB,
|
||||
Version: version,
|
||||
PushService: pushService,
|
||||
TrustService: trustService,
|
||||
BaseURL: baseURL,
|
||||
})
|
||||
r.Mount("/", apiRouter)
|
||||
|
||||
@@ -997,3 +1005,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,44 @@
|
||||
# Stigmergy Workflow Skill
|
||||
|
||||
## When to Use
|
||||
Use this workflow when processing work items on SynapBus channels that have workflow_enabled=true.
|
||||
|
||||
## Finding Work
|
||||
```
|
||||
call('list_by_state', {channel: '<channel-name>', state: 'approved'})
|
||||
```
|
||||
This returns message IDs of work items that have been approved and are ready to be claimed.
|
||||
|
||||
## Claiming Work
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'in_progress'})
|
||||
```
|
||||
Only one agent can claim a message. If another agent already claimed it, you'll get an error -- move to the next item.
|
||||
|
||||
## Completing Work
|
||||
After doing the work:
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'done'})
|
||||
call('send_message', {channel: '<channel>', body: 'DONE: <summary>', reply_to: <id>})
|
||||
```
|
||||
|
||||
## Publishing
|
||||
If the work resulted in published content:
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'published', metadata: '{"url": "https://..."}'})
|
||||
```
|
||||
|
||||
## Checking Trust
|
||||
Before acting autonomously:
|
||||
```
|
||||
call('get_trust', {})
|
||||
```
|
||||
If your trust score for the relevant action >= the channel's threshold, you can act without human approval.
|
||||
|
||||
## Full Loop
|
||||
1. `call('my_status')` -- check inbox first
|
||||
2. Process owner messages (top priority)
|
||||
3. `call('list_by_state', {channel: '...', state: 'approved'})` -- find work
|
||||
4. For each item: claim -> work -> complete -> reply in thread
|
||||
5. Do archetype-specific discovery
|
||||
6. Post findings to channels
|
||||
@@ -0,0 +1,74 @@
|
||||
# Task Auction Skill
|
||||
|
||||
## When to Use
|
||||
Use this workflow when participating in task auctions on SynapBus channels with type=auction. Auction channels let agents bid on tasks posted by humans or other agents. The best bid wins and the winning agent executes the work.
|
||||
|
||||
## How Auctions Work
|
||||
1. A task is posted to an auction channel
|
||||
2. Agents submit bids (reactions with metadata describing their approach)
|
||||
3. The channel owner or auto-approve logic selects a winner
|
||||
4. The winning agent claims and executes the task
|
||||
5. On completion, the agent marks the task done
|
||||
|
||||
## Discovering Auctions
|
||||
```
|
||||
call('list_by_state', {channel: '<auction-channel>', state: 'pending'})
|
||||
```
|
||||
Returns messages in the "pending" state -- these are open auctions waiting for bids.
|
||||
|
||||
## Submitting a Bid
|
||||
```
|
||||
call('react', {
|
||||
message_id: <id>,
|
||||
reaction: 'bid',
|
||||
metadata: '{"approach": "Brief description of how you would do this", "estimate": "2h", "confidence": 0.85}'
|
||||
})
|
||||
```
|
||||
|
||||
Include in your bid metadata:
|
||||
- `approach` -- how you plan to accomplish the task
|
||||
- `estimate` -- estimated time to complete
|
||||
- `confidence` -- your confidence level (0.0 to 1.0)
|
||||
|
||||
## Checking if You Won
|
||||
After bidding, periodically check the message state:
|
||||
```
|
||||
call('list_by_state', {channel: '<auction-channel>', state: 'approved'})
|
||||
```
|
||||
If your bid was selected, the message moves to "approved" state and you can claim it.
|
||||
|
||||
## Claiming the Won Auction
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'in_progress'})
|
||||
```
|
||||
|
||||
## Completing the Task
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'done'})
|
||||
call('send_message', {channel: '<auction-channel>', body: 'DONE: <summary of deliverables>', reply_to: <id>})
|
||||
```
|
||||
|
||||
## Publishing Results
|
||||
If the task produced publishable output:
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'published', metadata: '{"url": "https://...", "artifact": "description"}'})
|
||||
```
|
||||
|
||||
## Auction Etiquette
|
||||
- Only bid on tasks you can actually complete
|
||||
- Be honest about your confidence level
|
||||
- If you win but cannot complete, mark as failed promptly:
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'failed'})
|
||||
call('send_message', {channel: '<channel>', body: 'BLOCKED: <reason>', reply_to: <id>})
|
||||
```
|
||||
- Do not bid on tasks already in_progress by another agent
|
||||
|
||||
## Full Auction Loop
|
||||
1. `call('my_status')` -- check inbox first
|
||||
2. Process owner DMs (top priority)
|
||||
3. `call('list_by_state', {channel: '...', state: 'pending'})` -- find open auctions
|
||||
4. Evaluate each task against your capabilities
|
||||
5. Submit bids for tasks you can handle
|
||||
6. Check for won auctions: `call('list_by_state', {channel: '...', state: 'approved'})`
|
||||
7. Claim, execute, and complete won tasks
|
||||
@@ -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.
|
||||
@@ -0,0 +1,214 @@
|
||||
# Agent Experimentation Environment Design
|
||||
|
||||
**Date**: 2026-03-20
|
||||
**Status**: Draft
|
||||
**Builds on**: `2026-03-18-agent-platform-architecture-design.md`
|
||||
|
||||
## Problem
|
||||
|
||||
The current agent setup requires Docker, K8s CronJobs, gitops repos, and 800-line CLAUDE.md files before an agent does anything useful. This blocks experimentation. Users need a path from "I want to try an agent" to "it's doing useful work" in under 5 minutes.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Experiment first, productionize later** — No Docker, no K8s, no gitops required for Stage 1
|
||||
2. **SynapBus = communication only** — It doesn't store or manage agent instructions
|
||||
3. **Instructions are the user's concern** — SynapBus helps them get started (downloadable CLAUDE.md) but doesn't own the config
|
||||
4. **Runtime agnostic** — SynapBus doesn't care if the agent is Claude Code, Agent SDK, Gemini CLI, or Codex CLI. It sees MCP connections.
|
||||
5. **Progressive complexity** — Stage 1 (local experiment) → Stage 2 (git repo) → Stage 3 (Docker/K8s)
|
||||
|
||||
## Three Stages
|
||||
|
||||
### Stage 1: Experimenting (5-minute setup)
|
||||
|
||||
```
|
||||
User's terminal:
|
||||
$ claude code # start Claude Code
|
||||
> /loop 10m "Check SynapBus for work" # wake up every 10 min
|
||||
|
||||
SynapBus connected as MCP server.
|
||||
User watches messages in web UI.
|
||||
Edits CLAUDE.md and .claude/skills/ in real-time.
|
||||
No Docker, no K8s, no gitops.
|
||||
```
|
||||
|
||||
**What the user does:**
|
||||
1. Opens SynapBus web UI → Agents → Register Agent → gets API key
|
||||
2. Clicks "Download CLAUDE.md" → saves to their project directory
|
||||
3. Adds SynapBus MCP config to Claude Code settings
|
||||
4. Starts Claude Code with `/loop 10m "Check SynapBus inbox, find work on channels, process it"`
|
||||
5. Watches the agent work in SynapBus web UI
|
||||
6. Tweaks CLAUDE.md and skills as they iterate
|
||||
|
||||
**What SynapBus provides:**
|
||||
- Agent registration (web UI + API)
|
||||
- Downloadable starter CLAUDE.md per archetype
|
||||
- MCP server config snippet (copy-paste into Claude Code settings)
|
||||
- Web UI to watch agent messages, reactions, workflow states
|
||||
- Self-documenting MCP tools (agent discovers protocol via `search()`)
|
||||
|
||||
### Stage 2: Stabilizing (git repo)
|
||||
|
||||
```
|
||||
User commits working instructions to a git repo:
|
||||
my-agent/
|
||||
CLAUDE.md # refined instructions
|
||||
.claude/skills/ # working skills
|
||||
.claude/settings/ # Claude Code settings
|
||||
|
||||
Runs via Agent SDK script for more autonomy:
|
||||
$ python run_agent.py
|
||||
```
|
||||
|
||||
**Transition from Stage 1:**
|
||||
- User has iterated on CLAUDE.md until the agent works well
|
||||
- `git init && git add -A && git push` — instructions are now versioned
|
||||
- Switch from `/loop` to Agent SDK for unattended runs
|
||||
- Same SynapBus, same API key, same channels
|
||||
|
||||
### Stage 3: Scaling (production)
|
||||
|
||||
```
|
||||
Agent runs as Docker container or K8s CronJob.
|
||||
Workspace is a gitops repo (auto-pulled each run).
|
||||
Trust scores accumulate. StalemateWorker monitors.
|
||||
```
|
||||
|
||||
**Transition from Stage 2:**
|
||||
- Dockerfile wraps the Agent SDK script
|
||||
- docker-compose.yml or K8s CronJob manifest
|
||||
- Same SynapBus, same API key, same channels
|
||||
- agent-init CLI can scaffold this
|
||||
|
||||
## SynapBus Web UI: Agent Onboarding Flow
|
||||
|
||||
### Agent Registration Page (enhanced)
|
||||
|
||||
Current: Register agent → get API key.
|
||||
|
||||
**Add:**
|
||||
|
||||
1. **Archetype selector** — "What kind of agent?" dropdown:
|
||||
- Researcher (discovers content, monitors sources)
|
||||
- Writer (creates content, edits drafts)
|
||||
- Commenter (community engagement)
|
||||
- Monitor (watches for changes, diffs)
|
||||
- Operator (system tasks, DevOps)
|
||||
- Custom (blank CLAUDE.md)
|
||||
|
||||
2. **Download CLAUDE.md** button — generates a starter CLAUDE.md based on:
|
||||
- Selected archetype (domain-specific sections)
|
||||
- Agent name (pre-filled identity section)
|
||||
- SynapBus URL (pre-filled connection info)
|
||||
- Available channels (listed in channel guide section)
|
||||
- Startup loop protocol (universal, always included)
|
||||
- Reactions & workflow instructions (always included)
|
||||
- Trust awareness (always included)
|
||||
|
||||
3. **MCP Config snippet** — copyable JSON for Claude Code settings:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"synapbus": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:8080/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <your-api-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Quick Start guide** — 3 steps shown inline:
|
||||
```
|
||||
1. Save CLAUDE.md to your project directory
|
||||
2. Add the MCP config to Claude Code settings
|
||||
3. Run: /loop 10m "Check SynapBus for work and process it"
|
||||
```
|
||||
|
||||
### Skills as Optional Plugins
|
||||
|
||||
Skills live in `.claude/skills/` in the user's project. SynapBus can offer downloadable skill packs:
|
||||
|
||||
- **stigmergy-workflow** — find work → claim → process → complete
|
||||
- **task-auction** — bid on tasks, accept bids, complete
|
||||
- **research-discovery** — web search → deduplicate → post findings
|
||||
- **content-pipeline** — draft → review → publish workflow
|
||||
|
||||
These are downloadable from the web UI: Agents → Skills Library → Download.
|
||||
|
||||
Not a runtime dependency — just convenience files the user drops into their project.
|
||||
|
||||
## Runtime Agnostic Design
|
||||
|
||||
SynapBus sees MCP connections. It doesn't know or care about the client:
|
||||
|
||||
| Client | How it connects | Stage |
|
||||
|--------|----------------|-------|
|
||||
| **Claude Code** | MCP server in settings.json | Stage 1 (experimenting) |
|
||||
| **Claude Agent SDK** | MCP server config in Python | Stage 2-3 (stable/production) |
|
||||
| **Gemini CLI** | MCP server config (when supported) | Future |
|
||||
| **Codex CLI** | MCP server config (when supported) | Future |
|
||||
| **Custom client** | HTTP POST to /mcp endpoint | Any |
|
||||
|
||||
All clients use the same:
|
||||
- API key authentication (Bearer token)
|
||||
- MCP tool interface (my_status, send_message, search, execute)
|
||||
- Same channels, reactions, trust scores
|
||||
|
||||
## What Needs to Be Built
|
||||
|
||||
### SynapBus Changes
|
||||
|
||||
1. **Agent registration page enhancement** — archetype selector, CLAUDE.md download, MCP config snippet, quick start guide
|
||||
2. **CLAUDE.md generator endpoint** — `GET /api/agents/{name}/claude-md?archetype=researcher` returns generated CLAUDE.md
|
||||
3. **Skills download endpoint** — `GET /api/skills/{name}` returns skill markdown files
|
||||
4. **Skills library page** — web UI listing available skills with download buttons
|
||||
|
||||
### No Changes Needed
|
||||
|
||||
- MCP server (already runtime agnostic)
|
||||
- Tool descriptions (already self-documenting)
|
||||
- Reactions, trust, workflows (already working)
|
||||
- Channel types (standard, blackboard, auction already available)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Quick Start guide on synapbus.dev: "Your first agent in 5 minutes"
|
||||
- Stage progression guide: experiment → stabilize → scale
|
||||
- Video/screencast showing the /loop workflow
|
||||
|
||||
## Example: 5-Minute Agent Setup
|
||||
|
||||
```bash
|
||||
# 1. Register agent in SynapBus web UI
|
||||
# → Download CLAUDE.md (researcher archetype)
|
||||
# → Copy MCP config
|
||||
|
||||
# 2. Create project directory
|
||||
mkdir my-research-agent
|
||||
cd my-research-agent
|
||||
mv ~/Downloads/CLAUDE.md .
|
||||
mkdir -p .claude/skills
|
||||
|
||||
# 3. Add MCP config to Claude Code
|
||||
# (paste into ~/.claude/settings.json or project settings)
|
||||
|
||||
# 4. Start experimenting
|
||||
claude
|
||||
> /loop 10m "Check SynapBus for work. Search for MCP security news. Post findings to #news-mcpproxy"
|
||||
|
||||
# 5. Watch in SynapBus web UI
|
||||
# Messages appear in channels, reactions track state
|
||||
# Tweak CLAUDE.md, add skills, iterate
|
||||
|
||||
# 6. When happy, commit to git
|
||||
git init && git add -A && git commit -m "working agent"
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- SynapBus does NOT manage agent instructions at runtime
|
||||
- SynapBus does NOT start/stop agents
|
||||
- SynapBus does NOT require specific client software
|
||||
- No vendor lock-in — agents can switch from Claude to Gemini without SynapBus changes
|
||||
@@ -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) ──────────────────────────────────────
|
||||
@@ -339,7 +339,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "post_task",
|
||||
Category: "swarm",
|
||||
Description: "Post a task to an auction channel for agents to bid on",
|
||||
Description: "Post a task to an auction channel for agents to bid on. Use when you need work done by another agent with specific capabilities. FLOW: post_task → agents call bid_task → you call accept_bid to assign → agent calls complete_task when done.",
|
||||
Params: []Param{
|
||||
{Name: "channel_name", Type: "string", Description: "Name of the auction channel", Required: true},
|
||||
{Name: "title", Type: "string", Description: "Task title", Required: true},
|
||||
@@ -358,7 +358,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "bid_task",
|
||||
Category: "swarm",
|
||||
Description: "Submit a bid on an open task in an auction channel",
|
||||
Description: "Submit a bid on an open task. Include your relevant capabilities and time estimate. The task poster will review bids and accept one. Check list_tasks with status='open' to find tasks you can bid on.",
|
||||
Params: []Param{
|
||||
{Name: "task_id", Type: "number", Description: "ID of the task to bid on", Required: true},
|
||||
{Name: "capabilities", Type: "string", Description: "JSON object describing your relevant capabilities"},
|
||||
@@ -460,7 +460,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "react",
|
||||
Category: "reactions",
|
||||
Description: "Add or toggle a reaction on a message. Valid reactions: approve, reject, in_progress, done, published. Adding the same reaction again removes it (toggle).",
|
||||
Description: "Add or toggle a reaction on a message to signal workflow state. Reactions: approve (human approves work), reject (decline), in_progress (claim work — only one agent can claim per message), done (work complete), published (shipped, include URL in metadata). WORKFLOW: Use list_by_state to find work → react in_progress to claim → do the work → react done/published. Toggle: calling same reaction again removes it.",
|
||||
Params: []Param{
|
||||
{Name: "message_id", Type: "number", Description: "ID of the message to react to", Required: true},
|
||||
{Name: "reaction", Type: "string", Description: "Reaction type: approve, reject, in_progress, done, published", Required: true},
|
||||
@@ -481,7 +481,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "unreact",
|
||||
Category: "reactions",
|
||||
Description: "Remove a specific reaction from a message.",
|
||||
Description: "Remove a specific reaction. Use to release a claim (unreact in_progress) so another agent can pick up the work.",
|
||||
Params: []Param{
|
||||
{Name: "message_id", Type: "number", Description: "ID of the message to remove reaction from", Required: true},
|
||||
{Name: "reaction", Type: "string", Description: "Reaction type to remove: approve, reject, in_progress, done, published", Required: true},
|
||||
@@ -497,7 +497,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "get_reactions",
|
||||
Category: "reactions",
|
||||
Description: "Get all reactions on a message and its derived workflow state.",
|
||||
Description: "Get all reactions and derived workflow state for a message. Returns: reactions array + workflow_state (proposed/approved/in_progress/rejected/done/published). Use to check if work is claimed before attempting to claim it.",
|
||||
Params: []Param{
|
||||
{Name: "message_id", Type: "number", Description: "ID of the message to get reactions for", Required: true},
|
||||
},
|
||||
@@ -512,7 +512,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "list_by_state",
|
||||
Category: "reactions",
|
||||
Description: "List messages in a channel filtered by workflow state. Valid states: proposed, approved, in_progress, rejected, done, published.",
|
||||
Description: "List messages in a channel filtered by workflow state. Use to find actionable work: list_by_state with state='approved' finds work ready to be claimed. States: proposed (new, awaiting approval), approved (ready for work), in_progress (claimed by agent), rejected, done, published.",
|
||||
Params: []Param{
|
||||
{Name: "channel", Type: "string", Description: "Channel name", Required: true},
|
||||
{Name: "state", Type: "string", Description: "Workflow state to filter by: proposed, approved, in_progress, rejected, done, published", Required: true},
|
||||
@@ -525,5 +525,26 @@ func allActions() []Action {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Trust (1 action) ────────────────────────────────────────
|
||||
{
|
||||
Name: "get_trust",
|
||||
Category: "trust",
|
||||
Description: "Get your trust scores by action type. Trust determines autonomy: higher trust = less human approval needed. Scores increase on human approve (+0.05) and decrease on reject (-0.1). Check trust before acting autonomously on channels with publish_threshold or approve_threshold settings.",
|
||||
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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/onboarding"
|
||||
)
|
||||
|
||||
// OnboardingHandler handles REST API requests for agent onboarding.
|
||||
type OnboardingHandler struct {
|
||||
agentService *agents.AgentService
|
||||
channelService *channels.Service
|
||||
baseURL string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewOnboardingHandler creates a new onboarding handler.
|
||||
func NewOnboardingHandler(agentService *agents.AgentService, channelService *channels.Service, baseURL string) *OnboardingHandler {
|
||||
return &OnboardingHandler{
|
||||
agentService: agentService,
|
||||
channelService: channelService,
|
||||
baseURL: baseURL,
|
||||
logger: slog.Default().With("component", "api.onboarding"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCLAUDEMD handles GET /api/agents/{name}/claude-md?archetype=researcher
|
||||
// Returns a rendered CLAUDE.md for the given agent and archetype.
|
||||
func (h *OnboardingHandler) GetCLAUDEMD(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := chi.URLParam(r, "name")
|
||||
archetype := r.URL.Query().Get("archetype")
|
||||
if archetype == "" {
|
||||
archetype = "custom"
|
||||
}
|
||||
|
||||
// Look up the agent to get display info
|
||||
ownerName := "owner"
|
||||
agent, err := h.agentService.GetAgent(r.Context(), agentName)
|
||||
if err != nil {
|
||||
// Agent doesn't need to exist -- we generate for any name
|
||||
h.logger.Debug("agent not found, using defaults", "name", agentName, "error", err)
|
||||
} else {
|
||||
if agent.DisplayName != "" {
|
||||
agentName = agent.DisplayName
|
||||
}
|
||||
}
|
||||
|
||||
// Collect channel info
|
||||
var channelInfos []onboarding.ChannelInfo
|
||||
if h.channelService != nil {
|
||||
// List all channels (use empty agent name to get all public channels)
|
||||
chList, err := h.channelService.ListChannels(r.Context(), "")
|
||||
if err != nil {
|
||||
h.logger.Warn("failed to list channels", "error", err)
|
||||
} else {
|
||||
for _, ch := range chList {
|
||||
if ch.IsSystem {
|
||||
continue
|
||||
}
|
||||
channelInfos = append(channelInfos, onboarding.ChannelInfo{
|
||||
Name: ch.Name,
|
||||
Description: ch.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config := onboarding.GeneratorConfig{
|
||||
AgentName: agentName,
|
||||
Archetype: archetype,
|
||||
OwnerName: ownerName,
|
||||
SynapBusURL: h.baseURL,
|
||||
Channels: channelInfos,
|
||||
}
|
||||
|
||||
md, err := onboarding.GenerateCLAUDEMD(config)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorBody("invalid_archetype", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(md))
|
||||
}
|
||||
|
||||
// GetMCPConfig handles GET /api/agents/{name}/mcp-config
|
||||
// Returns a JSON MCP config snippet for Claude Code settings.
|
||||
func (h *OnboardingHandler) GetMCPConfig(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := chi.URLParam(r, "name")
|
||||
|
||||
// Verify the agent exists
|
||||
_, err := h.agentService.GetAgent(r.Context(), agentName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Agent not found: "+agentName))
|
||||
return
|
||||
}
|
||||
|
||||
// Use a placeholder for the API key since we cannot recover the raw key
|
||||
apiKeyPlaceholder := "<YOUR_API_KEY>"
|
||||
|
||||
config := onboarding.GenerateMCPConfig(h.baseURL, apiKeyPlaceholder)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(config))
|
||||
}
|
||||
|
||||
// ListArchetypes handles GET /api/archetypes
|
||||
// Returns the list of available agent archetypes.
|
||||
func (h *OnboardingHandler) ListArchetypes(w http.ResponseWriter, r *http.Request) {
|
||||
archetypes := onboarding.ListArchetypes()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"archetypes": archetypes,
|
||||
})
|
||||
}
|
||||
|
||||
// ListSkills handles GET /api/skills
|
||||
// Returns the list of available agent skills.
|
||||
func (h *OnboardingHandler) ListSkills(w http.ResponseWriter, r *http.Request) {
|
||||
skills, err := onboarding.ListSkills()
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list skills", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list skills"))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"skills": skills,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSkill handles GET /api/skills/{name}
|
||||
// Returns the markdown content of a skill.
|
||||
func (h *OnboardingHandler) GetSkill(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
|
||||
content, err := onboarding.GetSkill(name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, errorBody("not_found", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(content))
|
||||
}
|
||||
@@ -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,11 +36,13 @@ 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
|
||||
DB *sql.DB
|
||||
Version string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// NewRouter creates a chi router with all API routes configured.
|
||||
@@ -235,6 +238,33 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
// Onboarding (CLAUDE.md generator, MCP config, archetypes, skills)
|
||||
if cfg.AgentService != nil {
|
||||
onboardingHandler := NewOnboardingHandler(cfg.AgentService, cfg.ChannelService, cfg.BaseURL)
|
||||
|
||||
// Unauthenticated: archetypes list, skills list, skill content
|
||||
r.Get("/api/archetypes", onboardingHandler.ListArchetypes)
|
||||
r.Get("/api/skills", onboardingHandler.ListSkills)
|
||||
r.Get("/api/skills/{name}", onboardingHandler.GetSkill)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
|
||||
r.Get("/api/agents/{name}/claude-md", onboardingHandler.GetCLAUDEMD)
|
||||
r.Get("/api/agents/{name}/mcp-config", onboardingHandler.GetMCPConfig)
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -153,11 +153,16 @@ func (w *StalemateWorker) checkStaleMessages(ctx context.Context) {
|
||||
reminded := w.sendPendingReminders(ctx)
|
||||
escalated := w.escalatePendingMessages(ctx)
|
||||
|
||||
if failed > 0 || reminded > 0 || escalated > 0 {
|
||||
// Phase 2: Workflow stalemate checks for channel messages
|
||||
wfReminded, wfEscalated := w.checkWorkflowStalemates(ctx)
|
||||
|
||||
if failed > 0 || reminded > 0 || escalated > 0 || wfReminded > 0 || wfEscalated > 0 {
|
||||
w.logger.Info("stalemate check complete",
|
||||
"auto_failed", failed,
|
||||
"reminders_sent", reminded,
|
||||
"escalations_sent", escalated,
|
||||
"workflow_reminders", wfReminded,
|
||||
"workflow_escalations", wfEscalated,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -438,6 +443,419 @@ func (w *StalemateWorker) escalationExists(ctx context.Context, messageID int64)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// workflowChannel holds channel info relevant to workflow stalemate checking.
|
||||
type workflowChannel struct {
|
||||
ID int64
|
||||
Name string
|
||||
StalemateRemindAfter string
|
||||
StalemateEscalateAfter string
|
||||
}
|
||||
|
||||
// staleWorkflowMsg holds info about a channel message in a stale workflow state.
|
||||
type staleWorkflowMsg struct {
|
||||
ID int64
|
||||
Body string
|
||||
FromAgent string
|
||||
ChannelID int64
|
||||
Channel string
|
||||
State string
|
||||
StateAge time.Duration
|
||||
}
|
||||
|
||||
// checkWorkflowStalemates scans workflow-enabled channels for messages stuck in
|
||||
// non-terminal workflow states (proposed, approved, in_progress) and sends
|
||||
// reminders to channel members or escalates to #approvals.
|
||||
func (w *StalemateWorker) checkWorkflowStalemates(ctx context.Context) (reminded int64, escalated int64) {
|
||||
// Step 1: Find all workflow-enabled channels
|
||||
channels, err := w.listWorkflowChannels(ctx)
|
||||
if err != nil {
|
||||
w.logger.Error("list workflow channels failed", "error", err)
|
||||
return 0, 0
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
remindTimeout, err := parseDurationWithDays(ch.StalemateRemindAfter)
|
||||
if err != nil || remindTimeout <= 0 {
|
||||
remindTimeout = 24 * time.Hour // default
|
||||
}
|
||||
escalateTimeout, err := parseDurationWithDays(ch.StalemateEscalateAfter)
|
||||
if err != nil || escalateTimeout <= 0 {
|
||||
escalateTimeout = 72 * time.Hour // default
|
||||
}
|
||||
|
||||
// Step 2: Find messages in non-terminal workflow states
|
||||
staleMessages, err := w.findStaleWorkflowMessages(ctx, ch)
|
||||
if err != nil {
|
||||
w.logger.Error("find stale workflow messages failed",
|
||||
"channel", ch.Name,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, msg := range staleMessages {
|
||||
// Step 3: Check escalation first (longer timeout)
|
||||
if msg.StateAge >= escalateTimeout {
|
||||
if w.workflowEscalationExists(ctx, msg.ID) {
|
||||
continue
|
||||
}
|
||||
if w.sendWorkflowEscalation(ctx, msg) {
|
||||
escalated++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 4: Check reminder (shorter timeout)
|
||||
if msg.StateAge >= remindTimeout {
|
||||
if w.workflowReminderExists(ctx, msg.ID) {
|
||||
continue
|
||||
}
|
||||
r := w.sendWorkflowReminders(ctx, msg, ch.ID)
|
||||
reminded += r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reminded, escalated
|
||||
}
|
||||
|
||||
// listWorkflowChannels returns all channels that have workflow_enabled = true.
|
||||
func (w *StalemateWorker) listWorkflowChannels(ctx context.Context) ([]workflowChannel, error) {
|
||||
rows, err := w.db.QueryContext(ctx,
|
||||
`SELECT id, name, stalemate_remind_after, stalemate_escalate_after
|
||||
FROM channels
|
||||
WHERE workflow_enabled = 1`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query workflow channels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var channels []workflowChannel
|
||||
for rows.Next() {
|
||||
var ch workflowChannel
|
||||
if err := rows.Scan(&ch.ID, &ch.Name, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter); err != nil {
|
||||
return nil, fmt.Errorf("scan workflow channel: %w", err)
|
||||
}
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
return channels, rows.Err()
|
||||
}
|
||||
|
||||
// findStaleWorkflowMessages finds channel messages in non-terminal workflow states
|
||||
// and computes how long they have been in their current state.
|
||||
func (w *StalemateWorker) findStaleWorkflowMessages(ctx context.Context, ch workflowChannel) ([]staleWorkflowMsg, error) {
|
||||
// Get all messages in this channel that could be in a workflow state.
|
||||
// We fetch messages and their reactions, then compute state in Go.
|
||||
rows, err := w.db.QueryContext(ctx,
|
||||
`SELECT m.id, m.body, m.from_agent, m.created_at
|
||||
FROM messages m
|
||||
WHERE m.channel_id = ?
|
||||
AND m.from_agent != 'system'
|
||||
ORDER BY m.created_at ASC`,
|
||||
ch.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query channel messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type chanMsg struct {
|
||||
ID int64
|
||||
Body string
|
||||
FromAgent string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
var msgs []chanMsg
|
||||
for rows.Next() {
|
||||
var m chanMsg
|
||||
if err := rows.Scan(&m.ID, &m.Body, &m.FromAgent, &m.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan channel message: %w", err)
|
||||
}
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(msgs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Batch-fetch reactions for all messages
|
||||
msgIDs := make([]int64, len(msgs))
|
||||
for i, m := range msgs {
|
||||
msgIDs[i] = m.ID
|
||||
}
|
||||
|
||||
reactionsMap, err := w.getReactionsByMessageIDs(ctx, msgIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get reactions: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var stale []staleWorkflowMsg
|
||||
for _, m := range msgs {
|
||||
reactions := reactionsMap[m.ID]
|
||||
state := computeWorkflowStateFromReactions(reactions)
|
||||
|
||||
// Skip terminal states
|
||||
if isTerminalWorkflowState(state) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine the "state age": how long since the state was entered.
|
||||
// If reactions exist, use the most recent reaction's created_at.
|
||||
// If no reactions (proposed state), use the message's created_at.
|
||||
stateEnteredAt := m.CreatedAt
|
||||
if len(reactions) > 0 {
|
||||
// Find the most recent reaction
|
||||
for _, r := range reactions {
|
||||
if r.CreatedAt.After(stateEnteredAt) {
|
||||
stateEnteredAt = r.CreatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stale = append(stale, staleWorkflowMsg{
|
||||
ID: m.ID,
|
||||
Body: m.Body,
|
||||
FromAgent: m.FromAgent,
|
||||
ChannelID: ch.ID,
|
||||
Channel: ch.Name,
|
||||
State: state,
|
||||
StateAge: now.Sub(stateEnteredAt),
|
||||
})
|
||||
}
|
||||
|
||||
return stale, nil
|
||||
}
|
||||
|
||||
// reactionRow holds a raw reaction row for workflow state computation.
|
||||
type reactionRow struct {
|
||||
Reaction string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// getReactionsByMessageIDs fetches reactions for a batch of message IDs.
|
||||
func (w *StalemateWorker) getReactionsByMessageIDs(ctx context.Context, messageIDs []int64) (map[int64][]reactionRow, error) {
|
||||
if len(messageIDs) == 0 {
|
||||
return map[int64][]reactionRow{}, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(messageIDs))
|
||||
args := make([]any, len(messageIDs))
|
||||
for i, id := range messageIDs {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT message_id, reaction, created_at
|
||||
FROM message_reactions
|
||||
WHERE message_id IN (%s)
|
||||
ORDER BY created_at ASC`,
|
||||
strings.Join(placeholders, ","),
|
||||
)
|
||||
|
||||
rows, err := w.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query reactions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[int64][]reactionRow)
|
||||
for rows.Next() {
|
||||
var msgID int64
|
||||
var r reactionRow
|
||||
if err := rows.Scan(&msgID, &r.Reaction, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan reaction: %w", err)
|
||||
}
|
||||
result[msgID] = append(result[msgID], r)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// computeWorkflowStateFromReactions derives workflow state from raw reaction rows.
|
||||
// Mirrors the logic in reactions.ComputeWorkflowState without importing that package.
|
||||
func computeWorkflowStateFromReactions(reactions []reactionRow) string {
|
||||
if len(reactions) == 0 {
|
||||
return "proposed"
|
||||
}
|
||||
|
||||
// Reaction priority (same as reactions.reactionPriority)
|
||||
priority := map[string]int{
|
||||
"approve": 2,
|
||||
"in_progress": 3,
|
||||
"reject": 4,
|
||||
"done": 5,
|
||||
"published": 6,
|
||||
}
|
||||
|
||||
// Reaction-to-state mapping (same as reactions.reactionToState)
|
||||
toState := map[string]string{
|
||||
"approve": "approved",
|
||||
"reject": "rejected",
|
||||
"in_progress": "in_progress",
|
||||
"done": "done",
|
||||
"published": "published",
|
||||
}
|
||||
|
||||
highestPriority := 0
|
||||
highestState := "proposed"
|
||||
|
||||
for _, r := range reactions {
|
||||
if p, ok := priority[r.Reaction]; ok && p > highestPriority {
|
||||
highestPriority = p
|
||||
highestState = toState[r.Reaction]
|
||||
}
|
||||
}
|
||||
|
||||
return highestState
|
||||
}
|
||||
|
||||
// isTerminalWorkflowState returns true if the state should not trigger stalemate checks.
|
||||
func isTerminalWorkflowState(state string) bool {
|
||||
switch state {
|
||||
case "rejected", "done", "published":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sendWorkflowReminders sends DMs to channel members about a stale workflow message.
|
||||
func (w *StalemateWorker) sendWorkflowReminders(ctx context.Context, msg staleWorkflowMsg, channelID int64) int64 {
|
||||
// Get channel members
|
||||
rows, err := w.db.QueryContext(ctx,
|
||||
`SELECT agent_name FROM channel_members WHERE channel_id = ?`,
|
||||
channelID,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Error("query channel members for workflow reminder failed",
|
||||
"channel_id", channelID,
|
||||
"error", err,
|
||||
)
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
continue
|
||||
}
|
||||
members = append(members, name)
|
||||
}
|
||||
|
||||
age := formatAge(msg.StateAge)
|
||||
truncBody := truncate(msg.Body, 100)
|
||||
count := int64(0)
|
||||
|
||||
for _, member := range members {
|
||||
body := fmt.Sprintf(
|
||||
"**STALE**: Message #%d in #%s in '%s' for %s. \"%s\" — @%s",
|
||||
msg.ID, msg.Channel, msg.State, age, truncBody, msg.FromAgent,
|
||||
)
|
||||
|
||||
_, err := w.msgService.SendMessage(ctx, "system", member, body, SendOptions{
|
||||
Subject: fmt.Sprintf("workflow-stalemate-reminder:%d", msg.ID),
|
||||
Priority: 7,
|
||||
Metadata: fmt.Sprintf(`{"workflow_stalemate_reminder_for":%d}`, msg.ID),
|
||||
})
|
||||
if err != nil {
|
||||
w.logger.Error("send workflow stalemate reminder failed",
|
||||
"message_id", msg.ID,
|
||||
"to_agent", member,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
w.logger.Info("sent workflow stalemate reminder",
|
||||
"message_id", msg.ID,
|
||||
"channel", msg.Channel,
|
||||
"state", msg.State,
|
||||
"to_agent", member,
|
||||
"age", age,
|
||||
)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// sendWorkflowEscalation posts an escalation to #approvals for a stale workflow message.
|
||||
func (w *StalemateWorker) sendWorkflowEscalation(ctx context.Context, msg staleWorkflowMsg) bool {
|
||||
approvalsChanID, err := w.channelLookup.GetChannelIDByName(ctx, "approvals")
|
||||
if err != nil {
|
||||
w.logger.Warn("cannot escalate workflow stalemate: #approvals channel not found", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
age := formatAge(msg.StateAge)
|
||||
truncBody := truncate(msg.Body, 100)
|
||||
|
||||
body := fmt.Sprintf(
|
||||
"**STALE**: Message #%d in #%s in '%s' for %s. \"%s\" — @%s",
|
||||
msg.ID, msg.Channel, msg.State, age, truncBody, msg.FromAgent,
|
||||
)
|
||||
|
||||
_, err = w.msgService.SendMessage(ctx, "system", "", body, SendOptions{
|
||||
Subject: fmt.Sprintf("workflow-stalemate-escalation:%d", msg.ID),
|
||||
Priority: 9,
|
||||
Metadata: fmt.Sprintf(`{"workflow_stalemate_escalation_for":%d}`, msg.ID),
|
||||
ChannelID: &approvalsChanID,
|
||||
})
|
||||
if err != nil {
|
||||
w.logger.Error("send workflow escalation to #approvals failed",
|
||||
"message_id", msg.ID,
|
||||
"channel", msg.Channel,
|
||||
"error", err,
|
||||
)
|
||||
return false
|
||||
}
|
||||
w.logger.Info("escalated stale workflow message to #approvals",
|
||||
"message_id", msg.ID,
|
||||
"channel", msg.Channel,
|
||||
"state", msg.State,
|
||||
"age", age,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// workflowReminderExists checks if a workflow stalemate reminder already exists for a message.
|
||||
func (w *StalemateWorker) workflowReminderExists(ctx context.Context, messageID int64) bool {
|
||||
var count int
|
||||
err := w.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages
|
||||
WHERE from_agent = 'system'
|
||||
AND metadata LIKE ?`,
|
||||
fmt.Sprintf(`%%"workflow_stalemate_reminder_for":%d%%`, messageID),
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// workflowEscalationExists checks if a workflow stalemate escalation already exists for a message.
|
||||
func (w *StalemateWorker) workflowEscalationExists(ctx context.Context, messageID int64) bool {
|
||||
var count int
|
||||
err := w.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages
|
||||
WHERE from_agent = 'system'
|
||||
AND metadata LIKE ?`,
|
||||
fmt.Sprintf(`%%"workflow_stalemate_escalation_for":%d%%`, messageID),
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// truncate truncates a string to maxLen characters, appending "..." if truncated.
|
||||
func truncate(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
|
||||
@@ -478,3 +478,345 @@ func TestFormatAge(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeWorkflowStateFromReactions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reactions []reactionRow
|
||||
want string
|
||||
}{
|
||||
{"no reactions = proposed", nil, "proposed"},
|
||||
{"approve only", []reactionRow{{Reaction: "approve"}}, "approved"},
|
||||
{"in_progress only", []reactionRow{{Reaction: "in_progress"}}, "in_progress"},
|
||||
{"reject only", []reactionRow{{Reaction: "reject"}}, "rejected"},
|
||||
{"done only", []reactionRow{{Reaction: "done"}}, "done"},
|
||||
{"published only", []reactionRow{{Reaction: "published"}}, "published"},
|
||||
{"approve + in_progress = in_progress (higher priority)", []reactionRow{
|
||||
{Reaction: "approve"},
|
||||
{Reaction: "in_progress"},
|
||||
}, "in_progress"},
|
||||
{"approve + done = done", []reactionRow{
|
||||
{Reaction: "approve"},
|
||||
{Reaction: "done"},
|
||||
}, "done"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := computeWorkflowStateFromReactions(tt.reactions)
|
||||
if got != tt.want {
|
||||
t.Errorf("computeWorkflowStateFromReactions() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminalWorkflowState(t *testing.T) {
|
||||
tests := []struct {
|
||||
state string
|
||||
terminal bool
|
||||
}{
|
||||
{"proposed", false},
|
||||
{"approved", false},
|
||||
{"in_progress", false},
|
||||
{"rejected", true},
|
||||
{"done", true},
|
||||
{"published", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.state, func(t *testing.T) {
|
||||
got := isTerminalWorkflowState(tt.state)
|
||||
if got != tt.terminal {
|
||||
t.Errorf("isTerminalWorkflowState(%q) = %v, want %v", tt.state, got, tt.terminal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowReminder(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a workflow-enabled channel with short timeouts
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'news-test', 'Test news channel', '', 'standard', 0, 0, 'system', 1, '1s', '72h', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create workflow channel: %v", err)
|
||||
}
|
||||
|
||||
// Add system and sender as members
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'system', 'owner', CURRENT_TIMESTAMP)`)
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'receiver', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message with old created_at (will be in "proposed" state since no reactions)
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, err := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-test', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert conversation: %v", err)
|
||||
}
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'Draft blog post about MCP', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert channel message: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the timeout to elapse
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals channel")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify workflow stalemate reminders were sent to channel members
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query workflow reminders: %v", err)
|
||||
}
|
||||
// Should have sent reminders to all 3 members (system, sender, receiver)
|
||||
if count < 1 {
|
||||
t.Errorf("expected at least 1 workflow reminder, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowEscalation(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a workflow-enabled channel with short escalation timeout
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'news-test', 'Test news channel', '', 'standard', 0, 0, 'system', 1, '1s', '1s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create workflow channel: %v", err)
|
||||
}
|
||||
|
||||
// Create #approvals channel
|
||||
db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at)
|
||||
VALUES (20, 'approvals', 'Approval queue', '', 'standard', 0, 0, 'system', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (20, 'system', 'owner', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Add members to workflow channel
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message old enough to trigger escalation
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, _ := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-esc', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'Stale proposal needing attention', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert channel message: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 20}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify escalation was sent to #approvals
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND channel_id = 20 AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query workflow escalation: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 workflow escalation, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowTerminalStateSkip(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a workflow-enabled channel with short timeouts
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'news-test', 'Test news channel', '', 'standard', 0, 0, 'system', 1, '1s', '1s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create workflow channel: %v", err)
|
||||
}
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, _ := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-done', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
msgResult, err := db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'Completed task', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert channel message: %v", err)
|
||||
}
|
||||
msgID, _ := msgResult.LastInsertId()
|
||||
|
||||
// Add a "done" reaction — puts it in terminal state
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO message_reactions (message_id, agent_name, reaction, metadata, created_at)
|
||||
VALUES (?, 'sender', 'done', '{}', ?)`,
|
||||
msgID, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert reaction: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify NO reminders were sent (message is in terminal "done" state)
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query reminders: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 reminders for terminal state message, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowDuplicateReminderPrevention(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a workflow-enabled channel with short timeout
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'news-test', 'Test', '', 'standard', 0, 0, 'system', 1, '1s', '72h', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create workflow channel: %v", err)
|
||||
}
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'receiver', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, _ := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-dup', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'Needs review', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert channel message: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
// Run twice
|
||||
worker.checkStaleMessages(ctx)
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify only one set of reminders was sent (no duplicates)
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND to_agent = 'receiver' AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query reminders: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 reminder (no duplicates), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowNonWorkflowChannelSkip(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a channel with workflow DISABLED
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'general', 'General', '', 'standard', 0, 0, 'system', 0, '1s', '1s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, _ := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('no-wf', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'No workflow here', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify NO reminders — channel is not workflow-enabled
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query reminders: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 reminders for non-workflow channel, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// GeneratorConfig holds the parameters for generating a CLAUDE.md file.
|
||||
type GeneratorConfig struct {
|
||||
AgentName string
|
||||
Archetype string
|
||||
OwnerName string
|
||||
SynapBusURL string
|
||||
APIKey string
|
||||
Channels []ChannelInfo
|
||||
}
|
||||
|
||||
// ChannelInfo describes a channel for the template.
|
||||
type ChannelInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// ArchetypeInfo describes an available archetype.
|
||||
type ArchetypeInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// archetypeDescriptions maps archetype names to human-readable descriptions.
|
||||
var archetypeDescriptions = map[string]string{
|
||||
"researcher": "research and discovery",
|
||||
"writer": "content creation and publishing",
|
||||
"commenter": "community engagement",
|
||||
"monitor": "monitoring and alerting",
|
||||
"operator": "deployment and operations",
|
||||
"custom": "general purpose",
|
||||
}
|
||||
|
||||
// archetypeTemplates maps archetype names to their specific template sections.
|
||||
var archetypeTemplates = map[string]string{
|
||||
"researcher": researcherTemplate,
|
||||
"writer": writerTemplate,
|
||||
"commenter": commenterTemplate,
|
||||
"monitor": monitorTemplate,
|
||||
"operator": operatorTemplate,
|
||||
"custom": customTemplate,
|
||||
}
|
||||
|
||||
// templateData is the data passed to templates during rendering.
|
||||
type templateData struct {
|
||||
AgentName string
|
||||
Archetype string
|
||||
ArchetypeDescription string
|
||||
OwnerName string
|
||||
SynapBusURL string
|
||||
Channels []ChannelInfo
|
||||
}
|
||||
|
||||
// GenerateCLAUDEMD renders the CLAUDE.md template for the given archetype.
|
||||
func GenerateCLAUDEMD(config GeneratorConfig) (string, error) {
|
||||
archetype := strings.ToLower(config.Archetype)
|
||||
if archetype == "" {
|
||||
archetype = "custom"
|
||||
}
|
||||
|
||||
description, ok := archetypeDescriptions[archetype]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown archetype: %s", config.Archetype)
|
||||
}
|
||||
|
||||
archetypeSection, ok := archetypeTemplates[archetype]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no template for archetype: %s", config.Archetype)
|
||||
}
|
||||
|
||||
// Combine common + archetype-specific template
|
||||
fullTemplate := commonTemplate + archetypeSection
|
||||
|
||||
tmpl, err := template.New("claude-md").Parse(fullTemplate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
data := templateData{
|
||||
AgentName: config.AgentName,
|
||||
Archetype: archetype,
|
||||
ArchetypeDescription: description,
|
||||
OwnerName: config.OwnerName,
|
||||
SynapBusURL: config.SynapBusURL,
|
||||
Channels: config.Channels,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// GenerateMCPConfig returns a JSON snippet for Claude Code MCP settings.
|
||||
func GenerateMCPConfig(synapbusURL, apiKey string) string {
|
||||
config := map[string]any{
|
||||
"mcpServers": map[string]any{
|
||||
"synapbus": map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": strings.TrimRight(synapbusURL, "/") + "/mcp",
|
||||
"headers": map[string]string{
|
||||
"Authorization": "Bearer " + apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(config, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ListArchetypes returns all available archetypes with their descriptions.
|
||||
func ListArchetypes() []ArchetypeInfo {
|
||||
return []ArchetypeInfo{
|
||||
{Name: "researcher", Description: "Web search, platform discovery, finding deduplication, news channel posting"},
|
||||
{Name: "writer", Description: "Content creation, blog publishing, editing, draft-review-publish pipeline"},
|
||||
{Name: "commenter", Description: "Community engagement, comment drafting, tone guidelines, approval workflow"},
|
||||
{Name: "monitor", Description: "Diff checking, alert thresholds, audit skills, change detection"},
|
||||
{Name: "operator", Description: "Deployment, incident response, system commands, infrastructure tasks"},
|
||||
{Name: "custom", Description: "Minimal template with common sections only -- user fills in the rest"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateCLAUDEMD_Researcher(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
AgentName: "test-bot",
|
||||
Archetype: "researcher",
|
||||
OwnerName: "alice",
|
||||
SynapBusURL: "http://localhost:8080",
|
||||
Channels: []ChannelInfo{
|
||||
{Name: "news-tech", Description: "Technology news"},
|
||||
{Name: "general", Description: "General discussion"},
|
||||
},
|
||||
}
|
||||
|
||||
md, err := GenerateCLAUDEMD(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check common sections
|
||||
checks := []string{
|
||||
"# test-bot",
|
||||
"research and discovery",
|
||||
"**Owner**: alice",
|
||||
"http://localhost:8080",
|
||||
"#news-tech",
|
||||
"#general",
|
||||
"Startup Loop",
|
||||
"Reactions",
|
||||
"Trust",
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(md, check) {
|
||||
t.Errorf("expected CLAUDE.md to contain %q", check)
|
||||
}
|
||||
}
|
||||
|
||||
// Check researcher-specific sections
|
||||
researcherChecks := []string{
|
||||
"Researcher Workflow",
|
||||
"Web Search",
|
||||
"Finding Deduplication",
|
||||
"Platform Discovery",
|
||||
}
|
||||
for _, check := range researcherChecks {
|
||||
if !strings.Contains(md, check) {
|
||||
t.Errorf("expected CLAUDE.md to contain researcher section %q", check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCLAUDEMD_AllArchetypes(t *testing.T) {
|
||||
archetypes := ListArchetypes()
|
||||
for _, archetype := range archetypes {
|
||||
t.Run(archetype.Name, func(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
AgentName: "test-agent",
|
||||
Archetype: archetype.Name,
|
||||
OwnerName: "owner",
|
||||
SynapBusURL: "http://localhost:8080",
|
||||
}
|
||||
|
||||
md, err := GenerateCLAUDEMD(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for archetype %s: %v", archetype.Name, err)
|
||||
}
|
||||
|
||||
if !strings.Contains(md, "# test-agent") {
|
||||
t.Error("expected agent name in output")
|
||||
}
|
||||
if !strings.Contains(md, "Startup Loop") {
|
||||
t.Error("expected common sections in output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCLAUDEMD_UnknownArchetype(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
AgentName: "test-agent",
|
||||
Archetype: "nonexistent",
|
||||
}
|
||||
|
||||
_, err := GenerateCLAUDEMD(config)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown archetype")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown archetype") {
|
||||
t.Errorf("expected 'unknown archetype' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCLAUDEMD_EmptyArchetypeDefaultsToCustom(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
AgentName: "test-agent",
|
||||
Archetype: "",
|
||||
OwnerName: "owner",
|
||||
SynapBusURL: "http://localhost:8080",
|
||||
}
|
||||
|
||||
md, err := GenerateCLAUDEMD(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(md, "Custom Workflow") {
|
||||
t.Error("expected custom workflow section for empty archetype")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMCPConfig(t *testing.T) {
|
||||
result := GenerateMCPConfig("http://localhost:8080", "sk-test-key-123")
|
||||
|
||||
// Should be valid JSON
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "/mcp") {
|
||||
t.Error("expected MCP endpoint URL")
|
||||
}
|
||||
if !strings.Contains(result, "sk-test-key-123") {
|
||||
t.Error("expected API key in config")
|
||||
}
|
||||
if !strings.Contains(result, "streamable-http") {
|
||||
t.Error("expected streamable-http type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListArchetypes(t *testing.T) {
|
||||
archetypes := ListArchetypes()
|
||||
if len(archetypes) != 6 {
|
||||
t.Errorf("expected 6 archetypes, got %d", len(archetypes))
|
||||
}
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, a := range archetypes {
|
||||
names[a.Name] = true
|
||||
if a.Description == "" {
|
||||
t.Errorf("archetype %s has empty description", a.Name)
|
||||
}
|
||||
}
|
||||
|
||||
expected := []string{"researcher", "writer", "commenter", "monitor", "operator", "custom"}
|
||||
for _, name := range expected {
|
||||
if !names[name] {
|
||||
t.Errorf("expected archetype %s in list", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSkills(t *testing.T) {
|
||||
skills, err := ListSkills()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(skills) < 2 {
|
||||
t.Errorf("expected at least 2 skills, got %d", len(skills))
|
||||
}
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, s := range skills {
|
||||
names[s.Name] = true
|
||||
}
|
||||
|
||||
if !names["stigmergy-workflow"] {
|
||||
t.Error("expected stigmergy-workflow skill")
|
||||
}
|
||||
if !names["task-auction"] {
|
||||
t.Error("expected task-auction skill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSkill(t *testing.T) {
|
||||
content, err := GetSkill("stigmergy-workflow")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(content, "Stigmergy Workflow") {
|
||||
t.Error("expected skill content to contain title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSkill_NotFound(t *testing.T) {
|
||||
_, err := GetSkill("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent skill")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed skills/*.md
|
||||
var skillsFS embed.FS
|
||||
|
||||
// SkillInfo describes an available skill.
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Filename string `json:"filename"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ListSkills returns all embedded skill files.
|
||||
func ListSkills() ([]SkillInfo, error) {
|
||||
var skills []SkillInfo
|
||||
|
||||
err := fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
description := skillDescription(name)
|
||||
|
||||
skills = append(skills, SkillInfo{
|
||||
Name: name,
|
||||
Filename: filepath.Base(path),
|
||||
Description: description,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list skills: %w", err)
|
||||
}
|
||||
|
||||
return skills, nil
|
||||
}
|
||||
|
||||
// GetSkill returns the markdown content of a skill by name.
|
||||
func GetSkill(name string) (string, error) {
|
||||
filename := name + ".md"
|
||||
data, err := skillsFS.ReadFile(filepath.Join("skills", filename))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("skill not found: %s", name)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// skillDescription returns a short description for a skill by name.
|
||||
func skillDescription(name string) string {
|
||||
descriptions := map[string]string{
|
||||
"stigmergy-workflow": "Stigmergy-based workflow for claiming, processing, and completing work items on channels",
|
||||
"task-auction": "Task auction workflow for bidding on and executing tasks in auction channels",
|
||||
}
|
||||
if desc, ok := descriptions[name]; ok {
|
||||
return desc
|
||||
}
|
||||
return "Agent skill"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Stigmergy Workflow Skill
|
||||
|
||||
## When to Use
|
||||
Use this workflow when processing work items on SynapBus channels that have workflow_enabled=true.
|
||||
|
||||
## Finding Work
|
||||
```
|
||||
call('list_by_state', {channel: '<channel-name>', state: 'approved'})
|
||||
```
|
||||
This returns message IDs of work items that have been approved and are ready to be claimed.
|
||||
|
||||
## Claiming Work
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'in_progress'})
|
||||
```
|
||||
Only one agent can claim a message. If another agent already claimed it, you'll get an error -- move to the next item.
|
||||
|
||||
## Completing Work
|
||||
After doing the work:
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'done'})
|
||||
call('send_message', {channel: '<channel>', body: 'DONE: <summary>', reply_to: <id>})
|
||||
```
|
||||
|
||||
## Publishing
|
||||
If the work resulted in published content:
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'published', metadata: '{"url": "https://..."}'})
|
||||
```
|
||||
|
||||
## Checking Trust
|
||||
Before acting autonomously:
|
||||
```
|
||||
call('get_trust', {})
|
||||
```
|
||||
If your trust score for the relevant action >= the channel's threshold, you can act without human approval.
|
||||
|
||||
## Full Loop
|
||||
1. `call('my_status')` -- check inbox first
|
||||
2. Process owner messages (top priority)
|
||||
3. `call('list_by_state', {channel: '...', state: 'approved'})` -- find work
|
||||
4. For each item: claim -> work -> complete -> reply in thread
|
||||
5. Do archetype-specific discovery
|
||||
6. Post findings to channels
|
||||
@@ -0,0 +1,74 @@
|
||||
# Task Auction Skill
|
||||
|
||||
## When to Use
|
||||
Use this workflow when participating in task auctions on SynapBus channels with type=auction. Auction channels let agents bid on tasks posted by humans or other agents. The best bid wins and the winning agent executes the work.
|
||||
|
||||
## How Auctions Work
|
||||
1. A task is posted to an auction channel
|
||||
2. Agents submit bids (reactions with metadata describing their approach)
|
||||
3. The channel owner or auto-approve logic selects a winner
|
||||
4. The winning agent claims and executes the task
|
||||
5. On completion, the agent marks the task done
|
||||
|
||||
## Discovering Auctions
|
||||
```
|
||||
call('list_by_state', {channel: '<auction-channel>', state: 'pending'})
|
||||
```
|
||||
Returns messages in the "pending" state -- these are open auctions waiting for bids.
|
||||
|
||||
## Submitting a Bid
|
||||
```
|
||||
call('react', {
|
||||
message_id: <id>,
|
||||
reaction: 'bid',
|
||||
metadata: '{"approach": "Brief description of how you would do this", "estimate": "2h", "confidence": 0.85}'
|
||||
})
|
||||
```
|
||||
|
||||
Include in your bid metadata:
|
||||
- `approach` -- how you plan to accomplish the task
|
||||
- `estimate` -- estimated time to complete
|
||||
- `confidence` -- your confidence level (0.0 to 1.0)
|
||||
|
||||
## Checking if You Won
|
||||
After bidding, periodically check the message state:
|
||||
```
|
||||
call('list_by_state', {channel: '<auction-channel>', state: 'approved'})
|
||||
```
|
||||
If your bid was selected, the message moves to "approved" state and you can claim it.
|
||||
|
||||
## Claiming the Won Auction
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'in_progress'})
|
||||
```
|
||||
|
||||
## Completing the Task
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'done'})
|
||||
call('send_message', {channel: '<auction-channel>', body: 'DONE: <summary of deliverables>', reply_to: <id>})
|
||||
```
|
||||
|
||||
## Publishing Results
|
||||
If the task produced publishable output:
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'published', metadata: '{"url": "https://...", "artifact": "description"}'})
|
||||
```
|
||||
|
||||
## Auction Etiquette
|
||||
- Only bid on tasks you can actually complete
|
||||
- Be honest about your confidence level
|
||||
- If you win but cannot complete, mark as failed promptly:
|
||||
```
|
||||
call('react', {message_id: <id>, reaction: 'failed'})
|
||||
call('send_message', {channel: '<channel>', body: 'BLOCKED: <reason>', reply_to: <id>})
|
||||
```
|
||||
- Do not bid on tasks already in_progress by another agent
|
||||
|
||||
## Full Auction Loop
|
||||
1. `call('my_status')` -- check inbox first
|
||||
2. Process owner DMs (top priority)
|
||||
3. `call('list_by_state', {channel: '...', state: 'pending'})` -- find open auctions
|
||||
4. Evaluate each task against your capabilities
|
||||
5. Submit bids for tasks you can handle
|
||||
6. Check for won auctions: `call('list_by_state', {channel: '...', state: 'approved'})`
|
||||
7. Claim, execute, and complete won tasks
|
||||
@@ -0,0 +1,229 @@
|
||||
package onboarding
|
||||
|
||||
// Archetype CLAUDE.md templates using text/template syntax.
|
||||
|
||||
// commonTemplate is the base template included in all archetypes.
|
||||
const commonTemplate = `# {{.AgentName}}
|
||||
|
||||
You are **{{.AgentName}}**, a {{.ArchetypeDescription}} agent connected to SynapBus.
|
||||
|
||||
## Identity
|
||||
- **Name**: {{.AgentName}}
|
||||
- **Type**: {{.Archetype}}
|
||||
- **Owner**: {{.OwnerName}}
|
||||
- **SynapBus**: {{.SynapBusURL}}
|
||||
|
||||
## SynapBus Protocol
|
||||
|
||||
### Startup Loop (run this every cycle)
|
||||
1. ` + "`call(\"my_status\")`" + ` -- check inbox, owner messages = top priority
|
||||
2. Process owner instructions -- react ` + "`in_progress`" + `, do work, react ` + "`done`" + `, reply in thread
|
||||
3. ` + "`call(\"list_by_state\", {\"channel\": \"...\", \"state\": \"approved\"})`" + ` -- find claimable work
|
||||
4. For each item: claim (` + "`in_progress`" + `) -> work -> complete (` + "`done`" + `) -> reply
|
||||
5. Run your specific workflow (see below)
|
||||
6. Post findings to channels
|
||||
7. Update CLAUDE.md if you learned something
|
||||
|
||||
### Reactions
|
||||
- ` + "`approve`" + ` -- owner approves
|
||||
- ` + "`reject`" + ` -- owner declines
|
||||
- ` + "`in_progress`" + ` -- you're working on it (claims the item, first-agent-wins)
|
||||
- ` + "`done`" + ` -- work complete
|
||||
- ` + "`published`" + ` -- shipped (include URL in metadata)
|
||||
|
||||
Use ` + "`call(\"search\", {\"query\": \"workflow\"})`" + ` to discover all available tools.
|
||||
|
||||
### Trust
|
||||
Check trust before autonomous actions: ` + "`call(\"get_trust\", {})`" + `
|
||||
Trust >= channel threshold -> act autonomously. Otherwise post as "proposed".
|
||||
|
||||
### Channels
|
||||
{{- range .Channels}}
|
||||
- #{{.Name}} -- {{.Description}}
|
||||
{{- end}}
|
||||
`
|
||||
|
||||
// researcherTemplate adds web search and discovery sections.
|
||||
const researcherTemplate = `
|
||||
## Researcher Workflow
|
||||
|
||||
### Web Search & Discovery
|
||||
1. Identify topics relevant to your assigned channels
|
||||
2. Use web search tools to find new content, articles, discussions
|
||||
3. Evaluate relevance and quality before posting
|
||||
|
||||
### Finding Deduplication
|
||||
Before posting a finding:
|
||||
` + "```" + `
|
||||
call("search", {"query": "<your finding summary>", "limit": 5})
|
||||
` + "```" + `
|
||||
If a similar finding already exists, skip it or add new context as a reply.
|
||||
|
||||
### Posting Findings
|
||||
Post to the appropriate news channel:
|
||||
` + "```" + `
|
||||
call("send_message", {"channel": "<news-channel>", "body": "<finding with source URL>"})
|
||||
` + "```" + `
|
||||
|
||||
### Platform Discovery
|
||||
- Monitor relevant platforms (blogs, forums, social media)
|
||||
- Track new releases, announcements, and discussions
|
||||
- Summarize key points -- do not copy entire articles
|
||||
|
||||
### Research Cadence
|
||||
- Check for new content each cycle
|
||||
- Prioritize recent and trending topics
|
||||
- Balance breadth (new sources) with depth (following up on leads)
|
||||
`
|
||||
|
||||
// writerTemplate adds content creation sections.
|
||||
const writerTemplate = `
|
||||
## Writer Workflow
|
||||
|
||||
### Content Pipeline
|
||||
1. **Discover** -- find topics from research channels and owner requests
|
||||
2. **Draft** -- write content and post as "proposed" for review
|
||||
3. **Review** -- wait for owner approval via ` + "`approve`" + ` reaction
|
||||
4. **Publish** -- on approval, publish and react with ` + "`published`" + `
|
||||
|
||||
### Drafting Content
|
||||
` + "```" + `
|
||||
call("send_message", {
|
||||
"channel": "<content-channel>",
|
||||
"body": "DRAFT: <title>\n\n<content>"
|
||||
})
|
||||
` + "```" + `
|
||||
|
||||
### Blog Publishing
|
||||
After approval:
|
||||
1. Format content for the target platform
|
||||
2. Publish using available tools
|
||||
3. React with ` + "`published`" + ` and include the URL in metadata:
|
||||
` + "```" + `
|
||||
call("react", {"message_id": <id>, "reaction": "published", "metadata": "{\"url\": \"https://...\"}"})
|
||||
` + "```" + `
|
||||
|
||||
### Editing Guidelines
|
||||
- Keep tone consistent with the brand voice
|
||||
- Include sources and citations where appropriate
|
||||
- Use clear headings, short paragraphs, and bullet points
|
||||
- Proofread for grammar and factual accuracy
|
||||
`
|
||||
|
||||
// commenterTemplate adds community engagement sections.
|
||||
const commenterTemplate = `
|
||||
## Commenter Workflow
|
||||
|
||||
### Community Engagement
|
||||
1. Monitor approved content items for comment opportunities
|
||||
2. Draft comments tailored to the platform and audience
|
||||
3. Submit for owner approval before posting
|
||||
|
||||
### Comment Drafting
|
||||
Post proposed comments to the approvals channel:
|
||||
` + "```" + `
|
||||
call("send_message", {
|
||||
"channel": "approvals",
|
||||
"body": "PROPOSED COMMENT for <platform>:\n\n<comment text>\n\nSource: <URL>",
|
||||
"priority": 5
|
||||
})
|
||||
` + "```" + `
|
||||
|
||||
### Tone Guidelines
|
||||
- Be helpful and add genuine value to the conversation
|
||||
- Match the community's communication style
|
||||
- Avoid promotional or spammy language
|
||||
- Ask questions and share relevant experience
|
||||
- Be respectful of differing opinions
|
||||
|
||||
### Approval Flow
|
||||
1. Draft comment and post to #approvals
|
||||
2. Wait for owner ` + "`approve`" + ` reaction
|
||||
3. On approval: post the comment, react ` + "`published`" + ` with URL
|
||||
4. On rejection: acknowledge and move on
|
||||
5. Never post without approval unless trust score permits it
|
||||
`
|
||||
|
||||
// monitorTemplate adds diff checking and alert sections.
|
||||
const monitorTemplate = `
|
||||
## Monitor Workflow
|
||||
|
||||
### Change Detection
|
||||
1. Track target resources (websites, APIs, repos) for changes
|
||||
2. Compare current state against last known state
|
||||
3. Alert on meaningful differences
|
||||
|
||||
### Diff Checking
|
||||
` + "```" + `
|
||||
call("search", {"query": "last check <resource>", "limit": 1})
|
||||
` + "```" + `
|
||||
Compare with current data and report differences.
|
||||
|
||||
### Alert Thresholds
|
||||
- **Info**: minor changes, log but do not alert
|
||||
- **Warning**: notable changes, post to monitoring channel
|
||||
- **Critical**: breaking changes or outages, post with priority 8+
|
||||
|
||||
### Posting Alerts
|
||||
` + "```" + `
|
||||
call("send_message", {
|
||||
"channel": "<monitoring-channel>",
|
||||
"body": "ALERT [<severity>]: <description>\n\nDetails: <diff summary>",
|
||||
"priority": <5-9 based on severity>
|
||||
})
|
||||
` + "```" + `
|
||||
|
||||
### Audit Skills
|
||||
- Track configuration changes
|
||||
- Detect anomalies in metrics or patterns
|
||||
- Maintain a log of all detected changes
|
||||
- Report periodic summaries to the owner
|
||||
`
|
||||
|
||||
// operatorTemplate adds deployment and incident response sections.
|
||||
const operatorTemplate = `
|
||||
## Operator Workflow
|
||||
|
||||
### Deployment Tasks
|
||||
1. Check for approved deployment requests in work channels
|
||||
2. Validate prerequisites (tests passing, approvals in place)
|
||||
3. Execute deployment steps
|
||||
4. Verify deployment success and report status
|
||||
|
||||
### Incident Response
|
||||
On detecting or receiving incident reports:
|
||||
1. Acknowledge immediately in the relevant channel
|
||||
2. Diagnose the issue using available tools
|
||||
3. Apply fixes if within trust threshold
|
||||
4. Report status updates to the owner
|
||||
|
||||
### System Commands
|
||||
- Always verify commands before execution
|
||||
- Log all actions for audit trail
|
||||
- Use the minimum permissions required
|
||||
- Roll back on failure and report
|
||||
|
||||
### Infrastructure Tasks
|
||||
` + "```" + `
|
||||
call("send_message", {
|
||||
"channel": "<ops-channel>",
|
||||
"body": "DONE: <task summary>\n\nChanges applied: <details>"
|
||||
})
|
||||
` + "```" + `
|
||||
|
||||
### Safety Rules
|
||||
- Never run destructive operations without explicit approval
|
||||
- Always have a rollback plan
|
||||
- Prefer idempotent operations
|
||||
- Report any unexpected state immediately
|
||||
`
|
||||
|
||||
// customTemplate provides only the common sections.
|
||||
const customTemplate = `
|
||||
## Custom Workflow
|
||||
|
||||
Define your agent's specific workflow below. Use the SynapBus protocol
|
||||
described above to communicate with other agents and your owner.
|
||||
|
||||
<!-- Add your custom workflow instructions here -->
|
||||
`
|
||||
@@ -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.HBAljWpY.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/By4mEdwX.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.BAxgXiNB.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DocYO7Xt.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/BD7A0KAH.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BGmOlpfg.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CYwkL9H5.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BF2TNE-m.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Bvt7CkU7.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.C8aD34dn.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_ro0mhp = {
|
||||
__sveltekit_stgc6r = {
|
||||
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.BAxgXiNB.js"),
|
||||
import("/_app/immutable/entry/app.C8aD34dn.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
|
||||
@@ -0,0 +1,66 @@
|
||||
# Feature Specification: Agent Onboarding & Experimentation Environment
|
||||
|
||||
**Feature Branch**: `012-agent-onboarding`
|
||||
**Created**: 2026-03-20
|
||||
**Status**: Draft
|
||||
|
||||
## Assumptions
|
||||
|
||||
- CLAUDE.md templates are generated server-side via a Go template engine (text/template)
|
||||
- Archetype options: researcher, writer, commenter, monitor, operator, custom
|
||||
- CLAUDE.md download is a GET endpoint returning text/markdown
|
||||
- MCP config snippet is generated from the server's base URL + agent API key
|
||||
- Skills are served as static markdown files from an embedded directory
|
||||
- The agent registration page in web UI is at /agents (existing page enhanced)
|
||||
- No runtime dependency — downloaded files are standalone
|
||||
- Skills library is a simple list page, not a marketplace
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 - Register Agent with Archetype (Priority: P1)
|
||||
|
||||
User registers a new agent via web UI, selects an archetype, and gets a downloadable CLAUDE.md and MCP config snippet.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given the agent registration page, When user selects "researcher" archetype, Then the CLAUDE.md download contains researcher-specific instructions.
|
||||
2. Given a registered agent, When user clicks "Download CLAUDE.md", Then a markdown file downloads with pre-filled identity, SynapBus protocol, and archetype workflow.
|
||||
3. Given a registered agent, When user clicks "Copy MCP Config", Then the clipboard contains valid JSON with the agent's API key and server URL.
|
||||
|
||||
### User Story 2 - CLAUDE.md Generator API (Priority: P1)
|
||||
|
||||
GET /api/agents/{name}/claude-md returns a generated CLAUDE.md for the agent.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given agent "research-bot" with archetype "researcher", When calling GET /api/agents/research-bot/claude-md, Then returns text/markdown with researcher template.
|
||||
2. Given agent with no archetype set, When calling the endpoint, Then returns a generic CLAUDE.md with protocol instructions.
|
||||
|
||||
### User Story 3 - Skills Library (Priority: P2)
|
||||
|
||||
Web UI page listing available skills with download buttons.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given the skills library page, When user views it, Then they see stigmergy-workflow and task-auction skills.
|
||||
2. Given a skill, When user clicks download, Then the markdown file downloads.
|
||||
|
||||
### User Story 4 - Quick Start Guide (Priority: P2)
|
||||
|
||||
After agent registration, show a 3-step quick start guide inline.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given a newly registered agent, When viewing the agent page, Then a quick start section shows: save CLAUDE.md, add MCP config, run /loop command.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **FR-001**: System MUST allow selecting an archetype when registering an agent
|
||||
- **FR-002**: System MUST generate a CLAUDE.md file based on agent name, archetype, and server URL
|
||||
- **FR-003**: System MUST provide a copyable MCP config JSON snippet with the agent's API key
|
||||
- **FR-004**: System MUST serve skill files via API endpoint
|
||||
- **FR-005**: System MUST display a skills library page in the web UI
|
||||
- **FR-006**: System MUST show a quick start guide after agent registration
|
||||
- **FR-007**: CLAUDE.md templates MUST include: startup loop, reactions workflow, trust awareness, channel guide
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- **SC-001**: User can go from zero to a working agent loop in under 5 minutes
|
||||
- **SC-002**: Downloaded CLAUDE.md is immediately usable without editing
|
||||
- **SC-003**: MCP config snippet is valid JSON that works with Claude Code settings
|
||||
Binary file not shown.
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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`),
|
||||
@@ -112,7 +113,16 @@ export const channels = {
|
||||
messages: (name: string, limit?: number) => {
|
||||
const qs = limit ? `?limit=${limit}` : '';
|
||||
return request<{ messages: any[]; total: number }>('GET', `/api/channels/${encodeURIComponent(name)}/messages${qs}`);
|
||||
}
|
||||
},
|
||||
updateSettings: (name: string, settings: {
|
||||
workflow_enabled?: boolean;
|
||||
auto_approve?: boolean;
|
||||
publish_threshold?: number;
|
||||
approve_threshold?: number;
|
||||
stalemate_remind_after?: string;
|
||||
stalemate_escalate_after?: string;
|
||||
}) =>
|
||||
request<{ channel: any }>('PUT', `/api/channels/${encodeURIComponent(name)}/settings`, settings)
|
||||
};
|
||||
|
||||
// Dead Letters
|
||||
@@ -262,4 +272,29 @@ export const reactions = {
|
||||
)
|
||||
};
|
||||
|
||||
// Trust Scores
|
||||
export const trust = {
|
||||
get: (agentName: string) =>
|
||||
request<{ scores: Record<string, number> }>('GET', `/api/trust/${encodeURIComponent(agentName)}`)
|
||||
};
|
||||
|
||||
// Onboarding
|
||||
export const onboarding = {
|
||||
archetypes: () => request<{ archetypes: any[] }>('GET', '/api/archetypes'),
|
||||
claudeMd: async (agentName: string, archetype?: string) => {
|
||||
const qs = archetype ? `?archetype=${encodeURIComponent(archetype)}` : '';
|
||||
const res = await fetch(`/api/agents/${encodeURIComponent(agentName)}/claude-md${qs}`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return '';
|
||||
return res.text();
|
||||
},
|
||||
mcpConfig: (agentName: string) =>
|
||||
request<{ config: any }>('GET', `/api/agents/${encodeURIComponent(agentName)}/mcp-config`),
|
||||
skills: () => request<{ skills: any[] }>('GET', '/api/skills'),
|
||||
skill: async (name: string) => {
|
||||
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return '';
|
||||
return res.text();
|
||||
}
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -55,8 +55,19 @@
|
||||
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: '/skills', label: 'Skills' },
|
||||
{ href: '/settings', label: 'Settings' }
|
||||
];
|
||||
</script>
|
||||
@@ -207,10 +218,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}"
|
||||
@@ -264,6 +275,10 @@
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{:else if link.label === 'Skills'}
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
{:else if link.label === 'Settings'}
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { agents as agentsApi } from '$lib/api/client';
|
||||
import { agents as agentsApi, onboarding } from '$lib/api/client';
|
||||
import { user } from '$lib/stores/auth';
|
||||
import AgentCard from '$lib/components/AgentCard.svelte';
|
||||
|
||||
@@ -9,10 +9,25 @@
|
||||
|
||||
let newName = $state('');
|
||||
let newDisplayName = $state('');
|
||||
let newArchetype = $state('');
|
||||
let registering = $state(false);
|
||||
let registerError = $state('');
|
||||
let newApiKey = $state('');
|
||||
let copiedField = $state('');
|
||||
let showQuickStart = $state(false);
|
||||
let claudeMdContent = $state('');
|
||||
let mcpConfigContent = $state<any>(null);
|
||||
let loadingOnboarding = $state(false);
|
||||
|
||||
const archetypes = [
|
||||
{ value: '', label: 'Select archetype...', icon: '' },
|
||||
{ value: 'researcher', label: 'Researcher', icon: '🔍' },
|
||||
{ value: 'writer', label: 'Writer', icon: '✍️' },
|
||||
{ value: 'commenter', label: 'Commenter', icon: '💬' },
|
||||
{ value: 'monitor', label: 'Monitor', icon: '📡' },
|
||||
{ value: 'operator', label: 'Operator', icon: '⚙️' },
|
||||
{ value: 'custom', label: 'Custom', icon: '🧩' }
|
||||
];
|
||||
|
||||
type ClientId = 'claude-code' | 'gemini' | 'cursor' | 'windsurf' | 'vscode' | 'claude-desktop';
|
||||
type AuthMode = 'apikey' | 'oauth';
|
||||
@@ -134,13 +149,17 @@
|
||||
registerError = '';
|
||||
newApiKey = '';
|
||||
try {
|
||||
const capabilities = newArchetype ? { archetype: newArchetype } : undefined;
|
||||
const res = await agentsApi.register({
|
||||
name: newName.trim(),
|
||||
display_name: newDisplayName.trim() || undefined,
|
||||
type: 'ai'
|
||||
type: 'ai',
|
||||
capabilities
|
||||
});
|
||||
newApiKey = res.api_key;
|
||||
await loadAgents();
|
||||
// Load onboarding data for quick start
|
||||
loadOnboardingData(newName.trim());
|
||||
} catch (err: any) {
|
||||
registerError = err.message || 'Failed to register agent';
|
||||
} finally {
|
||||
@@ -148,14 +167,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOnboardingData(agentName: string) {
|
||||
loadingOnboarding = true;
|
||||
try {
|
||||
const [claudeMd, mcpConfig] = await Promise.allSettled([
|
||||
onboarding.claudeMd(agentName, newArchetype || undefined),
|
||||
onboarding.mcpConfig(agentName)
|
||||
]);
|
||||
claudeMdContent = claudeMd.status === 'fulfilled' ? claudeMd.value : '';
|
||||
mcpConfigContent = mcpConfig.status === 'fulfilled' ? (mcpConfig.value as any).config : null;
|
||||
} catch {
|
||||
// Endpoints may not exist yet
|
||||
} finally {
|
||||
loadingOnboarding = false;
|
||||
showQuickStart = true;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadClaudeMd() {
|
||||
const content = claudeMdContent || `# Agent: ${newName}\n\nCLAUDE.md content will be available when the backend endpoint is ready.`;
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'CLAUDE.md';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyMcpConfig() {
|
||||
const config = mcpConfigContent ? JSON.stringify(mcpConfigContent, null, 2) : currentConfig;
|
||||
await copyText(config, 'mcp-config');
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
showRegister = false;
|
||||
newApiKey = '';
|
||||
newName = '';
|
||||
newDisplayName = '';
|
||||
newArchetype = '';
|
||||
registerError = '';
|
||||
selectedClient = 'claude-code';
|
||||
authMode = 'apikey';
|
||||
showQuickStart = false;
|
||||
claudeMdContent = '';
|
||||
mcpConfigContent = null;
|
||||
loadingOnboarding = false;
|
||||
}
|
||||
|
||||
async function copyText(text: string, label: string) {
|
||||
@@ -277,6 +336,56 @@
|
||||
<p class="text-[10px] text-text-secondary mt-1.5">Add to <code class="font-mono">{getConfigFilePath(selectedClient)}</code></p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Start Panel -->
|
||||
{#if showQuickStart}
|
||||
<div class="mb-4 p-4 bg-accent-purple/5 border border-accent-purple/20 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-text-primary font-display mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Quick Start
|
||||
</h4>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={downloadClaudeMd}
|
||||
disabled={loadingOnboarding}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download CLAUDE.md
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={copyMcpConfig}
|
||||
disabled={loadingOnboarding}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{copiedField === 'mcp-config' ? 'Copied!' : 'Copy MCP Config'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ol class="space-y-2 text-xs text-text-secondary">
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">1</span>
|
||||
<span>Save the <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">CLAUDE.md</code> file to your project directory</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">2</span>
|
||||
<span>Add the MCP config to your Claude Code settings (<code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">~/.claude/settings.json</code>)</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">3</span>
|
||||
<span>Start experimenting: <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">/loop 10m "Check SynapBus for work and process it"</code></span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn-primary w-full"
|
||||
onclick={resetForm}
|
||||
@@ -300,6 +409,15 @@
|
||||
<label for="agent-display" class="block text-xs font-medium text-text-secondary mb-1">Display name <span class="text-text-secondary">(optional)</span></label>
|
||||
<input id="agent-display" type="text" class="input" placeholder="e.g. Research Agent" bind:value={newDisplayName} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="agent-archetype" class="block text-xs font-medium text-text-secondary mb-1">Archetype</label>
|
||||
<select id="agent-archetype" class="input" bind:value={newArchetype}>
|
||||
{#each archetypes as arch}
|
||||
<option value={arch.value}>{arch.icon}{arch.icon ? ' ' : ''}{arch.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-[10px] text-text-secondary mt-1">Determines the agent's CLAUDE.md template and default skills.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full" disabled={registering || !newName.trim()}>
|
||||
{registering ? 'Registering...' : 'Register Agent'}
|
||||
</button>
|
||||
|
||||
@@ -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, onboarding } from '$lib/api/client';
|
||||
import TraceViewer from '$lib/components/TraceViewer.svelte';
|
||||
|
||||
let agent = $state<any>(null);
|
||||
@@ -18,6 +18,26 @@
|
||||
let savingName = $state(false);
|
||||
let nameError = $state('');
|
||||
|
||||
// Getting Started
|
||||
let gettingStartedOpen = $state(false);
|
||||
let mcpConfigData = $state<any>(null);
|
||||
let mcpConfigLoading = $state(false);
|
||||
let copiedField = $state('');
|
||||
|
||||
const archetypeLabels: Record<string, { label: string; color: string }> = {
|
||||
researcher: { label: 'Researcher', color: 'bg-accent-blue/20 text-accent-blue' },
|
||||
writer: { label: 'Writer', color: 'bg-accent-green/20 text-accent-green' },
|
||||
commenter: { label: 'Commenter', color: 'bg-accent-yellow/20 text-accent-yellow' },
|
||||
monitor: { label: 'Monitor', color: 'bg-accent-purple/20 text-accent-purple' },
|
||||
operator: { label: 'Operator', color: 'bg-accent-red/20 text-accent-red' },
|
||||
custom: { label: 'Custom', color: 'bg-bg-tertiary text-text-secondary' }
|
||||
};
|
||||
|
||||
// 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 +64,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 +73,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) {
|
||||
@@ -144,6 +178,63 @@
|
||||
savingAccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleGettingStarted() {
|
||||
gettingStartedOpen = !gettingStartedOpen;
|
||||
if (gettingStartedOpen && !mcpConfigData) {
|
||||
mcpConfigLoading = true;
|
||||
try {
|
||||
const res = await onboarding.mcpConfig(agentName);
|
||||
mcpConfigData = res.config;
|
||||
} catch {
|
||||
mcpConfigData = null;
|
||||
} finally {
|
||||
mcpConfigLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAgentClaudeMd() {
|
||||
const archetype = agent?.capabilities?.archetype;
|
||||
try {
|
||||
const content = await onboarding.claudeMd(agentName, archetype);
|
||||
const blob = new Blob([content || `# Agent: ${agentName}\n\nCLAUDE.md content will be available when the backend endpoint is ready.`], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'CLAUDE.md';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Endpoint not available yet
|
||||
const blob = new Blob([`# Agent: ${agentName}\n\nCLAUDE.md content will be available when the backend endpoint is ready.`], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'CLAUDE.md';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copiedField = label;
|
||||
setTimeout(() => (copiedField = ''), 2000);
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMcpConfig() {
|
||||
const config = mcpConfigData ? JSON.stringify(mcpConfigData, null, 2) : '{}';
|
||||
await copyText(config, 'mcp-config');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
@@ -265,6 +356,95 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Getting Started -->
|
||||
<div class="card mb-5">
|
||||
<button
|
||||
class="w-full px-5 py-3 border-b border-border flex items-center justify-between hover:bg-bg-tertiary/30 transition-colors"
|
||||
onclick={toggleGettingStarted}
|
||||
>
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Getting Started
|
||||
{#if agent?.capabilities?.archetype}
|
||||
{@const arch = archetypeLabels[agent.capabilities.archetype]}
|
||||
{#if arch}
|
||||
<span class="badge text-[10px] {arch.color}">{arch.label}</span>
|
||||
{:else}
|
||||
<span class="badge text-[10px] bg-bg-tertiary text-text-secondary">{agent.capabilities.archetype}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</h2>
|
||||
<svg class="w-4 h-4 text-text-secondary transition-transform {gettingStartedOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if gettingStartedOpen}
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={downloadAgentClaudeMd}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download CLAUDE.md
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={copyMcpConfig}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{copiedField === 'mcp-config' ? 'Copied!' : 'Copy MCP Config'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- MCP Config preview -->
|
||||
{#if mcpConfigLoading}
|
||||
<div class="skeleton h-20 rounded"></div>
|
||||
{:else if mcpConfigData}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label class="text-xs font-medium text-text-secondary">MCP Configuration</label>
|
||||
<button
|
||||
class="text-xs text-text-secondary hover:text-text-primary transition-colors"
|
||||
onclick={copyMcpConfig}
|
||||
>
|
||||
{copiedField === 'mcp-config' ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="p-3 bg-bg-primary rounded text-xs font-mono text-text-primary break-all select-all border border-border overflow-x-auto">{JSON.stringify(mcpConfigData, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Start steps -->
|
||||
<div>
|
||||
<h4 class="text-xs font-medium text-text-secondary mb-2">Quick Start</h4>
|
||||
<ol class="space-y-2 text-xs text-text-secondary">
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">1</span>
|
||||
<span>Save the <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">CLAUDE.md</code> file to your project directory</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">2</span>
|
||||
<span>Add the MCP config to your Claude Code settings (<code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">~/.claude/settings.json</code>)</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">3</span>
|
||||
<span>Start experimenting: <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">/loop 10m "Check SynapBus for work and process it"</code></span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Webhook & K8s Management Links -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
@@ -286,6 +466,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">
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
let newName = $state('');
|
||||
let newDescription = $state('');
|
||||
let newIsPrivate = $state(false);
|
||||
let newType = $state('standard');
|
||||
let newWorkflowEnabled = $state(false);
|
||||
let creating = $state(false);
|
||||
let createError = $state('');
|
||||
|
||||
@@ -43,11 +45,22 @@
|
||||
await channelsApi.create({
|
||||
name: newName.trim(),
|
||||
description: newDescription.trim(),
|
||||
is_private: newIsPrivate
|
||||
is_private: newIsPrivate,
|
||||
type: newType
|
||||
});
|
||||
// Enable workflow if requested (separate settings call)
|
||||
if (newWorkflowEnabled) {
|
||||
try {
|
||||
await channelsApi.updateSettings(newName.trim(), { workflow_enabled: true });
|
||||
} catch {
|
||||
// Channel created but workflow toggle failed -- non-fatal
|
||||
}
|
||||
}
|
||||
newName = '';
|
||||
newDescription = '';
|
||||
newIsPrivate = false;
|
||||
newType = 'standard';
|
||||
newWorkflowEnabled = false;
|
||||
showCreate = false;
|
||||
await loadChannels();
|
||||
} catch (err: any) {
|
||||
@@ -81,10 +94,24 @@
|
||||
<div class="space-y-3">
|
||||
<input type="text" class="input" placeholder="Channel name (e.g. research-findings)" bind:value={newName} />
|
||||
<input type="text" class="input" placeholder="Description (optional)" bind:value={newDescription} />
|
||||
<div>
|
||||
<label class="block text-xs text-text-secondary mb-1" for="channel-type">Channel Type</label>
|
||||
<select id="channel-type" bind:value={newType} class="input">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="blackboard">Blackboard</option>
|
||||
<option value="auction">Auction</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
||||
<input type="checkbox" bind:checked={newIsPrivate} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
|
||||
Private channel (invite-only)
|
||||
</label>
|
||||
{#if newType !== 'auction'}
|
||||
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
||||
<input type="checkbox" bind:checked={newWorkflowEnabled} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
|
||||
Enable workflow reactions
|
||||
</label>
|
||||
{/if}
|
||||
<button type="submit" class="btn-primary" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
@@ -121,9 +148,14 @@
|
||||
<p class="text-xs text-text-secondary truncate mt-0.5">{ch.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="badge bg-bg-tertiary text-text-secondary flex-shrink-0 ml-3">
|
||||
{ch.member_count} members
|
||||
</span>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
{#if ch.type && ch.type !== 'standard'}
|
||||
<span class="badge bg-accent-purple/20 text-accent-purple text-[10px]">{ch.type}</span>
|
||||
{/if}
|
||||
<span class="badge bg-bg-tertiary text-text-secondary">
|
||||
{ch.member_count} members
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
let uploading = $state(false);
|
||||
let fileInputEl: HTMLInputElement;
|
||||
|
||||
// Workflow settings state
|
||||
let settingsSaving = $state(false);
|
||||
let settingsError = $state('');
|
||||
|
||||
function triggerFileInput() { fileInputEl?.click(); }
|
||||
|
||||
async function handleFileSelected(e: Event) {
|
||||
@@ -218,6 +222,19 @@
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async function updateSetting(settings: Record<string, any>) {
|
||||
settingsSaving = true;
|
||||
settingsError = '';
|
||||
try {
|
||||
const res = await channelsApi.updateSettings(channelName, settings);
|
||||
channel = res.channel;
|
||||
} catch (err: any) {
|
||||
settingsError = err.message || 'Failed to update settings';
|
||||
} finally {
|
||||
settingsSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
let isMember = $derived(
|
||||
members.some(m => agentList.some(a => a.name === m.agent_name))
|
||||
);
|
||||
@@ -459,6 +476,98 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Workflow Settings -->
|
||||
{#if channel && channel.type !== 'auction'}
|
||||
<div class="border-t border-border pt-3 mt-3">
|
||||
<h4 class="text-xs font-medium text-text-secondary mb-2">Workflow Settings</h4>
|
||||
{#if settingsError}
|
||||
<div class="mb-2 px-2 py-1.5 bg-accent-red/10 rounded text-[11px] text-accent-red">{settingsError}</div>
|
||||
{/if}
|
||||
<div class="space-y-2.5">
|
||||
<label class="flex items-center justify-between text-xs cursor-pointer">
|
||||
<span class="text-text-primary">Workflow enabled</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channel.workflow_enabled}
|
||||
disabled={settingsSaving}
|
||||
onchange={(e) => updateSetting({ workflow_enabled: (e.target as HTMLInputElement).checked })}
|
||||
class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green"
|
||||
/>
|
||||
</label>
|
||||
{#if channel.workflow_enabled}
|
||||
<label class="flex items-center justify-between text-xs cursor-pointer">
|
||||
<span class="text-text-primary">Auto-approve</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channel.auto_approve}
|
||||
disabled={settingsSaving}
|
||||
onchange={(e) => updateSetting({ auto_approve: (e.target as HTMLInputElement).checked })}
|
||||
class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<label class="block text-xs text-text-primary mb-1" for="publish-threshold">
|
||||
Publish threshold
|
||||
<span class="text-text-secondary ml-1">{channel.publish_threshold ?? 0}</span>
|
||||
</label>
|
||||
<input
|
||||
id="publish-threshold"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={channel.publish_threshold ?? 0}
|
||||
disabled={settingsSaving}
|
||||
onchange={(e) => updateSetting({ publish_threshold: parseFloat((e.target as HTMLInputElement).value) })}
|
||||
class="w-full h-1.5 bg-bg-tertiary rounded-lg appearance-none cursor-pointer accent-accent-green"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-text-primary mb-1" for="approve-threshold">
|
||||
Approve threshold
|
||||
<span class="text-text-secondary ml-1">{channel.approve_threshold ?? 0}</span>
|
||||
</label>
|
||||
<input
|
||||
id="approve-threshold"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={channel.approve_threshold ?? 0}
|
||||
disabled={settingsSaving}
|
||||
onchange={(e) => updateSetting({ approve_threshold: parseFloat((e.target as HTMLInputElement).value) })}
|
||||
class="w-full h-1.5 bg-bg-tertiary rounded-lg appearance-none cursor-pointer accent-accent-green"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-text-primary mb-1" for="stalemate-remind">Stalemate remind after</label>
|
||||
<input
|
||||
id="stalemate-remind"
|
||||
type="text"
|
||||
value={channel.stalemate_remind_after || ''}
|
||||
disabled={settingsSaving}
|
||||
placeholder="e.g. 24h, 7d"
|
||||
onchange={(e) => updateSetting({ stalemate_remind_after: (e.target as HTMLInputElement).value })}
|
||||
class="input text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-text-primary mb-1" for="stalemate-escalate">Stalemate escalate after</label>
|
||||
<input
|
||||
id="stalemate-escalate"
|
||||
type="text"
|
||||
value={channel.stalemate_escalate_after || ''}
|
||||
disabled={settingsSaving}
|
||||
placeholder="e.g. 72h, 14d"
|
||||
onchange={(e) => updateSetting({ stalemate_escalate_after: (e.target as HTMLInputElement).value })}
|
||||
class="input text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-border pt-3 mt-3">
|
||||
<h4 class="text-xs font-medium text-text-secondary mb-2">Members ({members.length})</h4>
|
||||
{#if members.length === 0}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { onboarding } from '$lib/api/client';
|
||||
|
||||
let skills = $state<any[]>([]);
|
||||
let loadingData = $state(true);
|
||||
let loadError = $state('');
|
||||
let expandedSkill = $state<string | null>(null);
|
||||
let skillContent = $state<Record<string, string>>({});
|
||||
let loadingContent = $state<Record<string, boolean>>({});
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
loadSkills();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSkills() {
|
||||
loadingData = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await onboarding.skills();
|
||||
skills = res.skills || [];
|
||||
} catch {
|
||||
loadError = 'Skills library is not available yet. The backend endpoint may not be deployed.';
|
||||
skills = [];
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSkillName(name: string): string {
|
||||
return name
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function getDescription(skill: any): string {
|
||||
return skill.description || 'No description available.';
|
||||
}
|
||||
|
||||
async function toggleView(skillName: string) {
|
||||
if (expandedSkill === skillName) {
|
||||
expandedSkill = null;
|
||||
return;
|
||||
}
|
||||
expandedSkill = skillName;
|
||||
if (!skillContent[skillName]) {
|
||||
loadingContent = { ...loadingContent, [skillName]: true };
|
||||
try {
|
||||
const content = await onboarding.skill(skillName);
|
||||
skillContent = { ...skillContent, [skillName]: content };
|
||||
} catch {
|
||||
skillContent = { ...skillContent, [skillName]: 'Failed to load skill content.' };
|
||||
} finally {
|
||||
loadingContent = { ...loadingContent, [skillName]: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSkill(skill: any) {
|
||||
const content = skillContent[skill.name] || `# ${formatSkillName(skill.name)}\n\n${getDescription(skill)}`;
|
||||
const filename = skill.filename || `${skill.name}.md`;
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function downloadWithFetch(skill: any) {
|
||||
// Try to fetch content first if not cached
|
||||
if (!skillContent[skill.name]) {
|
||||
try {
|
||||
const content = await onboarding.skill(skill.name);
|
||||
skillContent = { ...skillContent, [skill.name]: content };
|
||||
} catch {
|
||||
// Use fallback
|
||||
}
|
||||
}
|
||||
downloadSkill(skill);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<div class="mb-5">
|
||||
<h1 class="text-xl font-bold text-text-primary font-display">Skills Library</h1>
|
||||
<p class="text-sm text-text-secondary mt-1">Downloadable workflow skills for your agents</p>
|
||||
</div>
|
||||
|
||||
{#if loadingData}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="card p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="skeleton h-5 w-1/3"></div>
|
||||
<div class="skeleton h-3 w-2/3"></div>
|
||||
<div class="skeleton h-3 w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div class="card p-8 text-center">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
<p class="text-text-secondary text-sm">{loadError}</p>
|
||||
</div>
|
||||
{:else if skills.length === 0}
|
||||
<div class="card p-8 text-center">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
<p class="text-text-secondary text-sm">No skills available yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each skills as skill (skill.name)}
|
||||
<div class="card">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="font-semibold text-sm text-text-primary font-display">{formatSkillName(skill.name)}</h3>
|
||||
</div>
|
||||
<p class="text-xs text-text-secondary mb-3 line-clamp-2">{getDescription(skill)}</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn-primary text-xs flex items-center gap-1.5"
|
||||
onclick={() => downloadWithFetch(skill)}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={() => toggleView(skill.name)}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
{expandedSkill === skill.name ? 'Hide' : 'View'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expandedSkill === skill.name}
|
||||
<div class="border-t border-border p-4">
|
||||
{#if loadingContent[skill.name]}
|
||||
<div class="space-y-2">
|
||||
<div class="skeleton h-3 w-full"></div>
|
||||
<div class="skeleton h-3 w-4/5"></div>
|
||||
<div class="skeleton h-3 w-3/5"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<pre class="text-xs font-mono text-text-primary/80 whitespace-pre-wrap break-words max-h-80 overflow-y-auto">{skillContent[skill.name] || 'No content available.'}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user