phase 4: hermes agent — MCP tools, activity logging, 'all' role, wiring fixes
- mcp/server.go: 7 new tools (get_signal_history, get_patterns, get_skills, request_execution, get_trend, get_event_timeline, get_agent_activity), agent_activity logging middleware on every tool call. - phase3.go: QueryAgentActivity REST handler implemented (was stub). Fixed scan count mismatch in ListSkills/PatchSkill/ListSkillVersions (13 cols → 12 targets). Fixed AgentActivity cursor pagination (lexicographic → integer comparison). Fixed s.Slug → s.Name in log. - cmd/oikos/main.go: 'all' role now runs api + scheduler + notifier in one process. Replaced nil SchedulerRunner/NotifierRunner with direct scheduler.RunnerForMain() / notifier.RunnerForMain() imports. Added runWithPool helper for standalone scheduler/notifier roles. - internal/config/config.go: added HermesAgentID env var. - internal/httpapi/server.go: pass HermesAgentID to MCP handler. - docker-compose.yml: added scheduler and notifier services (dev profile). - hermes/: config.yaml, SOUL.md, skills/homelab-ops/SKILL.md. - Cleaned up: scheduler/init.go dead code, mcp/server.go pgx import guard.
This commit is contained in:
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
@@ -34,8 +33,9 @@ func objSchema(props ...prop) *jsonschema.Schema {
|
||||
}
|
||||
|
||||
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
||||
func NewHandler(pool *db.Pool, token string) http.Handler {
|
||||
s := newServer(pool)
|
||||
// agentID is the Hermes agent entity UUID; tool calls are logged to agent_activity.
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID) http.Handler {
|
||||
s := newServer(pool, agentID)
|
||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||
if token != "" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||
@@ -47,15 +47,19 @@ func NewHandler(pool *db.Pool, token string) http.Handler {
|
||||
return handler
|
||||
}
|
||||
|
||||
func newServer(pool *db.Pool) *mcp.Server {
|
||||
// toolHandler is the function signature registered via AddTool.
|
||||
type toolHandler = mcp.ToolHandler
|
||||
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
|
||||
// All tools use the untyped handler (s.AddTool) for simplicity.
|
||||
// Arguments are accessed via req.Parameters.Arguments.(map[string]any).
|
||||
register := func(tool *mcp.Tool, handler toolHandler) {
|
||||
s.AddTool(tool, withActivityLogging(pool, agentID, tool.Name, handler))
|
||||
}
|
||||
|
||||
s.AddTool(&mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
|
||||
register(&mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
|
||||
InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
@@ -63,7 +67,7 @@ func newServer(pool *db.Pool) *mcp.Server {
|
||||
return queryEntity(ctx, pool, idOrSlug), nil
|
||||
})
|
||||
|
||||
s.AddTool(&mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
|
||||
register(&mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
|
||||
InputSchema: objSchema(
|
||||
prop{"type", "string", "Filter by entity type"},
|
||||
prop{"state", "string", "Filter by lifecycle state"},
|
||||
@@ -82,7 +86,7 @@ func newServer(pool *db.Pool) *mcp.Server {
|
||||
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil
|
||||
})
|
||||
|
||||
s.AddTool(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
|
||||
register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
|
||||
InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
@@ -96,7 +100,7 @@ func newServer(pool *db.Pool) *mcp.Server {
|
||||
ORDER BY r.type`, slug), nil
|
||||
})
|
||||
|
||||
s.AddTool(&mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
|
||||
register(&mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_id", "string", "Entity slug"},
|
||||
prop{"depth", "integer", "Traversal depth (default 3)"}),
|
||||
@@ -109,7 +113,7 @@ func newServer(pool *db.Pool) *mcp.Server {
|
||||
slug, depth), nil
|
||||
})
|
||||
|
||||
s.AddTool(&mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
|
||||
register(&mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
|
||||
InputSchema: objSchema(),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return queryRows(ctx, pool, `
|
||||
@@ -118,7 +122,7 @@ func newServer(pool *db.Pool) *mcp.Server {
|
||||
ORDER BY e.slug`), nil
|
||||
})
|
||||
|
||||
s.AddTool(&mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
|
||||
register(&mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
|
||||
InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
@@ -129,19 +133,19 @@ func newServer(pool *db.Pool) *mcp.Server {
|
||||
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
||||
})
|
||||
|
||||
s.AddTool(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation",
|
||||
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation",
|
||||
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
q, _ := args["query"].(string)
|
||||
q := nStr(args["query"])
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT id, title, LEFT(content, 500) AS preview
|
||||
FROM knowledge_entities
|
||||
WHERE title ILIKE '%'||$1||'%' OR content ILIKE '%'||$1||'%'
|
||||
WHERE ($1::text IS NULL OR title ILIKE '%'||$1||'%' OR content ILIKE '%'||$1||'%')
|
||||
ORDER BY title LIMIT 20`, q), nil
|
||||
})
|
||||
|
||||
s.AddTool(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
|
||||
register(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
|
||||
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
@@ -158,9 +162,214 @@ func newServer(pool *db.Pool) *mcp.Server {
|
||||
ORDER BY bucket DESC LIMIT 100`, hours), nil
|
||||
})
|
||||
|
||||
// ─── Phase 4: new tools ──────────────────────────────────────────
|
||||
|
||||
register(&mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_slug", "string", "Filter by target entity slug"},
|
||||
prop{"state", "string", "Filter by signal state (raised, resolved)"},
|
||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 50))
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT s.entity_id::text, s.kind, s.severity, s.state,
|
||||
s.occurrence_count, e.slug AS target_slug,
|
||||
s.first_seen_at, s.last_seen_at
|
||||
FROM signals s
|
||||
LEFT JOIN entities e ON e.id = s.target_entity_id
|
||||
WHERE ($1::text IS NULL OR e.slug = $1)
|
||||
AND ($2::text IS NULL OR s.state = $2)
|
||||
ORDER BY s.last_seen_at DESC LIMIT $3`,
|
||||
nStr(args["entity_slug"]), nStr(args["state"]), limit), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "get_patterns", Description: "List learned action patterns",
|
||||
InputSchema: objSchema(
|
||||
prop{"status", "string", "Filter by status (hypothesized, validated, active)"},
|
||||
prop{"entity_type", "string", "Filter by applies_type"},
|
||||
prop{"action", "string", "Filter by action"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT p.entity_id::text, p.applies_type, p.action, p.pattern,
|
||||
p.confidence, p.evidence_count, p.success_count, p.failure_count,
|
||||
p.status, p.quarantined, p.version, p.last_validated_at
|
||||
FROM patterns p
|
||||
WHERE ($1::text IS NULL OR p.status = $1)
|
||||
AND ($2::text IS NULL OR p.applies_type = $2)
|
||||
AND ($3::text IS NULL OR p.action = $3)
|
||||
ORDER BY p.applies_type, p.action`,
|
||||
nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "get_skills", Description: "List available automation skills",
|
||||
InputSchema: objSchema(
|
||||
prop{"status", "string", "Filter by status (active, inactive, deprecated)"},
|
||||
),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview,
|
||||
s.applies_type, s.action, s.status, s.success_rate,
|
||||
s.changed_by::text, s.change_reason, s.last_used_at
|
||||
FROM skills s
|
||||
WHERE ($1::text IS NULL OR s.status = $1)
|
||||
ORDER BY s.name, s.version DESC`,
|
||||
nStr(args["status"])), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (Hermes-only mutation path)",
|
||||
InputSchema: objSchema(
|
||||
prop{"target", "string", "Target entity slug"},
|
||||
prop{"action", "string", "Action to perform"},
|
||||
),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
targetSlug, _ := args["target"].(string)
|
||||
action, _ := args["action"].(string)
|
||||
if targetSlug == "" || action == "" {
|
||||
return textResult("error: target and action are required"), nil
|
||||
}
|
||||
|
||||
var targetID uuid.UUID
|
||||
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
|
||||
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("uuid error: %v", err)), nil
|
||||
}
|
||||
correlationID := uuid.New().String()
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO executions (entity_id, target_entity_id, action, risk_class,
|
||||
status, correlation_id, agent_id)
|
||||
VALUES ($1, $2, $3, 'unclassified', 'pending', $4, $5)`,
|
||||
id, targetID, action, correlationID, agentID)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("insert execution: %v", err)), nil
|
||||
}
|
||||
|
||||
return textResult(fmt.Sprintf("execution requested: id=%s target=%s action=%s correlation=%s",
|
||||
id, targetSlug, action, correlationID)), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_id", "string", "Entity slug"},
|
||||
prop{"days", "integer", "Look-back window in days (default 7)"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["entity_id"].(string)
|
||||
days := int(getFloat(args, "days", 7))
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT metric,
|
||||
ROUND(avg(value)::numeric, 2) AS avg_val,
|
||||
ROUND(stddev(value)::numeric, 2) AS std_val,
|
||||
count(*) AS sample_count,
|
||||
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
|
||||
FROM metric_samples ms
|
||||
JOIN entities e ON e.id = ms.entity_id
|
||||
WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2)
|
||||
GROUP BY metric
|
||||
ORDER BY metric`, slug, days), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "get_event_timeline", Description: "Get recent events",
|
||||
InputSchema: objSchema(
|
||||
prop{"severity", "string", "Filter by severity (info, warn, error)"},
|
||||
prop{"entity_slug", "string", "Filter by entity slug"},
|
||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 50))
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT ev.ts, ev.event_type, ev.severity, ev.actor, e.slug AS entity_slug,
|
||||
ev.message, ev.correlation_id
|
||||
FROM events ev
|
||||
LEFT JOIN entities e ON e.id = ev.entity_id
|
||||
WHERE ($1::text IS NULL OR ev.severity = $1)
|
||||
AND ($2::text IS NULL OR e.slug = $2)
|
||||
ORDER BY ev.ts DESC LIMIT $3`,
|
||||
nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log",
|
||||
InputSchema: objSchema(
|
||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 50))
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
|
||||
entity_id::text, left(input_summary, 200) AS input_summary,
|
||||
left(output_summary, 200) AS output_summary,
|
||||
duration_ms, token_count, success, correlation_id
|
||||
FROM agent_activity
|
||||
WHERE agent_id = $1
|
||||
ORDER BY ts DESC LIMIT $2`, agentID, limit), nil
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// withActivityLogging wraps a tool handler to record agent_activity rows.
|
||||
func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next mcp.ToolHandler) mcp.ToolHandler {
|
||||
if agentID == uuid.Nil {
|
||||
return next
|
||||
}
|
||||
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
start := time.Now()
|
||||
result, err := next(ctx, req)
|
||||
duration := int(time.Since(start).Milliseconds())
|
||||
|
||||
// Build input summary (first 500 chars of args)
|
||||
inputSummary := ""
|
||||
if req != nil && len(req.Params.Arguments) > 0 {
|
||||
inputSummary = string(req.Params.Arguments)
|
||||
}
|
||||
if len(inputSummary) > 500 {
|
||||
inputSummary = inputSummary[:500]
|
||||
}
|
||||
|
||||
// Build output summary
|
||||
outputSummary := ""
|
||||
success := err == nil
|
||||
if result != nil {
|
||||
for _, c := range result.Content {
|
||||
if tc, ok := c.(*mcp.TextContent); ok {
|
||||
outputSummary = tc.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
outputSummary = err.Error()
|
||||
success = false
|
||||
}
|
||||
if len(outputSummary) > 500 {
|
||||
outputSummary = outputSummary[:500]
|
||||
}
|
||||
|
||||
correlationID := uuid.New().String()
|
||||
|
||||
_, logErr := pool.Exec(ctx, `
|
||||
INSERT INTO agent_activity
|
||||
(agent_id, activity_type, tool_name, input_summary, output_summary,
|
||||
duration_ms, success, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
agentID, "tool_call", toolName, inputSummary, outputSummary,
|
||||
duration, success, correlationID)
|
||||
if logErr != nil {
|
||||
slog.Warn("mcp: log agent_activity", "error", logErr)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
func argsMap(req *mcp.CallToolRequest) map[string]any {
|
||||
@@ -181,6 +390,12 @@ func getFloat(m map[string]any, key string, def float64) float64 {
|
||||
return v
|
||||
case int:
|
||||
return float64(v)
|
||||
case json.Number:
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return f
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -244,7 +459,4 @@ func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *m
|
||||
|
||||
data, _ := json.MarshalIndent(items, "", " ")
|
||||
return textResult(string(data))
|
||||
}
|
||||
|
||||
var _ = pgx.ErrNoRows
|
||||
var _ = time.Now
|
||||
}
|
||||
Reference in New Issue
Block a user