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_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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user