Skip to content

feat(agents): roster endpoint + last_seen_at + ETag (parity port of cueapi/cueapi#630)#80

Merged
mikemolinet merged 1 commit into
mainfrom
feat/agents-roster-and-last-seen-at-fresh
May 11, 2026
Merged

feat(agents): roster endpoint + last_seen_at + ETag (parity port of cueapi/cueapi#630)#80
mikemolinet merged 1 commit into
mainfrom
feat/agents-roster-and-last-seen-at-fresh

Conversation

@mikemolinet
Copy link
Copy Markdown
Collaborator

Summary

Re-port of closed PR #47 which was on a stale base ~8880 deletions behind main. Fresh against current main HEAD.

Phase A of the Agent Directory productization (PRD: trydock.ai/mike/agent-directory-productization-prd). Eliminates the failure mode where agents had to remember 6+ fields per recipient AND had no way to discover the live roster.

What lands

New endpoint

GET /v1/agents/roster — display-optimized snapshot for prompt-injection at session-boot. Distinct from GET /v1/agents (management surface):

  • Always-full list (no pagination)
  • Drops opaque IDs / secrets / timestamps / tenancy metadata
  • Adds derived online, last_seen_relative, preferred_contact
  • Always excludes soft-deleted agents
  • Weak ETag + If-None-Match → 304 Not Modified for poll efficiency
  • ETag bucketed to 5-min windows so quiet periods produce stable hashes
  • Cache-Control: private, max-age=300 matches derivation buckets

Migration 031 (renumbered from private's 048)

Adds agents.last_seen_at TIMESTAMPTZ NULL. Nullable, no backfill.

Hot-path hooks

last_seen_at = now() writes:

  • create_message — sender's agent (via touch_last_seen in agent_service)
  • list_inbox — recipient's agent on EVERY poll (via _bump_last_seen_stmt in inbox_service). Even when no queued messages exist, the poll proves activity.

Online derivation (server-computed in list_roster)

  • within 5 min → online
  • within 30 min → away
  • older or NULL → offline
  • Caller override wins: PATCHed status=away/offline keeps that override

Pure helpers (for unit-testability)

pytest-cov + ASGI in-process dispatch on Python 3.12 doesn't always trace HTTP-routed paths cleanly. Pure helpers sidestep that.

  • _build_roster_entry(agent, now) in agent_service.py
  • _compute_roster_etag(parts) in agent_service.py
  • _derive_online_state(now, last_seen_at, asserted_status)
  • _format_relative(now, last_seen_at)
  • _bucketed_seen(last_seen_at)
  • _bump_last_seen_stmt(agent_id, now) in inbox_service.py
  • _etag_matches(if_none_match, server_etag) in agents router

Tests

27 new tests in tests/test_agent_roster.py (verbatim from private):

  • Shape verification, hot-path hooks (sender + recipient)
  • Derivation correctness across all 3 buckets
  • Caller-asserted status override
  • Soft-delete exclusion
  • preferred_contact derivation
  • last_seen_relative formatting
  • ETag 304 handling, ETag changes when roster mutates
  • Pure-helper unit tests
27 passed in 2.44s

Full local suite: 890 passed + 18 xfailed (pre-existing) + 4 skipped. Zero regressions.

Re-port note

Re-port of closed PR #47. Fresh against current main after PR #74 + #75 + #76 + #77 + #78 + #79 merged earlier in this session.

Test plan

  • 27 new tests pass locally
  • Full local suite: 890 passed, zero regressions
  • Migration 031 renumbered cleanly
  • Parity manifest updated for new migration entry
  • Pure-helper unit tests pass without DB+ASGI
  • CI green
  • Admin-merge per agents-merge-own-PRs directive

🤖 Generated with Claude Code

…ueapi/cueapi#630)

Re-port of closed [PR #47](#47) which was on a stale base ~8880 deletions behind main. Fresh against current main HEAD.

Phase A of the Agent Directory productization. Eliminates the failure
mode where agents had to remember 6+ fields per recipient AND had no
way to discover the live roster.

## What lands

- **GET /v1/agents/roster** — display-optimized snapshot for prompt-
  injection at session-boot. Distinct from the existing management
  surface (GET /v1/agents):
  - Always-full list (no pagination)
  - Drops opaque IDs / secrets / timestamps / tenancy metadata
  - Adds derived ``online``, ``last_seen_relative``, ``preferred_contact``
  - Always excludes soft-deleted agents
  - Weak ETag + ``If-None-Match`` → 304 Not Modified for poll efficiency
  - ETag bucketed to 5-min windows so quiet periods produce stable hashes
  - ``Cache-Control: private, max-age=300`` matches derivation buckets

- **Migration 031** (renumbered from private's 048) — adds
  ``agents.last_seen_at TIMESTAMPTZ NULL``. Nullable, no backfill.

- **Hot-path hooks** write ``last_seen_at = now()``:
  - ``create_message`` — sender's agent (in same tx via touch_last_seen)
  - ``list_inbox`` — recipient's agent, on EVERY poll (via
    _bump_last_seen_stmt). Even when no queued messages exist, the
    poll proves activity.

- **Online derivation** (server-computed in ``list_roster``):
  - within 5 min   → ``online``
  - within 30 min  → ``away``
  - older or NULL  → ``offline``
  - Caller override wins: PATCHed status=away/offline keeps that
    override regardless of recent activity

## Pure helpers (for unit-testability — pytest-cov + ASGI issue)

- ``_build_roster_entry(agent, now)`` in agent_service.py: ORM Agent
  → (entry_dict, etag_part_string)
- ``_compute_roster_etag(parts)`` in agent_service.py: list → weak ETag
- ``_derive_online_state(now, last_seen_at, asserted_status)`` →
  (online_bool, derived_status)
- ``_format_relative(now, last_seen_at)`` → "active now" / "5m ago" / ...
- ``_bucketed_seen(last_seen_at)`` → string for ETag stability
- ``_bump_last_seen_stmt(agent_id, now)`` in inbox_service.py:
  SQLAlchemy UPDATE statement
- ``_etag_matches(if_none_match, server_etag)`` in agents router:
  conditional GET predicate

## Tests

27 new tests in tests/test_agent_roster.py (verbatim from private):
shape verification, hot-path hooks (sender + recipient), derivation
correctness across all 3 buckets, caller-asserted status override,
soft-delete exclusion, preferred_contact derivation,
last_seen_relative formatting, ETag 304 handling, ETag changes when
roster mutates, pure-helper unit tests.

27/27 pass locally. Full local suite: 890 passed + 18 xfailed
(pre-existing) + 4 skipped. Zero regressions.

## Re-port note

Re-port of closed PR #47. Fresh against current main after PR #74 +
#75 + #76 + #77 + #78 + #79 merged earlier in this session.
@github-actions
Copy link
Copy Markdown

Parity check

This PR modifies files tracked in parity-manifest.json:

  • alembic/versions/031_agents_last_seen_at.py
  • app/models/agent.py
  • app/routers/agents.py
  • app/routers/executions.py
  • app/schemas/agent.py
  • app/services/agent_service.py
  • app/services/inbox_service.py
  • app/services/message_service.py

Please confirm one of the following in a reply or PR description update:

  1. The equivalent change has been applied to the private cueapi monorepo. Link the PR.
  2. This change is OSS-only and does not need porting. Briefly explain why (e.g. "fixes a bug that only exists in the OSS build").
  3. A follow-up issue has been filed to port the reverse direction. Link the issue.

This is a soft check — it does not block merge. The goal is visibility, not friction. See HOSTED_ONLY.md for the open-core policy.

@govindkavaturi-art govindkavaturi-art enabled auto-merge (squash) May 11, 2026 16:33
@mikemolinet mikemolinet merged commit 965dd4a into main May 11, 2026
7 checks passed
@mikemolinet mikemolinet deleted the feat/agents-roster-and-last-seen-at-fresh branch May 11, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant