feat: platform instructions system with global/team/workspace scope

Adds a configurable instruction injection system that prepends rules to
every agent's system prompt. Instructions are stored in the DB and fetched
at workspace startup, supporting three scopes:

- Global: applies to all agents (e.g., "verify with tools before reporting")
- Team: applies to agents in a specific team
- Workspace: applies to a single agent (role-specific rules)

Components:
- Migration 040: platform_instructions table with scope hierarchy
- Go API: CRUD endpoints + resolve endpoint that merges scopes
- Python runtime: fetches instructions at startup via /instructions/resolve
  and prepends them to the system prompt as highest-priority context

Initial global instructions seeded:
1. Verify Before Acting (check issues/PRs/docs first)
2. Verify Output Before Reporting (second signal before reporting done)
3. Tool Usage Requirements (claims must include tool output)
4. No Hallucinated Emergencies (CRITICAL needs proof)
5. Staging-First Workflow (never push to main directly)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rabbitblood 2026-04-22 15:13:47 -07:00
parent 6c618c9c3f
commit d7afd15e59
6 changed files with 333 additions and 1 deletions

View File

@ -0,0 +1,271 @@
package handlers
import (
"log"
"net/http"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
type InstructionsHandler struct{}
func NewInstructionsHandler() *InstructionsHandler {
return &InstructionsHandler{}
}
type Instruction struct {
ID string `json:"id"`
Scope string `json:"scope"`
ScopeTarget *string `json:"scope_target"`
Title string `json:"title"`
Content string `json:"content"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// List returns instructions filtered by scope. Agents call this at startup
// to fetch their full instruction set (global + team + workspace).
//
// GET /instructions?scope=global
// GET /instructions?workspace_id=<uuid> (returns global + team + workspace)
func (h *InstructionsHandler) List(c *gin.Context) {
ctx := c.Request.Context()
scope := c.Query("scope")
workspaceID := c.Query("workspace_id")
var rows_ interface{ Close() error }
var err error
if workspaceID != "" {
// Agent bootstrap: fetch all applicable instructions (global + team + workspace)
// ordered by scope priority (global first) then user priority descending.
var teamSlug *string
db.DB.QueryRowContext(ctx,
`SELECT t.slug FROM teams t
JOIN team_members tm ON tm.team_id = t.id
WHERE tm.workspace_id = $1 LIMIT 1`, workspaceID).Scan(&teamSlug)
query := `SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at
FROM platform_instructions
WHERE enabled = true AND (
scope = 'global'
OR (scope = 'team' AND scope_target = $1)
OR (scope = 'workspace' AND scope_target = $2)
)
ORDER BY CASE scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'workspace' THEN 2 END,
priority DESC`
teamTarget := ""
if teamSlug != nil {
teamTarget = *teamSlug
}
r, qErr := db.DB.QueryContext(ctx, query, teamTarget, workspaceID)
if qErr != nil {
log.Printf("Instructions list error: %v", qErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
rows_ = r
defer r.Close()
instructions := scanInstructions(r)
c.JSON(http.StatusOK, instructions)
return
}
// Admin listing by scope
query := `SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at
FROM platform_instructions WHERE 1=1`
args := []interface{}{}
if scope != "" {
query += ` AND scope = $1`
args = append(args, scope)
}
query += ` ORDER BY scope, priority DESC, created_at`
r, qErr := db.DB.QueryContext(ctx, query, args...)
if qErr != nil {
log.Printf("Instructions list error: %v", qErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
rows_ = r
_ = rows_
defer r.Close()
c.JSON(http.StatusOK, scanInstructions(r))
}
// Create adds a new platform instruction.
// POST /instructions
func (h *InstructionsHandler) Create(c *gin.Context) {
var body struct {
Scope string `json:"scope" binding:"required"`
ScopeTarget *string `json:"scope_target"`
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Priority int `json:"priority"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope, title, and content are required"})
return
}
if body.Scope != "global" && body.Scope != "team" && body.Scope != "workspace" {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be global, team, or workspace"})
return
}
if body.Scope != "global" && (body.ScopeTarget == nil || *body.ScopeTarget == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope_target required for team/workspace scope"})
return
}
var id string
err := db.DB.QueryRowContext(c.Request.Context(),
`INSERT INTO platform_instructions (scope, scope_target, title, content, priority)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
body.Scope, body.ScopeTarget, body.Title, body.Content, body.Priority,
).Scan(&id)
if err != nil {
log.Printf("Instructions create error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "insert failed"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id})
}
// Update modifies an existing instruction.
// PUT /instructions/:id
func (h *InstructionsHandler) Update(c *gin.Context) {
id := c.Param("id")
var body struct {
Title *string `json:"title"`
Content *string `json:"content"`
Priority *int `json:"priority"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
result, err := db.DB.ExecContext(c.Request.Context(),
`UPDATE platform_instructions SET
title = COALESCE($2, title),
content = COALESCE($3, content),
priority = COALESCE($4, priority),
enabled = COALESCE($5, enabled),
updated_at = NOW()
WHERE id = $1`,
id, body.Title, body.Content, body.Priority, body.Enabled,
)
if err != nil {
log.Printf("Instructions update error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
if n, _ := result.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "instruction not found"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
// Delete removes an instruction.
// DELETE /instructions/:id
func (h *InstructionsHandler) Delete(c *gin.Context) {
id := c.Param("id")
result, err := db.DB.ExecContext(c.Request.Context(),
`DELETE FROM platform_instructions WHERE id = $1`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
if n, _ := result.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "instruction not found"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// Resolve returns the merged instruction text for a workspace — all enabled
// instructions across global → team → workspace scope, concatenated in order.
// This is what the Python runtime calls to get the full instruction set.
//
// GET /instructions/resolve?workspace_id=<uuid>
func (h *InstructionsHandler) Resolve(c *gin.Context) {
workspaceID := c.Query("workspace_id")
if workspaceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"})
return
}
ctx := c.Request.Context()
var teamSlug string
db.DB.QueryRowContext(ctx,
`SELECT COALESCE(t.slug, '') FROM teams t
JOIN team_members tm ON tm.team_id = t.id
WHERE tm.workspace_id = $1 LIMIT 1`, workspaceID).Scan(&teamSlug)
rows, err := db.DB.QueryContext(ctx,
`SELECT scope, title, content FROM platform_instructions
WHERE enabled = true AND (
scope = 'global'
OR (scope = 'team' AND scope_target = $1)
OR (scope = 'workspace' AND scope_target = $2)
)
ORDER BY CASE scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'workspace' THEN 2 END,
priority DESC`,
teamSlug, workspaceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
defer rows.Close()
var merged string
currentScope := ""
for rows.Next() {
var scope, title, content string
if err := rows.Scan(&scope, &title, &content); err != nil {
continue
}
if scope != currentScope {
scopeLabel := map[string]string{
"global": "Platform-Wide Rules",
"team": "Team Rules",
"workspace": "Role-Specific Rules",
}[scope]
merged += "\n## " + scopeLabel + "\n\n"
currentScope = scope
}
merged += "### " + title + "\n" + content + "\n\n"
}
c.JSON(http.StatusOK, gin.H{
"workspace_id": workspaceID,
"team_slug": teamSlug,
"instructions": merged,
})
}
func scanInstructions(rows interface {
Next() bool
Scan(dest ...interface{}) error
}) []Instruction {
var instructions []Instruction
for rows.Next() {
var inst Instruction
if err := rows.Scan(&inst.ID, &inst.Scope, &inst.ScopeTarget, &inst.Title,
&inst.Content, &inst.Priority, &inst.Enabled, &inst.CreatedAt, &inst.UpdatedAt); err != nil {
log.Printf("Instructions scan error: %v", err)
continue
}
instructions = append(instructions, inst)
}
if instructions == nil {
instructions = []Instruction{}
}
return instructions
}

View File

@ -364,6 +364,19 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
adminAuth.DELETE("/admin/secrets/:key", sechGlobal.DeleteGlobal)
}
// Platform instructions — configurable rules with global/team/workspace scope.
// Admin endpoints for CRUD; workspace-facing resolve endpoint for agent bootstrap.
{
instrH := handlers.NewInstructionsHandler()
adminInstr := r.Group("", middleware.AdminAuth(db.DB))
adminInstr.GET("/instructions", instrH.List)
adminInstr.POST("/instructions", instrH.Create)
adminInstr.PUT("/instructions/:id", instrH.Update)
adminInstr.DELETE("/instructions/:id", instrH.Delete)
// Resolve endpoint is open to workspace auth (agents call it at startup)
r.GET("/instructions/resolve", instrH.Resolve)
}
// Admin — cross-workspace schedule health monitoring (issue #618).
// Lets cron-audit agents and operators detect silent schedule failures
// across all workspaces without holding individual workspace bearer tokens.

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_platform_instructions_scope;
DROP TABLE IF EXISTS platform_instructions;

View File

@ -0,0 +1,18 @@
-- Platform-level configurable instructions with global/team/workspace scope.
-- Injected into every agent's system prompt at startup and refreshed
-- periodically, so platform operators can enforce rules without editing
-- template files.
CREATE TABLE IF NOT EXISTS platform_instructions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scope TEXT NOT NULL CHECK (scope IN ('global', 'team', 'workspace')),
scope_target TEXT, -- NULL for global, team slug for team, workspace_id for workspace
title TEXT NOT NULL,
content TEXT NOT NULL,
priority INT DEFAULT 0, -- higher = shown first within scope
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_platform_instructions_scope
ON platform_instructions (scope, scope_target) WHERE enabled = true;

View File

@ -294,7 +294,7 @@ class BaseAdapter(ABC):
from plugins import load_plugins
from skill_loader.loader import load_skills
from coordinator import get_children, get_parent_context, build_children_description
from prompt import build_system_prompt, get_peer_capabilities
from prompt import build_system_prompt, get_peer_capabilities, get_platform_instructions
from builtin_tools.approval import request_approval
from builtin_tools.delegation import delegate_to_workspace, check_delegation_status
from builtin_tools.memory import commit_memory, search_memory
@ -344,6 +344,7 @@ class BaseAdapter(ABC):
# Build system prompt with all context
peers = await get_peer_capabilities(platform_url, config.workspace_id)
platform_instructions = await get_platform_instructions(platform_url, config.workspace_id)
coordinator_prompt = build_children_description(children) if is_coordinator else ""
extra_prompts = list(plugins.prompt_fragments)
if coordinator_prompt:
@ -355,6 +356,7 @@ class BaseAdapter(ABC):
plugin_rules=plugins.rules,
plugin_prompts=extra_prompts,
parent_context=parent_context,
platform_instructions=platform_instructions,
)
return SetupResult(

View File

@ -1,5 +1,6 @@
"""Build the system prompt for the workspace agent."""
import os
from pathlib import Path
from skill_loader.loader import LoadedSkill
@ -25,6 +26,24 @@ async def get_peer_capabilities(platform_url: str, workspace_id: str) -> list[di
return []
async def get_platform_instructions(platform_url: str, workspace_id: str) -> str:
"""Fetch resolved platform instructions (global + team + workspace scope)."""
try:
import httpx
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{platform_url}/instructions/resolve",
params={"workspace_id": workspace_id},
)
if resp.status_code == 200:
data = resp.json()
return data.get("instructions", "")
except Exception as e:
print(f"Warning: could not fetch platform instructions: {e}")
return ""
def build_system_prompt(
config_path: str,
workspace_id: str,
@ -34,6 +53,7 @@ def build_system_prompt(
plugin_rules: list[str] | None = None,
plugin_prompts: list[str] | None = None,
parent_context: list[dict] | None = None,
platform_instructions: str = "",
) -> str:
"""Build the complete system prompt.
@ -50,6 +70,12 @@ def build_system_prompt(
"""
parts = []
# Platform instructions (global → team → workspace scope) go first so
# they take highest precedence in the context window.
if platform_instructions:
parts.append("# Platform Instructions\n")
parts.append(platform_instructions)
# Load prompt files in order
files_to_load = list(prompt_files or [])
if not files_to_load: