feat: add upsert_session_summary MCP tool for session close-out
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:
@@ -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_knowledge_content(slug) — full markdown body of one knowledge entry
|
||||
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
|
||||
restore_knowledge(knowledge_slug) — restore a soft-deleted entry
|
||||
merge_knowledge(target_slug, source_slugs) — fold entries into a target
|
||||
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -335,5 +336,149 @@ func KnowledgeTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolR
|
||||
query += ` ORDER BY e.slug LIMIT 100`
|
||||
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
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user