"""Agents tab โ€” the management console for the agent roster. Everything here writes through ``config/agent_registry`` to ``state/agents.json``: enable/disable agents, per-agent research time budgets, risk knobs, system prompts, and adding brand-new agents (cloned from a template persona). The nightly run reads the same registry, so changes take effect on the next run โ€” no code edits needed. """ from __future__ import annotations import os import streamlit as st from config import agent_registry from config.personas import PERSONAS_BY_ID from config.settings import _dotenv_extras, get_settings _HORIZONS = ["intraday", "swing", "weeks", "months"] def _has_dedicated_creds(pid: str) -> bool: s = get_settings() key_name = f"ALPACA_{pid.upper()}_KEY" sec_name = f"ALPACA_{pid.upper()}_SECRET" extras = _dotenv_extras() key = getattr(s, f"alpaca_{pid}_key", "") or os.environ.get(key_name, "") or extras.get(key_name, "") sec = getattr(s, f"alpaca_{pid}_secret", "") or os.environ.get(sec_name, "") or extras.get(sec_name, "") return bool(key and sec) def _lessons_snippet(pid: str) -> str: try: from reporting.journal import lessons_for return lessons_for(pid) except Exception: return "" def _agent_card(persona, s) -> None: is_builtin = persona.id in PERSONAS_BY_ID creds_ok = _has_dedicated_creds(persona.id) status = "๐ŸŸข" if persona.enabled else "โšช๏ธ disabled" creds = "๐Ÿ”‘" if creds_ok else "โ—๏ธno keys" with st.expander(f"{status} **{persona.display_name}** (`{persona.id}`) ยท {creds}"): if not creds_ok: st.warning( f"No dedicated Alpaca paper account keys found. Add to `.env`:\n\n" f"`ALPACA_{persona.id.upper()}_KEY` and `ALPACA_{persona.id.upper()}_SECRET`\n\n" "(Until then this agent falls back to the shared/single-account keys.)" ) with st.form(f"edit_{persona.id}"): c1, c2, c3 = st.columns([2, 1, 1]) display_name = c1.text_input("Display name", persona.display_name) enabled = c2.checkbox("Enabled", persona.enabled) options_quota = c3.number_input("Options quota / night", 0, 4, persona.options_quota) st.markdown("**Research time** (0 = use global default: " f"{s.per_agent_wallclock_min} min cap / {s.per_agent_min_minutes} min floor)") t1, t2 = st.columns(2) time_budget = t1.number_input( "Wall-clock cap (min)", 0, 360, persona.time_budget_min or 0, 5, key=f"tb_{persona.id}", ) min_research = t2.number_input( "Research floor (min)", 0, 360, persona.min_research_min or 0, 5, key=f"mr_{persona.id}", ) st.markdown("**Risk knobs**") r1, r2, r3, r4 = st.columns(4) max_pos = r1.slider("Max position %", 0.01, 0.25, float(persona.max_position_pct), 0.01, key=f"mp_{persona.id}") picks = r2.number_input("Picks / night", 1, 8, persona.target_pick_count, key=f"pk_{persona.id}") stop = r3.slider("Default stop %", 0.02, 0.25, float(persona.default_stop_pct), 0.01, key=f"st_{persona.id}") take = r4.slider("Default take %", 0.05, 0.60, float(persona.default_take_pct), 0.01, key=f"tk_{persona.id}") horizon = st.selectbox( "Preferred horizon", _HORIZONS, index=_HORIZONS.index(persona.preferred_horizon) if persona.preferred_horizon in _HORIZONS else 1, key=f"hz_{persona.id}", ) risk_style = st.text_input( "Risk style (shown to the Investment Committee)", persona.risk_style, key=f"rs_{persona.id}", ) prompt = st.text_area("System prompt", persona.system_prompt, height=260, key=f"sp_{persona.id}") b1, b2 = st.columns([1, 1]) saved = b1.form_submit_button("๐Ÿ’พ Save", type="primary") reset = b2.form_submit_button( "โ†ฉ๏ธ Reset to code defaults" if is_builtin else "๐Ÿ—‘ Delete agent" ) if saved: agent_registry.upsert_agent( persona.id, { "display_name": display_name, "enabled": enabled, "system_prompt": prompt, "max_position_pct": float(max_pos), "target_pick_count": int(picks), "options_quota": int(options_quota), "default_stop_pct": float(stop), "default_take_pct": float(take), "preferred_horizon": horizon, "risk_style": risk_style, "time_budget_min": int(time_budget) or None, "min_research_min": int(min_research) or None, }, ) st.success("Saved โ€” applies from the next nightly run.") st.rerun() if reset: agent_registry.delete_agent(persona.id) st.success("Reset to code defaults." if is_builtin else "Agent deleted.") st.rerun() lessons = _lessons_snippet(persona.id) if lessons: st.caption("Track record") st.code(lessons, language="text") def _add_agent_form() -> None: st.subheader("โž• Add a new agent") templates = list(PERSONAS_BY_ID) with st.form("add_agent"): c1, c2 = st.columns([1, 2]) new_id = c1.text_input("Agent id (lowercase, e.g. `scalper`)") new_name = c2.text_input("Display name") template = st.selectbox("Clone style & knobs from", templates, index=0) prompt = st.text_area( "System prompt (leave blank to copy the template's prompt)", "", height=160, ) create = st.form_submit_button("Create agent", type="primary") if create: if not new_id.strip() or not new_name.strip(): st.error("Both id and display name are required.") return base = PERSONAS_BY_ID[template].model_dump() base.pop("id") base["display_name"] = new_name.strip() if prompt.strip(): base["system_prompt"] = prompt.strip() try: agent_registry.upsert_agent(new_id, base) except Exception as e: st.error(f"Could not create agent: {e}") return pid = new_id.strip().lower().replace(" ", "_").replace("-", "_") st.success( f"Agent `{pid}` created (enabled). Give it its own Alpaca paper account by adding " f"`ALPACA_{pid.upper()}_KEY` and `ALPACA_{pid.upper()}_SECRET` to `.env` โ€” " "until then it uses the shared fallback keys." ) st.rerun() def render() -> None: s = get_settings() st.caption( f"Roster and per-agent settings live in `state/agents.json` โ€” the nightly run reads it " f"on start. Registry file: `{agent_registry.registry_path()}`" ) personas = agent_registry.load_personas(include_disabled=True) enabled_n = sum(1 for p in personas if p.enabled) st.markdown(f"**{len(personas)} agents** ({enabled_n} enabled)") for persona in personas: _agent_card(persona, s) st.divider() _add_agent_form()