feat: add upsert_session_summary MCP tool for session close-out
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

New tool batches session discoveries into the knowledge graph:
- Creates a session-audit knowledge entry with summary
- Links it to all touched entities via 'documents' relationships
- Creates individual discovery knowledge entries
- Stamps each entity with last_agent_session attribute
- Updates AGENTS.md with tool listing
This commit is contained in:
hermes
2026-08-15 18:00:50 +02:00
parent 9ec05e2e3f
commit e7cc57a929
2 changed files with 146 additions and 0 deletions

View File

@@ -112,6 +112,7 @@ elsewhere; regenerate from `internal/mcp/` when tools change):
get_entity_knowledge(entity_slug) — all docs/investigations/runbooks linked to a slug get_entity_knowledge(entity_slug) — all docs/investigations/runbooks linked to a slug
get_knowledge_content(slug) — full markdown body of one knowledge entry get_knowledge_content(slug) — full markdown body of one knowledge entry
upsert_knowledge(title, content, about, tags, kind) — write what you learned upsert_knowledge(title, content, about, tags, kind) — write what you learned
upsert_session_summary(session_id, summary, entities_touched, discoveries) — batch-write session findings into the graph; creates knowledge entries, links entities, records a session-audit entry
delete_knowledge(knowledge_slug) — soft-delete a knowledge entry delete_knowledge(knowledge_slug) — soft-delete a knowledge entry
restore_knowledge(knowledge_slug) — restore a soft-deleted entry restore_knowledge(knowledge_slug) — restore a soft-deleted entry
merge_knowledge(target_slug, source_slugs) — fold entries into a target merge_knowledge(target_slug, source_slugs) — fold entries into a target

View File

@@ -2,6 +2,7 @@ package mcp
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
@@ -335,5 +336,149 @@ func KnowledgeTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolR
query += ` ORDER BY e.slug LIMIT 100` query += ` ORDER BY e.slug LIMIT 100`
return queryRows(ctx, pool, query, dbArgs...), nil return queryRows(ctx, pool, query, dbArgs...), nil
}}, }},
// upsert_session_summary persists what was learned in a session
// back into the knowledge graph. Call this at the end of a
// session to close the loop: knowledge entries are created,
// entity attributes are updated, and the graph stays current.
{tool: &mcp.Tool{Name: "upsert_session_summary", Description: "Batch-write session discoveries into the knowledge graph. Call at session end to persist everything learned: creates knowledge entries for each discovery, links them to the entities they affect, and records a session-audit knowledge entry tagged with the session_id. This is how the system gets smarter over time — without it, everything discovered in a session is lost when the conversation ends.",
InputSchema: objSchema(
prop{"session_id", "string", "Unique session identifier (e.g. chat session UUID, cron job ID). Used to tag and group knowledge entries from this session."},
prop{"summary", "string", "Free-text summary of what was accomplished in this session. Written as a readable paragraph for future reference."},
prop{"entities_touched", "string", "JSON array of entity slugs that were modified or inspected (e.g. [\"lxc:arriman\", \"service:sabnzbd\"]). Each gets linked to the session-audit knowledge entry."},
prop{"discoveries", "string", "Optional JSON array of discovery objects. Each object: {title, content, kind (document|investigation|runbook), tags (comma-separated)}. Each creates a separate searchable knowledge entry linked to the relevant entities."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
sessionID, _ := args["session_id"].(string)
summary, _ := args["summary"].(string)
entitiesRaw, _ := args["entities_touched"].(string)
discoveriesRaw, _ := args["discoveries"].(string)
if sessionID == "" || summary == "" {
return textResult("error: session_id and summary are required"), nil
}
var entitySlugs []string
if entitiesRaw != "" {
if err := json.Unmarshal([]byte(entitiesRaw), &entitySlugs); err != nil {
return textResult(fmt.Sprintf("error: entities_touched is not valid JSON array: %v", err)), nil
}
}
var discoveries []struct {
Title string `json:"title"`
Content string `json:"content"`
Kind string `json:"kind"`
Tags string `json:"tags"`
}
if discoveriesRaw != "" {
if err := json.Unmarshal([]byte(discoveriesRaw), &discoveries); err != nil {
return textResult(fmt.Sprintf("error: discoveries is not valid JSON: %v", err)), nil
}
}
created := 0
linked := 0
updated := 0
// Create the session-audit knowledge entry
auditTitle := fmt.Sprintf("Session audit: %s", sessionID)
auditSlug := fmt.Sprintf("session-audit/%s", sessionID)
tags := []string{"audit", "session"}
auditDocID, _ := uuid.NewV7()
err := pool.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, attributes)
VALUES ($1, $2, 'investigation', $3, '{}')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
RETURNING id`, auditDocID, auditSlug, auditTitle).Scan(&auditDocID)
if err != nil {
return textResult(fmt.Sprintf("error creating audit entity: %v", err)), nil
}
_, err = pool.Exec(ctx, `
INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at)
VALUES ($1, $2, $3, 'nomos-agent', $4, now())
ON CONFLICT (entity_id) DO UPDATE
SET title = EXCLUDED.title, content = EXCLUDED.content,
tags = EXCLUDED.tags, updated_at = now()`,
auditDocID, auditTitle, summary, tags)
if err != nil {
return textResult(fmt.Sprintf("error writing audit knowledge: %v", err)), nil
}
created++
// Link the audit entry to each touched entity
for _, slug := range entitySlugs {
var targetID uuid.UUID
if qerr := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&targetID); qerr == nil {
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`,
auditDocID, targetID)
linked++
}
}
// Create discovery knowledge entries
for _, d := range discoveries {
kind := d.Kind
switch kind {
case "document", "investigation", "runbook":
case "":
kind = "investigation"
default:
continue
}
discTitle := strings.TrimSpace(d.Title)
discContent := strings.TrimSpace(d.Content)
if discTitle == "" || discContent == "" {
continue
}
slug := fmt.Sprintf("discovery/%s/%s", sessionID, strings.ReplaceAll(strings.ToLower(discTitle), " ", "-"))
discID, _ := uuid.NewV7()
err := pool.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, attributes)
VALUES ($1, $2, $3, $4, '{}')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
RETURNING id`, discID, slug, kind, discTitle).Scan(&discID)
if err != nil {
continue
}
var discTags []string
for _, t := range strings.Split(d.Tags, ",") {
if t = strings.TrimSpace(t); t != "" {
discTags = append(discTags, t)
}
}
discTags = append(discTags, "discovery", "session")
_, err = pool.Exec(ctx, `
INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at)
VALUES ($1, $2, $3, 'nomos-agent', $4, now())
ON CONFLICT (entity_id) DO UPDATE
SET title = EXCLUDED.title, content = EXCLUDED.content,
tags = EXCLUDED.tags, updated_at = now()`,
discID, discTitle, discContent, discTags)
if err != nil {
continue
}
created++
}
// Update attributes on each touched entity
for _, slug := range entitySlugs {
pool.Exec(ctx, `
UPDATE entities
SET attributes = jsonb_set(COALESCE(attributes, '{}'), '{last_agent_session}', to_jsonb($2::text), true),
updated_at = now()
WHERE slug = $1`, slug, sessionID)
updated++
}
return textResult(fmt.Sprintf(
"Session summary saved. Created %d knowledge entries, linked to %d entities, updated %d entity attributes.",
created, linked, updated)), nil
}},
} }
} }