multi-agent-lab / src /ui /fishbowl /render /agent_panel.py
agharsallah
fix(fishbowl): make the model pickers click-to-select dropdowns (filterable=False)
b3189b5
Raw
History Blame Contribute Delete
6.13 kB
"""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"<span class='lab-chip'><span class='lab-chip-k'>{html.escape(label)}</span>"
f"<span class='lab-chip-v'>{safe}</span></span>"
)
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"<div class='lab-chips'>{chips}</div>"
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,
)