feat(agent): close knowledge loop — refuse complete_task without writeback (D.1+D.2)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

D.1 — complete_task structural gate:
- hadDiscovery(ctx, session) reports whether the session ran `run` successfully
  against a live target (NOT get_entity/list_lxcs — those are DB lookups, not
  new facts). A trivial Q&A that only calls get_entity is a degenerate case
  and must NOT be blocked.
- complete_task with outcome=success is REFUSED when hadDiscovery && !
  hadEntityWriteback. The refusal fires BEFORE completeTask runs, so the
  session stays in 'executing' state and the agent must call
  update_entity_attributes/create_relationship then retry complete_task.
  An explicit failure/partial is allowed through (the agent is acknowledging
  it didn't finish — no reason to force writeback).
- Replaces the prior advisory warning (5.5) which the agent consistently
  ignored. The agent saw the warning and ended the task anyway; this gate
  makes the writeback a hard prerequisite for success.

D.2 — propose_plan auto-append writeback step:
- When the agent proposes a plan whose steps don't mention
  update_entity_attributes or create_relationship, D.2 appends a final
  'Write back: update_entity_attributes + create_relationship +
  upsert_knowledge' step before persisting. The result string tells the
  agent it was appended.
- With the seq-order enforcement (5.6) and D.1's complete_task gate, the
  agent must complete the writeback step (and actually call the tools) to
  finish. Neither relies on the agent reading SOUL.md.
- Removed the old advisory writeback nudge from propose_plan's result
  string — D.2 makes it structural.
- Updated the propose_plan tool description to state both gates crisply.

Verification:
- TestHadDiscoveryAndWriteback: hadDiscovery true only after a successful
  `run`; false after failed run, get_entity, or no calls. hadEntityWriteback
  true only after update_entity_attributes/create_relationship.
- e2e against the live agent (oikos-nomos-1, v0.5.1):
  - D.2: agent proposed 3 steps (no writeback); D.2 auto-appended step 4
    'Write back: update_entity_attributes + ...'. Result string said
    '(appended a writeback step — your plan didn't include one; step 4)'.
  - D.1: agent ran `run` (uptime on lxc:gitea), called complete_task, was
    REFUSED ('Refused: this session ran run against live targets (discovery)
    but did not call update_entity_attributes...'). Agent self-corrected:
    called update_entity_attributes, retried complete_task, succeeded.
    Knowledge loop closed end-to-end.

Version 0.5.0 -> 0.5.1 (patch: structural enforcement of existing intent).
This commit is contained in:
2026-07-14 20:40:49 +02:00
parent c5bee740ad
commit 3de359b85f
4 changed files with 151 additions and 18 deletions

View File

@@ -726,6 +726,31 @@ func (s *store) hadEntityWriteback(ctx context.Context, sessionID string) bool {
return count > 0
}
// hadDiscovery checks whether this session ran `run` successfully against a
// real target — i.e. discovered live state (versions, package counts, host
// facts, service status) that the DB didn't have. Used by complete_task to
// refuse success when discovery happened but no writeback followed (the
// knowledge-loop drift the prior warnings failed to close — the agent
// ignored advisory text, so D.1 makes it structural).
//
// Only `run` counts as discovery here, NOT get_entity/list_lxcs/etc. — those
// are DB lookups, not new facts. A trivial Q&A ("status of lxc:dns?") that
// only calls get_entity is a degenerate case (SOUL.md: "Don't invent
// attributes that don't exist") and must NOT be blocked. Only sessions that
// actually executed against a live target get the writeback gate.
func (s *store) hadDiscovery(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" {
return false // fail safe: don't block when we can't check
}
var count int
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM agent_activity
WHERE session_id = $1
AND tool_name = 'run'
AND success = true`, sessionID).Scan(&count)
return count > 0
}
// staleGoalSession is a goal-bearing task that's gone idle without reaching
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
// plans/2026-07-11-task-completion-safety-net.md).