Skip to content

Latest commit

 

History

History
85 lines (69 loc) · 5.77 KB

File metadata and controls

85 lines (69 loc) · 5.77 KB
concept AgentSurface
status stable
tracked_sources
ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/events/surface/**
ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/event/AgentSurfaceEvent.kt
docs/ampere/agent-surface.md
related
EventSerialBus
PluginPermissions
last_verified 2026-04-29

AgentSurface

What it is

AgentSurface is a typed, serializable description of a UI render request emitted by a plugin (or an agent) when it needs human input or wants to display a structured result. Variants are sealed: Form (multi-field input), Choice (single/multi-select picker), Confirmation (accept / reject with severity), Card (slot-based rich content). Every variant carries a correlationId so the emitter can await the matching AgentSurfaceResponse without coupling to bus internals.

Platform renderers — Compose Multiplatform on Desktop / Android, native UI on iOS, possibly text on CLI — translate each variant into local UI. The contract lives in commonMain and intentionally references no platform or framework types.

Why it exists

A plugin running in commonMain code cannot know what the host platform is. If the contract for "show the user a confirm dialog" leaked any Compose, SwiftUI, or terminal-rendering reference, plugins would either need to import it (impossible across platforms) or every platform would need a parallel plugin API (combinatorial). Instead, the plugin describes what it wants the user to do and the platform decides how to render that.

Three design pressures:

  1. Plugins must be platform-agnostic. Treat the surface contract as the entire plugin↔host UI vocabulary. New surface kinds require a commonMain change followed by per-platform renderer changes — never one without the other.
  2. The model can't draw UI. An LLM can't render pixels, but it can choose which surface to emit and fill in the fields. Sealed variants constrain the choice to four well-understood shapes.
  3. Responses are paired, not pushed. correlationId makes a request and its response a transactional unit. Plugins use awaitSurfaceResponse(correlationId) to suspend until the user replies. Without correlation, async UI events would race.

Where it lives

  • agents/events/surface/AgentSurface.kt — the sealed AgentSurface interface and four variants.
  • agents/events/surface/AgentSurfaceField.kt — typed fields for Form (Text, Number, Choice, Date, …).
  • agents/events/surface/AgentSurfaceResponse.kt — the response shape, also keyed by correlationId.
  • agents/events/surface/AgentSurfaceBusExt.ktawaitSurfaceResponse extension.
  • agents/domain/event/AgentSurfaceEvent.kt — the bus event carrying a surface request.
  • docs/ampere/agent-surface.md — design doc with renderer guidance.

Invariants

  • No platform types in the contract. AgentSurface and its variants reference only kotlinx.serialization, commonMain types, and primitives. A Composable, UIView, View, or terminal type appearing in this package is a violation.
  • correlationId pairs request and response. Every variant requires one; every AgentSurfaceResponse carries the same id. Renderers must propagate it. Generating new ids on the response side defeats the pairing.
  • Variants are sealed. New surface kinds extend the sealed hierarchy; plugins can't define their own. This is what makes per-platform renderers exhaustively switchable.
  • Surfaces are emitted via the bus, not direct method calls. AgentSurfaceEvent carries the surface to the platform layer. A plugin reaching directly into a renderer skips logging, replay, and trace.
  • Field constraints in Form are validated by the renderer, then again by the response handler. Don't trust the response; validate twice.
  • Card.Slot is sealed. New slot kinds add a sealed variant — they don't introduce a Custom(any) escape hatch.

Common operations

  • Ask for a typed input — emit AgentSurface.Form(correlationId, title, fields = listOf(AgentSurfaceField.Text(...))). awaitSurfaceResponse(correlationId) returns the typed field map.
  • Ask the user to pickAgentSurface.Choice(...); set multiSelect = true for multi-pick, otherwise it's single-select.
  • Confirm a destructive actionAgentSurface.Confirmation(... severity = Severity.Destructive) so the renderer can style accordingly without leaking renderer types.
  • Display a structured resultAgentSurface.Card(title, slots = listOf(Slot.Heading(...), Slot.KeyValue(...))).
  • Add a new surface kind — extend the sealed AgentSurface (and Slot for cards). Add a renderer for each platform; CI must show all renderers updated together.

Anti-patterns

  • Emitting strings or markdown when a typed surface fits. "Please enter your email:" printed to a log loses the structure that lets the platform render properly and lets the trace show what the user was asked.
  • Generating a new correlationId in the renderer. Now the request and response don't pair, and awaitSurfaceResponse hangs forever.
  • Importing Compose / UIKit / terminal types into agents/events/surface/. Even "for one helper". Once it's in commonMain, every platform inherits it (or fails to compile).
  • Using AgentSurface.Card slot escape hatches like Body(text = "<html>...") to embed arbitrary markup. Slots are typed for a reason; HTML in Body defeats per-platform rendering.
  • Branching renderer behaviour on string-matched titles. "If title contains 'destructive' show red." Use Severity on Confirmation instead — that's what it's for.
  • Skipping the bus: passing an AgentSurface directly to a renderer instance. Bypasses AgentSurfaceEvent, so the trace can't show the user prompt and the recall layer can't see what was asked.