praxis: an empty attention list is not always an all-clear (V-540)

ECOSYSTEM-SPEC §2.6 requires list_attention to distinguish "nothing needs
attention" from "I cannot currently tell", and to say so when a source is
failed or stale. Maven said the first one unconditionally: ListAttention
decoded into []map[string]any, the word degraded appeared nowhere, and an empty
list answered "ничего не требует внимания". A Praxis with every source dead
read as calm.

Two halves, because the spec's mechanism does not exist server-side yet. The
deployed Praxis answers /api/v1/tools/attention with a bare array and no
envelope, so praxisAttention now decodes either shape and believes a degraded
array when one arrives. Until one does, an empty list triggers one read of
/api/v1/sources, and anything that is not reporting health "ok" is named
instead of the all-clear. Zero sources is the same answer: a Praxis that polls
nothing knows nothing, which is the state of this box today.

A sources read that fails is deliberately not a hedge. The attention call
succeeded, and not being able to ask about health is not evidence of a fault.

Both hedges also cover the entity-scoped digest, where a per-entity all-clear
is the more convincing of the two. New keys attention_degraded and
attention_no_sources, in acts_ru_v1.json and the floor. The fake Praxis serves
one healthy source by default, so the existing attention tests still assert an
all-clear on purpose rather than by omission.
This commit is contained in:
2026-08-05 12:07:07 +04:00
parent c586346a60
commit 1524991adc
6 changed files with 283 additions and 14 deletions
+55 -3
View File
@@ -154,14 +154,18 @@ func (listAttentionCapability) aliases() []string {
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
started := h.now()
items, err := px.ListAttention(ctx, 20)
att, err := px.ListAttention(ctx, 20)
if err != nil {
log.Printf("ecosystem: praxis attention: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
started, traceErrorFields(err))
return phraser.A(phraser.AttentionFail, nil)
}
items := att.Items
if len(items) == 0 {
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, started); hedge != "" {
return hedge
}
return phraser.A(phraser.AttentionNone, nil)
}
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
@@ -299,14 +303,14 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
}
queried := h.now()
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
att, err := px.ListAttentionForEntity(ctx, entityID, 20)
if err != nil {
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
}
items, scoped := scopedToEntity(items, entityID)
items, scoped := scopedToEntity(att.Items, entityID)
if !scoped {
// A Praxis old enough to ignore an unknown query parameter answers the
// scoped question with the unscoped list. Reading that back as "по
@@ -339,6 +343,11 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
parts = append(parts, known)
}
if len(parts) == 0 {
// The scoped list is as exposed to a silent source as the unscoped one,
// and a per-entity all-clear is the more convincing of the two (#540).
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, queried); hedge != "" {
return hedge
}
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
}
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
@@ -783,3 +792,46 @@ func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Dec
}
return h.handleHexisAct(ctx, dec)
}
// attentionCannotTell returns the hedge to say instead of an all-clear, or ""
// when an empty attention list really does mean nothing needs looking at
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
//
// "Nothing needs attention" and "I cannot currently tell" are different answers
// and only one of them was ever said. The spec's mechanism is a `degraded` array
// on the attention response, which the deployed Praxis does not send, so the
// source health read is the half that works today. It costs one HTTP call and
// only on the empty-list turn, which is the only turn where an all-clear is at
// stake.
//
// A failed sources read is deliberately NOT a hedge. The attention call itself
// succeeded, and not being able to ask about health is not evidence of a fault —
// hedging on it would turn one flaky endpoint into a permanently uncertain
// assistant.
func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisClient, degraded []string, started time.Time) string {
if len(degraded) > 0 {
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
"degraded": strings.Join(degraded, ","), "source": "response",
})
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(degraded, ", ")})
}
bad, total, err := px.UnhealthySources(ctx)
if err != nil {
log.Printf("ecosystem: praxis sources: %v", err)
return ""
}
if total == 0 {
// A Praxis that polls nothing knows nothing, so its silence is not an
// all-clear either. This is the state the box is in as of 2026-08-05:
// /api/v1/sources answers with an empty array.
h.recordPraxisTrace(ctx, "attention_no_sources", started, map[string]any{"sources": 0})
return phraser.A(phraser.AttentionNoSources, nil)
}
if len(bad) > 0 {
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
"degraded": strings.Join(bad, ","), "sources": total, "source": "health",
})
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(bad, ", ")})
}
return ""
}