feat(tasks): make research-first / knowledge-write-back-last explicit steps

Closes the gap that made the knowledge loop optional/implicit: every
non-trivial task now has an EXPLICIT first plan step (research) and last
plan step (write back), not just background behavior the model might skip.

New MCP tools (the agent had no way to do these before — only REST endpoints
existed, unexposed to it):
- update_entity_attributes(slug, attributes): shallow-merge new/changed facts
  into an entity (an IP, a version, a discovered port) so a future task
  doesn't have to rediscover them from scratch. No approval required — this
  updates the knowledge graph, not live infra.
- create_relationship(source, target, type): record a discovered edge
  (depends-on, hosts, provides, ...). Idempotent, FK-validated against the
  ontology's relationship_types, no approval required.

SOUL.md: restructured the task loop so step 1 is explicitly "gather
knowledge, not just status" (get_entity_knowledge, search_knowledge,
get_relations, get_blast_radius, http_get) and the last step before
complete_task is explicitly "write back" (update_entity_attributes,
create_relationship, upsert_knowledge) — both called out as real plan
entries the operator should see in propose_plan, not silent side-work. This
is what prevents the graph drifting from reality and is the concrete
mechanism behind "tasks compound."

propose_plan's tool description reinforces the same first-step/last-step
convention at the call site.

Verified against the live stack: both tools registered and callable via MCP;
update_entity_attributes merged an attribute correctly; create_relationship
rejected an invalid type (FK violation, clear error) and succeeded with a
valid type+direction, confirmed idempotent (2 calls, 1 row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 14:14:02 +02:00
parent 5384499903
commit e30813a43d
3 changed files with 112 additions and 20 deletions

View File

@@ -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 " +

View File

@@ -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) {

View File

@@ -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