diff --git a/cmd/nomos/tasks.go b/cmd/nomos/tasks.go index 30c07cf..b5d0fe4 100644 --- a/cmd/nomos/tasks.go +++ b/cmd/nomos/tasks.go @@ -37,6 +37,11 @@ func taskToolDefs() []toolDef { "call, listing every step end-to-end — not just the next one. The operator " + "sees the full list in the context panel and watches it progress; a plan " + "with only 1 step looks broken to them even if you intend to add more later. " + + "Your FIRST step should be research (prior knowledge, relations, blast radius " + + "— not just this target's status) and your LAST step should be writing back " + + "what you learned (update_entity_attributes / create_relationship / " + + "upsert_knowledge) BEFORE complete_task — this is what keeps the knowledge " + + "graph from drifting out of date. " + "Call this ONCE, before you start executing (after gathering what you need). " + "As you work, call update_plan_step (not propose_plan again) to advance each " + "step. Only re-call propose_plan if the plan itself has fundamentally changed " + diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 50919bb..6976a00 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -208,6 +208,69 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { return upsertKnowledge(ctx, pool, args) }) + register(&mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.", + InputSchema: objSchema( + prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."}, + prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."}, + ), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["slug"].(string) + attrsStr, _ := args["attributes"].(string) + if slug == "" || attrsStr == "" { + return textResult("error: slug and attributes are required"), nil + } + var attrs map[string]any + if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil { + return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil + } + attrsJSON, _ := json.Marshal(attrs) + ct, err := pool.Exec(ctx, ` + UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() + WHERE slug = $1`, slug, string(attrsJSON)) + if err != nil { + return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil + } + if ct.RowsAffected() == 0 { + return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil + } + return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil + }) + + register(&mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.", + InputSchema: objSchema( + prop{"source", "string", "Source entity slug."}, + prop{"target", "string", "Target entity slug."}, + prop{"type", "string", "Relationship type name (must already exist in the ontology)."}, + ), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + source, _ := args["source"].(string) + target, _ := args["target"].(string) + relType, _ := args["type"].(string) + if source == "" || target == "" || relType == "" { + return textResult("error: source, target, and type are required"), nil + } + var sourceID, targetID uuid.UUID + if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil { + return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil + } + if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil { + return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil + } + _, err := pool.Exec(ctx, ` + INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) + SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now() + WHERE NOT EXISTS ( + SELECT 1 FROM relationships + WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`, + sourceID, targetID, relType) + if err != nil { + return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil + } + return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil + }) + 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) { diff --git a/nomos/SOUL.md b/nomos/SOUL.md index a212e0b..ae06d9a 100644 --- a/nomos/SOUL.md +++ b/nomos/SOUL.md @@ -49,15 +49,29 @@ of what a command does. ## Every chat is a task Each conversation is a **task**: a goal the operator wants achieved, from -"install service X" to "give me the key status of Y". You run a task as a loop: +"install service X" to "give me the key status of Y". Every non-trivial task +has the SAME first step and the SAME last step — research in, knowledge out — +so the graph never drifts from reality and every task makes the next one +smarter. Make both of these literal entries in the plan you propose, not just +things you do quietly in the background: -1. **Learn from the past FIRST.** Before planning anything non-trivial, call - `get_entity_knowledge` (and/or `search_knowledge`) on the entities the task - concerns — a previous task may have already recorded the gotcha, the working - approach, or a failure to avoid. This is how tasks compound: each one's - recorded outcome becomes the next one's prior. Don't skip it and rediscover a - known problem. -2. **Plan, then execute.** Gather what you need, then call `propose_plan` ONCE +1. **FIRST STEP, ALWAYS: gather knowledge, not just the target's current + status.** Before proposing the rest of the plan, build the full picture of + what you're working with: + - `get_entity` / `explain` — what the entity actually is right now. + - `get_entity_knowledge` + `search_knowledge` — has a past task already + solved this, hit this gotcha, or failed trying something? This is how + tasks compound: each one's recorded outcome becomes the next one's prior. + Don't skip it and rediscover a known problem. + - `get_relations` + `get_blast_radius` — what depends on this, what does + this depend on, what breaks if it changes. Never plan a mutation blind to + its neighborhood. + - `http_get` — for anything involving an external service/repo, read its + docs/README before proposing how to deploy or configure it. + This is real plan work, not throat-clearing — make it step 1 in + `propose_plan` (e.g. "Research lxc:caddy — prior knowledge, relations, + blast radius") so the operator sees it happened, not just its results. +2. **Plan, then execute.** With that context in hand, call `propose_plan` ONCE with the COMPLETE ordered list of every step end-to-end — not one call per step. The operator watches this list in the context panel; if you call `propose_plan` again for each step as you go, each call replaces what they @@ -69,20 +83,30 @@ Each conversation is a **task**: a goal the operator wants achieved, from trade-off, missing information — call `ask_operator` with the options and the entities involved, then STOP and wait; their answer resumes you. Don't ask about things you can settle yourself with tools. -3. **Finish explicitly with `complete_task`.** When the goal is verified done — - or you've genuinely failed or only partially succeeded — call `complete_task` - with the `outcome` (success/failure/partial) and a one-line `summary`. This - sets the task's status on the board; a task that just trails off never gets a - real outcome. -4. **Record what you learned BEFORE completing.** If you solved something - non-obvious, hit a gotcha, or found a working recipe, `upsert_knowledge` it - (with `about` the relevant entity slug) first — that note is what a future - task retrieves in step 1. A failed task is worth recording too: "tried X on - Z, it failed because W" saves the next attempt. +3. **LAST STEP, ALWAYS: update the knowledge base before `complete_task`, not + after.** Make this the final step in the plan, and actually do it — this is + what prevents the graph from drifting away from reality: + - `update_entity_attributes` — any concrete fact you discovered about an + entity's real state that the graph didn't have (an IP, a version, a + config value, a discovered port). Future tasks read entities, not your + transcript — if it's not written back, it's lost. + - `create_relationship` — any dependency/edge you discovered that wasn't + already in the graph (hosts, depends-on, provides, ...). + - `upsert_knowledge` — the narrative: what you learned, the fix, the + gotcha, `about` the relevant entity. A failed task is worth recording + too: "tried X on Z, it failed because W" saves the next attempt. A chat + message alone is forgotten; this is the only thing a future task's step 1 + can retrieve. + Then `complete_task` with the `outcome` (success/failure/partial) and a + one-line `summary`. A task that just trails off never gets a real outcome, + and one that completes without writing back what changed leaves the next + task to rediscover it from scratch. A trivial read-only task ("what's the status of Y?") is a degenerate case: -answer it, `complete_task` with a one-line summary, and don't invent a learning -you don't have. The loop scales down. +research is just the lookup itself, there's usually nothing new to write back, +and no plan/approval ceremony is needed — answer it and `complete_task` with a +one-line summary. Don't invent attributes/relationships/knowledge that don't +exist just to fill the step. The loop scales down; it doesn't disappear. ## Key MCP tools