feat(agents): per-agent inference — momentum, overdue-task, recent-patterns, focus-area (ADR-0014 step 7)

All four agents bumped to v1.1.0.

momentum (#114): infers engagement_trend ('up'|'stable'|'down') by comparing
done-rate in the last 7 days vs the prior 7 days. Agent surfaces the trend
in its snippet ("trending up — build on the momentum").

overdue-task (#115): infers lateness_tolerance_days (0/1/2) from snooze rate.
Agent now filters tasks against the tolerance so low-urgency users aren't
nagged about tasks that are only hours overdue.

recent-patterns (#116): infers window_days (7/14/30) from feedback event
density — sparse users get a wider window so the snippet isn't always empty.

focus-area (#113): no inferred params (project-level feedback linkage needed,
tracked under #78). preferred_areas pref was declared but ignored; agent now
honours it as a tiebreaker and mentions it in the snippet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 11:21:10 +00:00
parent ad6747c242
commit afb0e9b0cb
8 changed files with 383 additions and 26 deletions

View File

@@ -1,12 +1,57 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest
from .inference.history import UserHistory
from .manifest import AgentManifest, InferredParam
def _infer_engagement_trend(history: UserHistory) -> str:
"""Compare done-rate in the most recent 7 days vs the 7 days before that."""
events = sorted(history.events, key=lambda e: e.created_at)
if not events:
return "stable"
try:
latest = datetime.fromisoformat(events[-1].created_at.replace("Z", "+00:00"))
except ValueError:
return "stable"
cutoff_recent = latest - timedelta(days=7)
cutoff_older = latest - timedelta(days=14)
recent = [e for e in events if _parse_dt(e.created_at) >= cutoff_recent]
older = [e for e in events if cutoff_older <= _parse_dt(e.created_at) < cutoff_recent]
if len(older) < 3:
return "stable" # not enough baseline to compare
recent_rate = sum(1 for e in recent if e.action == "done") / max(len(recent), 1)
older_rate = sum(1 for e in older if e.action == "done") / max(len(older), 1)
delta = recent_rate - older_rate
if delta > 0.10:
return "up"
if delta < -0.10:
return "down"
return "stable"
def _parse_dt(iso: str) -> datetime:
try:
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError:
return datetime.min.replace(tzinfo=timezone.utc)
MANIFEST = AgentManifest(
id="momentum",
version="1.0.0",
version="1.1.0", # bumped: engagement_trend InferredParam added (#114)
description="Characterises the user's recent engagement trend from profile features.",
pref_schema={
"type": "object",
@@ -25,6 +70,15 @@ MANIFEST = AgentManifest(
required_consents=["data:core", "agent:momentum"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=21_600,
inferred_params=[
InferredParam(
key="engagement_trend",
ttl_sec=21_600, # recompute every 6 hours alongside snippet
cold_start_default="stable",
min_history=10,
infer=_infer_engagement_trend,
),
],
)
@@ -38,6 +92,7 @@ class MomentumAgent(BaseAgent):
completion = inp.profile.get("completion_rate_30d")
dismiss = inp.profile.get("dismiss_rate_30d")
volume = inp.profile.get("tip_volume_30d")
trend: str = inp.agent_prefs.get("engagement_trend", "stable")
parts: list[str] = []
@@ -65,10 +120,16 @@ class MomentumAgent(BaseAgent):
if volume is not None and int(volume) < 5:
parts.append("Very few tips served so far — this is an early-stage user.")
if trend == "up":
parts.append("Engagement is trending up compared to last week — build on the momentum.")
elif trend == "down":
parts.append("Engagement is trending down — a motivational or easy-win tip may help.")
prompt = " ".join(parts) if parts else "No engagement data available yet."
snapshot = {
"completion_rate_30d": completion,
"dismiss_rate_30d": dismiss,
"tip_volume_30d": volume,
"engagement_trend": trend,
}
return self._make_output(inp, prompt, snapshot)