Files
Teknium 2dd7d59a7b perf(gateway): retire closed local and idle API sessions from the authority's memory
A LiveSession (event stream, replay ring key, locks, pending controls) stayed in
SessionAuthority.sessions for the life of the process: one per local conversation ever
closed and one per API conversation ever served (+2 per soak cycle, unbounded).

Retirement (gateway/session_retirement.py) drops it when nothing pins it, leaving the
state a restart leaves: a closed local conversation once its close lifecycle ran, an API
conversation at its drain's idle exit. Every entry point already restores an absent
conversation (attach, admission, API bind); _schedule, _publish_pending and
observe_api_controls now restore one input committed for after it retired.

Fixes the Greptile findings on the split-out code (#147831):
- P1 accepted prompts: the pins and a FIFO/worker read are re-checked after the last
  await, in one loop step with the removal; a claim since the close (execution stamp)
  keeps the reopened conversation; a drain alive at close retires it at its idle exit.
- P1 shared launch keys: release_launch_secrets counts retired conversations' policies
  (authority.retired_local), so a closed branch keeps its parent's key.
- P2 prune: retire_live_sessions forgets a retired conversation's adapter source and
  policy by id.
2026-11-02 06:58:58 -08:00

528 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fresh local routes owned by the authenticated gateway, not by a viewer.
Source and supported launch settings are frozen per route, never process env.
"""
from __future__ import annotations
import json
import logging
import uuid
from contextlib import contextmanager
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.session import SessionSource
from gateway.session_contract import CANONICAL_GATEWAY_PROTOCOL
from hermes_state_runtime import RuntimeStoreError
logger = logging.getLogger(__name__)
class LocalSessionAdapter(BasePlatformAdapter):
"""Delivery stays in the canonical event stream; no second local transport."""
def __init__(self, authority):
super().__init__(PlatformConfig(enabled=True), Platform.LOCAL)
self.authority = authority
# Built outside ``_create_adapter``: without its runner a push wake skips the authority FIFO
# and falls to a message handler this adapter never has (``deliver_wake``).
self.gateway_runner = getattr(authority, 'runner', None)
self.sources = {}
self.policies = {}
@staticmethod
def _source_key(source):
# A conversation its chat moved past keeps the chat's id, which every other one of that chat
# shares: its own key names it (gateway/session_detached.py).
from gateway.session_detached import detached_route_of
return detached_route_of(source) or source.chat_id
def authorize_source(self, source):
saved = self.sources.get(self._source_key(source))
if saved is None:
return False
original, identity = saved
return (source is original and self._identity(source) == identity
and any(live.source is source for live in self.authority.sessions.values()))
@staticmethod
def _identity(source):
return source.platform, source.chat_id, source.user_id, source.profile, source.chat_type
def register_source(self, source):
self.sources[self._source_key(source)] = source, self._identity(source)
def kept_source(self, source):
"""The object this route was admitted with when *source* is that same source: a closed
conversation retired from memory comes back with it, so the results its background work
still holds pass ``authorize_source`` (an identity check)."""
saved = self.sources.get(self._source_key(source))
return saved[0] if saved is not None and saved[0].to_dict() == source.to_dict() else source
def forget(self, source, route):
"""A deleted conversation's admission source, frozen policy and post-delivery slot (a local
route names one conversation; a detached copy keeps only its own key, its chat's stays)."""
key = self._source_key(source)
if self.sources.get(key, (None,))[0] is source:
del self.sources[key]
if key == source.chat_id and source.platform == Platform.LOCAL:
self.policies.pop(key, None)
self._post_delivery_callbacks.pop(route, None)
async def connect(self, *, is_reconnect=False):
return True
async def disconnect(self):
self.sources.clear()
async def send(self, chat_id, content, reply_to=None, metadata=None):
# TurnRunner already publishes model/tool deltas and settlement exactly once.
return SendResult(success=True, message_id=uuid.uuid4().hex)
async def edit_message(self, chat_id, message_id, content, *, finalize=False):
return SendResult(success=True, message_id=message_id)
async def _record_delivery_obligation(self, *args, **kwargs):
# No platform ACK to lose: viewers replay the durable transcript. A ledger row would only
# park a raw copy of the reply in state.db that no redelivery can ever use.
return None
async def send_typing(self, chat_id, metadata=None):
return None
async def get_chat_info(self, chat_id):
return {'id': chat_id, 'type': 'dm'}
async def send_exec_approval(self, **kwargs):
# The existing callback owns registration in PendingControls. An ACK here
# selects that rendering path without a plaintext fallback or a second waiter.
return SendResult(success=True, message_id=uuid.uuid4().hex)
async def send_clarify(self, chat_id, question, choices, clarify_id, session_key, metadata=None):
error = self._unanswerable_clarify(chat_id)
if error is not None:
# Acknowledging this question would park the turn on an answer nobody can submit.
# A structured decline releases the wait at once and skips the plain-text re-ask
# (an ordinary failure would fall back to a send() that ACKs, and the turn would
# wait the whole clarify timeout).
from gateway.relay.egress import EGRESS_DECLINE_CODE
return SendResult(success=False, error=error, error_kind='forbidden',
raw_response={'success': False, 'code': EGRESS_DECLINE_CODE, 'error': error})
# Local sessions keep the base prompt: a choice question also captures typed text.
return await super().send_clarify(chat_id, question, choices, clarify_id, session_key, metadata)
def _unanswerable_clarify(self, chat_id):
"""Why no one can answer a question asked in this chat now, or None to ask it.
Hosted rooms (controls expose approvals only) and forwarded A2A conversations (the
author is a remote peer with no prompt surface) never have an answering viewer. Any
other chat can be answered by an attached viewer holding ``session:respond``; with none
attached, a producer turn (Bot Chat DM, plugin/heartbeat injection) declines, while a
viewer's own turn keeps waiting so its author can re-attach and answer."""
policy = self.policies.get(chat_id)
if policy is None:
return None
from gateway.session_a2a import is_forward_policy
if policy.source == 'bot_room':
return 'Clarification is unavailable in hosted rooms'
if is_forward_policy(policy):
return 'Clarification is unavailable to a forwarded peer'
live = self.authority.sessions.get(chat_id)
if live is None:
return None
with live.event_stream.lock:
if any('session:respond' in member.capabilities for member in live.subscribers.values()):
return None
from hermes_state_runtime import list_session_admissions
started = next((row for row in list_session_admissions(self.authority.db, session_id=chat_id)
if row['status'] == 'started'), None)
if started is not None and 'local_automation_v1' in started['payload']:
return 'Clarification is unavailable: no viewer can answer this chat'
return None
def forget_live_session(authority, session_id, live):
"""What the local adapter and the authority keyed by a deleted live session."""
authority.launch_sources.pop(session_id, None)
if live.source is None or live.source.platform != Platform.LOCAL:
return
from gateway.session_local_recovery import local_adapter_map
local = local_adapter_map(authority).get(Platform.LOCAL)
if local is not None:
local.forget(live.source, live.route)
def authorize_local_source(runner, source):
"""None for legacy/nonlocal routes and history mounts; registered local adapters fail closed."""
if source is None or source.platform != Platform.LOCAL:
return None
# A forged profile must not miss this adapter and fall into a messaging
# allow-all policy. Local source identity is owned by this authority alone.
from gateway.session_authorities import all_authorities
owners = all_authorities(runner)
adapter = runner._adapters_for_profile(source.profile).get(Platform.LOCAL)
if not isinstance(adapter, LocalSessionAdapter):
# A LOCAL source naming a profile this runtime does not serve must fail closed rather
# than fall into a messaging allow-all policy; legacy runners (no authority) stay None.
return False if owners else None
# The adapter's authority must be one THIS runtime owns (the launch authority or a served
# secondary's), never a stale/foreign object that happens to carry a LOCAL adapter.
if not any(adapter.authority is owner for owner in owners):
return False
return adapter.authorize_source(source)
def _bypass_policy(params, *, private_secrets, client_local=False):
"""Frozen snapshot for --safe-mode / --ignore-user-config: code defaults plus the explicit
launch options. The profile's config.yaml/.env are never read, so a malformed profile cannot
block or shape the session; there is no default model to inherit, so one must be explicit."""
import copy
from gateway.session_policy import build_policy
from hermes_cli.config_defaults import DEFAULT_CONFIG
if not params.get('model'):
raise RuntimeStoreError('invalid_params', field='model',
detail='a bypass launch has no profile to inherit a model from')
defaults = copy.deepcopy(DEFAULT_CONFIG)
if not isinstance(defaults.get('model'), dict): # code default is the empty string
defaults['model'] = {}
return build_policy(params, defaults, private_secrets=private_secrets, profile_terminal=False,
client_local=client_local)
def load_profile_config(home=None):
"""The profile's effective config.yaml for a NEW session's policy, ``${VAR}`` refs expanded against
the profile's CURRENT ``.env`` (the source tool env refreshes from), not the env this daemon
started with: a single-profile owner binds no secret scope and never reloads ``.env`` for local
turns. A bound scope (a served profile's owner scope) already is that source and is kept; a miss
on the launch profile still falls to the process env (systemd / ``op run`` injection)."""
from pathlib import Path
from gateway.run import _load_gateway_config
with profile_env_scope(home):
return _load_gateway_config(*(() if home is None else (Path(home) / 'config.yaml',)))
@contextmanager
def profile_env_scope(home=None):
"""Bind the profile's current ``.env`` (see ``load_profile_config``) unless a scope is bound."""
from pathlib import Path
from agent.secret_scope import build_profile_secret_scope, current_secret_scope, reset_secret_scope, set_secret_scope
from gateway.run import _gateway_config_home
if current_secret_scope() is not None:
yield
return
home = Path(home) if home is not None else _gateway_config_home() # default: the active gateway home
token = set_secret_scope(build_profile_secret_scope(home), profile_home=str(home))
try:
yield
finally:
reset_secret_scope(token)
def project_plugins_warning(policy):
"""The plain notice for a session whose client opted into project plugins (its forwarded
``HERMES_ENABLE_PROJECT_PLUGINS``) in a folder that has some: the gateway never loads them."""
from pathlib import Path
from utils import is_truthy_value
# The client's folder is a path on this host only for a client the gateway proved local.
env = policy.client_env() if policy is not None and not policy.client_env_remote else None
folder = Path(policy.cwd) / '.hermes' / 'plugins' if env else None
if not env or not is_truthy_value(env.get('HERMES_ENABLE_PROJECT_PLUGINS')) or not folder.is_dir():
return None
try:
if not any(folder.iterdir()):
return None
except OSError: # an unlistable folder skips the optional notice, never the session
return None
return (f'project plugins in {folder} are not loaded: the gateway that runs Hermes sessions does not '
'load ./.hermes/plugins, so HERMES_ENABLE_PROJECT_PLUGINS has no effect here.')
def create_local_session(authority, actor, params, *, trusted_policy=None, trusted_secrets=None, skills_prompt=None,
client_local=False):
"""Stage, commit and publish a local session inline (synchronous callers)."""
from gateway.session_local_recovery import restore_local_session
from hermes_state_local import commit_local_session
staged = stage_local_session(authority, actor, params, trusted_policy=trusted_policy,
trusted_secrets=trusted_secrets, skills_prompt=skills_prompt,
client_local=client_local)
if isinstance(staged, dict):
commit_local_session(authority.db, epoch=authority.epoch, receipt=staged)
staged = restore_local_session(authority, staged['session_id'])
return staged
def staged_creation_follows(authority, actor, request_id):
"""Whether a retry of *request_id* may ask to follow the profile.
True when nothing is staged under that identity, or the staged request already follows.
False only when an older build committed the session without ``follow_profile_config`` and
stopped before its title. The next attempt reuses the identity, and a different request is
refused for good. The retry then carries the frozen request.
"""
if not isinstance(request_id, str) or not request_id:
return True
from gateway.session_local_recovery import local_identity
from hermes_state_local import local_receipt
sid = local_identity(authority.profile_id, actor.subject, request_id)
if authority.db.get_session(sid) is None:
return True
try:
request = json.loads(local_receipt(authority.db, sid)['policy']['request_json'])
except (KeyError, TypeError, ValueError, RuntimeStoreError):
return True
return isinstance(request, dict) and request.get('follow_profile_config') is True
def stage_local_session(authority, actor, params, *, trusted_policy=None, trusted_secrets=None, skills_prompt=None,
client_local=False, desktop_local=False):
"""Everything before the creation commit: the existing session's ref on an exact retry, else the
receipt ``commit_local_session`` writes (the async RPC commits it off the owner loop).
``client_local``: the gateway's own proof that the client is on this host (its connection redeemed a
ticket minted over the private control socket), never a value the client sends. ``desktop_local``:
that client is also the Desktop app (``process_identity.local_desktop_client``); a Desktop chat
records it so its turns run as work for the local Desktop (``session_policy.local_desktop_turn``)."""
if actor.profile_id != authority.profile_id:
raise RuntimeStoreError('profile_mismatch')
if 'session:create' not in actor.capabilities:
raise RuntimeStoreError('permission_denied')
from gateway.session_policy import build_policy, BYPASS_FIELDS, FOLLOW_ADOPTED, LOCAL_DESKTOP_CHAT
from gateway.run import _resolve_gateway_model
from dataclasses import replace
private_secrets = dict(trusted_secrets or {})
client_key = params.get('api_key') # the client's own; an alias's key is the owner's expansion
if trusted_policy is not None:
policy = trusted_policy
elif any(params.get(name) is True for name in BYPASS_FIELDS):
policy = _bypass_policy(params, private_secrets=private_secrets, client_local=client_local)
else:
from gateway.session_local_route import resolve_launch_route
raw_model, sent = params.get('model'), params
with profile_env_scope(): # an alias's ``key_env`` / ``${VAR}`` key reads the same .env
config = load_profile_config()
params = resolve_launch_route(params, config)
# A client's session.create is the one caller whose named folder may select the coding posture.
policy = build_policy(params, config, private_secrets=private_secrets, workspace_posture=True,
client_local=client_local)
if params is not sent: # resolved, even to a model of the alias's own name
# Owner-stamped provenance (not create fields): a remote client cannot re-resolve the typed
# `-m` alias against its own home, so resume and an exact retry compare a repeat against the
# name, and ``route_derived`` names the fields the route (not the client) filled.
import json
derived = sorted(key for key in ('provider', 'base_url') if key in params and key not in sent)
policy = replace(policy, request_json=json.dumps(
{**json.loads(policy.request_json), 'model_requested': raw_model, 'route_derived': derived},
sort_keys=True))
marks = {LOCAL_DESKTOP_CHAT: True} if desktop_local and policy.source == 'gui' else {}
if policy.model is None:
from hermes_cli.anon_auth import GUEST_MODEL
policy = replace(policy, model=_resolve_gateway_model(policy.config()) or None)
if policy.model == GUEST_MODEL:
# Follows its profile, so the provider the user connects later replaces the trial model.
marks[FOLLOW_ADOPTED] = True
if marks:
import json
policy = replace(policy, config_json=json.dumps({**json.loads(policy.config_json), **marks}))
request_id = params.get('request_id', uuid.uuid4().hex)
if not isinstance(request_id, str) or not request_id or len(request_id) > 256:
raise RuntimeStoreError('invalid_params', field='request_id',
detail='must be a non-empty string of at most 256 characters')
from dataclasses import asdict
from gateway.session import SessionEntry
from gateway.session_lifecycle import _now
from gateway.session_local_recovery import local_identity, restore_local_session
authority._require_admission_open()
sid = local_identity(authority.profile_id, actor.subject, request_id)
from gateway.session_policy import bind_launch_key, restore_policy, launch_key
if authority.db.get_session(sid) is not None:
from hermes_state_local import local_receipt
saved = local_receipt(authority.db, sid)
original = restore_policy(saved['policy'])
from hermes_state_local import same_create_retry
if not same_create_retry(saved['policy'], policy.request_json, params):
raise RuntimeStoreError('invalid_params', field='request_id',
detail='a resumed request_id must carry the same frozen request')
import hmac
import json
if 'model_requested' in json.loads(original.request_json):
# An owner-resolved alias: its key came from the profile (a `.env` rotation is not a changed
# request) and the client sent none, so one it sends now is a different request.
if client_key is not None:
raise RuntimeStoreError('admission_conflict')
elif original.credential_ref is not None or params.get('api_key') is not None:
key = launch_key(authority, original)
if key is None or not hmac.compare_digest(key, params.get('api_key') or ''):
raise RuntimeStoreError('admission_conflict')
return restore_local_session(authority, sid)
from gateway.session_policy import launch_skills
if launch_skills(params) and skills_prompt is None:
# `-s` is rendered off-loop by the RPC handler (render_launch_skills); a caller that
# skipped it must not mint a session silently missing its preload.
raise RuntimeStoreError('invalid_params', field='skills',
detail='the caller must render -s skills before minting the session')
policy = replace(policy, skills_prompt=skills_prompt)
if warning := project_plugins_warning(policy):
logger.warning('session %s: %s', sid, warning)
client_request = {k: params[k] for k in ('client_env',) if k in params}
policy = bind_launch_key(authority, sid, policy, params.get("api_key"), config_secrets=private_secrets,
owner_key=client_key is None, # an overlapping exact retry takes the held one
client_request=client_request)
from gateway.session_local_recovery import local_source
source = local_source(authority, sid, actor.subject)
route = authority.runner.session_store._generate_session_key(source)
# Same clock as every other routing entry: the recovery sweeps compare against a
# naive cutoff, and one aware entry aborts the whole iteration.
now = _now()
entry = SessionEntry(route, sid, now, now, origin=source, platform=Platform.LOCAL)
# Prefill is read now, at creation (main's CLI: at startup), and held only if this attempt commits.
from gateway.session_worker_construct import stage_session_prefill
staged = stage_session_prefill(authority, policy, {
'profile_id': authority.profile_id, 'principal_id': actor.subject, 'request_id': request_id,
'session_id': sid, 'route': route, 'entry': entry.to_dict(), 'policy': asdict(policy)})
# Owner memory, never stored: an overlapping create's commit judges this request's own map by the
# winner's committed rules, as a sequential retry is.
staged.client_request = client_request
return staged
def publish_local_policy(authority, session_id, *, evict=True):
from hermes_state_local import local_receipt
from gateway.session_policy import restore_policy
live = authority.sessions[session_id]
from gateway.session_local_recovery import local_adapter_map
adapter = local_adapter_map(authority)[Platform.LOCAL]
adapter.policies[live.source.chat_id] = restore_policy(local_receipt(authority.db, session_id)['policy'])
if evict:
authority.runner._evict_cached_agent(live.route)
def _lazy_model(authority, policy):
"""The model a not-yet-built session will run: with no provider pinned, the free tier's agent
build pins ``nous/welcome`` (``pin_model_for_route``), so the configured default is not it."""
import json
model = policy.current_model(authority) if policy else None
if (policy is None or json.loads(policy.request_json).get('model')
or str(policy.provider or '').strip().lower() not in {'', 'auto'}):
return model
from gateway.session_authorities import owner_scope
from hermes_cli.anon_auth import GUEST_MODEL, free_tier_route
with owner_scope(authority):
return GUEST_MODEL if free_tier_route() else model
def _reasoning_fields(agent, policy, model, authority=None, override=None):
"""Main's session-info effort/tier fields, from the built agent or (lazy) what the policy's next turn
resolves (a following session's current profile): a resumed Desktop slice holds its reasoning pill
pending until ``reasoning_effort`` arrives."""
from agent.fast_mode import parse_service_tier
from agent.voice_turn_route import session_runtime_view
if agent is None and policy is None:
return {}
view = session_runtime_view(agent) if agent is not None else None
if view is None:
from hermes_constants import resolve_reasoning_config
effective, next_model = policy.effective(authority, secrets=False)
route = effective.get('model') or {}
config = (getattr(view, 'reasoning_config', None) if view is not None
else override if override is not None else resolve_reasoning_config(effective, next_model or ''))
tier = (getattr(view, 'service_tier', None) if view is not None
else parse_service_tier((effective.get('agent') or {}).get('service_tier'))) or ''
provider = getattr(view, 'provider', None) if view is not None else route.get('provider')
base_url = getattr(view, 'base_url', None) if view is not None else route.get('base_url')
return effort_fields(config, tier, model, provider, base_url, getattr(view, 'api_mode', None))
def effort_fields(config, tier, model, provider, base_url, api_mode=None):
"""Session-info ``reasoning_effort`` / ``_wire`` / ``service_tier`` / ``fast`` for one resolved route."""
from agent.fast_mode import STATIC_TIERS
from agent.reasoning_effort import clamp_effort, route_supported_efforts
from hermes_cli.models import resolve_fast_mode_overrides
tier = tier or ''
effort = ''
if isinstance(config, dict):
effort = 'none' if config.get('enabled') is False else str(config.get('effort') or '')
wire = ''
if effort and effort != 'none':
wire = str(clamp_effort(effort, route_supported_efforts(provider, model, api_mode)) or '')
try:
fast = tier in STATIC_TIERS and resolve_fast_mode_overrides(
model, provider=provider or None, base_url=base_url or None, tier=tier) is not None
except Exception: # health: allow BLE001 -- main's _fast_tier_applies gate: unknown means not Fast
fast = False
return {'reasoning_effort': effort, 'reasoning_effort_wire': wire, 'service_tier': tier, 'fast': fast}
def local_session_info(authority, ref):
live = authority.sessions[ref.session_id]
agent = authority.agent(ref)
from gateway.session_authorities import served_profile_name
from gateway.session_policy import policy_for_source
policy = policy_for_source(authority.runner, live.source)
if live.source is None: # a history mount: what its stored row says
source = authority.db.get_session(ref.session_id)['source']
else:
source = policy.source if policy else live.source.platform.value
info = {'source': source,
'model': (getattr(agent, 'model', policy.current_model(authority) if policy else None) if agent is not None
else _lazy_model(authority, policy)),
'lazy': agent is None,
'profile_id': authority.profile_id, 'desktop_protocol': CANONICAL_GATEWAY_PROTOCOL,
# Main's TUI labels a named profile's composer (``alpha ❯``) from this field.
'profile_name': served_profile_name(authority.profile_id),
**({'cwd': policy.cwd} if policy else {}),
# An ACP session's edit approval policy: every attached surface reads the same one.
**({'edit_approval_policy': policy.edit_approval_policy} if policy and policy.source == 'acp' else {})}
peek = getattr(authority.runner, '_peek_session_state', None)
state = peek(live.route) if peek is not None else None
override = state.conversation.reasoning_override if state is not None else None # a typed /reasoning
if agent is None and policy is None and live.source is not None and live.source.platform != Platform.LOCAL:
# A messaging chat not built yet: what its next turn resolves (its /model and /reasoning picks
# included), so a viewer's model and reasoning pill never wait on a field that never comes.
from gateway.session_messaging_picks import messaging_route_info
info.update(messaging_route_info(authority.runner, live))
else:
info.update(_reasoning_fields(agent, policy, info['model'], authority, override))
if policy:
# The creation request the route was frozen with (secrets already extracted), so a
# resuming client can tell "same flags again" from a real override attempt.
import json
info['launch_request'] = json.loads(policy.request_json)
from agent.session_client_env import NOT_LOCAL_WARNING
# Session-start notices main's TUI already renders as ``config_warning``.
notices = (project_plugins_warning(policy), NOT_LOCAL_WARNING if policy.client_env_remote else None)
if warning := ' '.join(filter(None, notices)):
info['config_warning'] = warning
info.update(_classic_cli_fields(authority, ref, live, agent, policy))
return info
def _classic_cli_fields(authority, ref, live, agent, policy):
"""What main's HermesCLI read off itself for /status, the status bar and the exit summary: the
stored title and times, the session's yolo, api_mode, the loaded tool count and a masked
credential hint (never the key). Lazy sessions report what is known without building an agent."""
import contextlib
stored_id = ref.session_id
with contextlib.suppress(Exception):
from hermes_state_local import local_receipt
stored_id = local_receipt(authority.db, ref.session_id)['entry']['session_id'] or stored_id
row = {}
with contextlib.suppress(Exception):
row = authority.db.get_session(stored_id) or {}
yolo = bool(policy is not None and getattr(policy, 'yolo', False))
with contextlib.suppress(Exception):
from tools.approval import is_session_yolo_enabled
yolo = yolo or is_session_yolo_enabled(live.route)
api_mode = getattr(agent, 'api_mode', None) if agent is not None else None
if api_mode is None and policy is not None:
with contextlib.suppress(Exception):
api_mode = (policy.effective(authority, secrets=False)[0].get('model') or {}).get('api_mode')
hint = None
if agent is not None and getattr(agent, 'api_key', None):
from agent.redact import mask_secret
hint = mask_secret(str(agent.api_key))
tools = getattr(agent, 'tools', None) if agent is not None else None
started = row.get('started_at')
return {'title': row.get('title'), 'started_at': started,
'last_active_at': row.get('last_activity_at') or started, 'yolo': yolo,
'api_mode': api_mode or None, 'tool_count': len(tools) if isinstance(tools, list) else None,
'credential_hint': hint}