"""Fishbowl · Lab — one agent's editable card. A factory that emits the per-agent controls inside the Cast section: the model dropdown (as before), plus the editable surface the engine actually consumes at runtime and that is safe to patch via ``model_copy`` — a per-agent tool grant, a persona override, and the schedule knobs (``tick_every`` / ``max_consecutive``). Read-only facts (role, profile, what the agent listens for / may emit) ride along as info chips so the user can edit with context without touching the fragile event contract. The factory wires nothing into the run on its own — it returns a small handle bundle so :func:`build_lab` can connect each control's ``.change`` into the matching ``gr.State`` dict (one dict per editable field, keyed by agent name). This keeps the composer's "knobs → one validated WorldConfig" contract (ADR-0011 / ADR-0025) intact. """ from __future__ import annotations import html from dataclasses import dataclass import gradio as gr from src.core.manifest import AgentManifest # Soft per-turn cap for the schedule knobs — generous enough for any cast we ship, # small enough that a slider stays legible. The engine enforces the real bounds. _TICK_MAX = 32 _CONSECUTIVE_MAX = 8 @dataclass class AgentPanel: """Handles for one agent's card — the controls :func:`build_lab` wires up. ``tools`` / ``persona`` / ``tick_every`` / ``max_consecutive`` are None when the card omits that control (e.g. an agent with no tool grant has no tool picker), so the wiring loop can skip absent fields without guessing. """ name: str model: gr.Dropdown tools: gr.CheckboxGroup | None = None persona: gr.Textbox | None = None tick_every: gr.Number | None = None max_consecutive: gr.Number | None = None def _chip(label: str, value: str) -> str: """One read-only info chip (escaped); empty value → a muted dash.""" safe = html.escape(value) if value else "—" return ( f"{html.escape(label)}" f"{safe}" ) def _info_chips(manifest: AgentManifest) -> str: """The read-only fact strip: role, profile, and the (frozen) event contract.""" subs = ", ".join(manifest.subscribes_to) if manifest.subscribes_to else "everything" emits = ", ".join(manifest.may_emit) if manifest.may_emit else "—" chips = "".join( [ _chip("role", manifest.role), _chip("profile", manifest.model_profile), _chip("listens", subs), _chip("may emit", emits), ] ) return f"
{chips}
" def render_agent_panel( manifest: AgentManifest, *, model_choices: list[tuple[str, str]], model_value: str | None, backend_label: str, tool_choices: list[tuple[str, str]] | None = None, start_open: bool = False, ) -> AgentPanel: """Render one agent's editable card and return its control handles. Called inside a ``gr.render`` (or any Blocks context). The card is a *collapsed* ``gr.Accordion`` whose title carries the at-a-glance facts (name · archetype · tier · a 🛠 marker for a tool-capable mind), so the cast reads as a tidy stack of minds you expand only when you want to tune one — not a wall of textboxes. *model_choices* / *model_value* / *backend_label* drive the model dropdown exactly as the old inline row did. *tool_choices* is the set of (label, tool_id) the agent may be granted *and* that the engine actually has — when empty/None the tool picker is omitted, so a tool checkbox only ever appears for a tool-capable agent. *start_open* expands the card on first render (the composer opens the lead mind so the section is never an opaque list). """ archetype = manifest.archetype or f"the {manifest.role}" tool_mark = " · 🛠" if tool_choices else "" title = f"{manifest.name} · {archetype} · {manifest.model_profile}{tool_mark}" with gr.Accordion(title, open=start_open, elem_classes=["lab-agent-card"]): gr.Markdown(_info_chips(manifest), elem_classes=["lab-agent-meta"]) model = gr.Dropdown( choices=model_choices, value=model_value, label=f"model · {backend_label}", interactive=bool(model_choices), filterable=False, # a pick-from-catalogue dropdown, not a type-to-filter combobox ) tools_group: gr.CheckboxGroup | None = None if tool_choices: tools_group = gr.CheckboxGroup( choices=tool_choices, value=list(manifest.tools), label="tool grants · MCP capabilities", info="Capability-checked: the mind may only call what is ticked here.", interactive=True, ) persona = gr.Textbox( value=(manifest.persona or "").strip(), label="persona · injected into every prompt", info="Leave unchanged to keep the cast's written identity.", lines=3, interactive=True, ) with gr.Accordion("schedule · when this mind acts", open=False): with gr.Row(): tick_every = gr.Number( value=manifest.schedule.tick_every, label="tick every (turns · blank = event-driven)", precision=0, minimum=0, maximum=_TICK_MAX, interactive=True, ) max_consecutive = gr.Number( value=manifest.schedule.max_consecutive, label="max consecutive turns", precision=0, minimum=1, maximum=_CONSECUTIVE_MAX, interactive=True, ) return AgentPanel( name=manifest.name, model=model, tools=tools_group, persona=persona, tick_every=tick_every, max_consecutive=max_consecutive, )