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:
2026-07-07 16:07:08 +02:00
parent aa197190cd
commit 3823a82417
12 changed files with 542 additions and 61 deletions

View File

@@ -887,7 +887,7 @@ func (s *Server) ListSkills(ctx context.Context, req gen.ListSkillsRequestObject
rows, err := s.pool.Query(ctx, `
SELECT s.entity_id, s.version, s.name, s.procedure, s.applies_type,
s.action, s.pattern_ids, s.status, s.success_rate,
s.changed_by::text, s.change_reason, s.last_used_at, s.created_at
s.changed_by::text, s.change_reason, s.last_used_at
FROM skills s
WHERE ($1::text IS NULL OR s.status = $1)
AND ($2::text IS NULL OR s.applies_type = $2)
@@ -917,7 +917,7 @@ func (s *Server) ListSkills(ctx context.Context, req gen.ListSkillsRequestObject
}
seen[s.Id.String()] = true
if err := json.Unmarshal(procBytes, &s.Procedure); err != nil {
slog.Warn("phase3: unmarshal skill procedure", "skill", s.Slug, "error", err)
slog.Warn("phase3: unmarshal skill procedure", "skill", s.Name, "error", err)
}
if len(patternIDs) > 0 {
pids := make([]string, len(patternIDs))
@@ -977,7 +977,7 @@ func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject
err = tx.QueryRow(ctx, `
SELECT entity_id, version, name, procedure, applies_type, action,
pattern_ids, status, success_rate, changed_by::text,
change_reason, last_used_at, created_at
change_reason, last_used_at
FROM skills WHERE entity_id = $1 ORDER BY version DESC LIMIT 1`, id).
Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType,
&skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate,
@@ -1022,7 +1022,7 @@ func (s *Server) ListSkillVersions(ctx context.Context, req gen.ListSkillVersion
rows, err := s.pool.Query(ctx, `
SELECT entity_id, version, name, procedure, applies_type, action,
pattern_ids, status, success_rate, changed_by::text,
change_reason, last_used_at, created_at
change_reason, last_used_at
FROM skills WHERE entity_id = $1
ORDER BY version DESC`, id)
if err != nil {
@@ -1739,7 +1739,82 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
// ─── Agent Activity (stub) ─────────────────────────────────────────────
func (s *Server) QueryAgentActivity(ctx context.Context, request gen.QueryAgentActivityRequestObject) (gen.QueryAgentActivityResponseObject, error) {
return nil, errNotImplemented
limit := clampLimit(request.Params.Limit)
from := time.Now().Add(-24 * time.Hour)
if request.Params.From != nil {
from = *request.Params.From
}
to := time.Now()
if request.Params.To != nil {
to = *request.Params.To
}
var agentID *string
if request.Params.AgentId != nil {
a := *request.Params.AgentId
agentID = &a
}
var activityType *string
if request.Params.ActivityType != nil {
a := string(*request.Params.ActivityType)
activityType = &a
}
var entityID *string
if request.Params.EntityId != nil {
a := *request.Params.EntityId
entityID = &a
}
var cursorID *int
if request.Params.Cursor != nil && *request.Params.Cursor != "" {
if id, err := parseIntOrZero(*request.Params.Cursor); err == nil && id > 0 {
cursorID = &id
}
}
rows, err := s.pool.Query(ctx, `
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
entity_id::text, input_summary, output_summary,
duration_ms, token_count, success, correlation_id
FROM agent_activity
WHERE ts >= $1 AND ts <= $2
AND ($3::text IS NULL OR agent_id::text = $3)
AND ($4::text IS NULL OR activity_type = $4)
AND ($5::text IS NULL OR entity_id::text = $5)
AND ($6::bigint IS NULL OR id < $6::bigint)
ORDER BY id DESC
LIMIT $7`,
from, to, agentID, activityType, entityID, cursorID, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.AgentActivity{}
for rows.Next() {
var a gen.AgentActivity
if err := rows.Scan(&a.Id, &a.Ts, &a.AgentId, &a.SessionId,
&a.ActivityType, &a.ToolName, &a.EntityId,
&a.InputSummary, &a.OutputSummary,
&a.DurationMs, &a.TokenCount, &a.Success,
&a.CorrelationId); err != nil {
return nil, err
}
items = append(items, a)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
lastID := fmt.Sprintf("%d", items[len(items)-1].Id)
next = &lastID
}
if items == nil {
items = []gen.AgentActivity{}
}
return gen.QueryAgentActivity200JSONResponse{Items: items, NextCursor: next}, nil
}
// ─── Helpers ───────────────────────────────────────────────────────────
@@ -1750,3 +1825,14 @@ func coalesceStr(s *string, def string) string {
}
return *s
}
func parseIntOrZero(s string) (int, error) {
var n int
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("invalid integer: %q", s)
}
n = n*10 + int(c-'0')
}
return n, nil
}

View File

@@ -26,6 +26,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// contextKey for storing actor identity in request context.
@@ -109,7 +110,13 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE)
// Mount MCP at /mcp (plan R3-10)
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken))
hermesAgentID := uuid.Nil
if cfg.HermesAgentID != "" {
if id, err := uuid.Parse(cfg.HermesAgentID); err == nil {
hermesAgentID = id
}
}
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, hermesAgentID))
return r
}