agui

package
v0.715.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 15, 2026 License: MIT Imports: 37 Imported by: 0

Documentation

Overview

Package agui implements the AG-UI protocol (Agent-User Interaction Protocol, https://docs.ag-ui.com) as an isolated side-car adapter on top of Pando.

AG-UI is the wire contract CopilotKit and other Generative-UI frontends speak to any agent backend. Making Pando speak it is what lets a React application drive a Pando agent with `<CopilotKit agent="pando">` and zero Pando-specific frontend code.

Architectural invariants

This package is deliberately a leaf adapter. It must never become a second implementation of the agent, and it must never disturb the surfaces that already exist (TUI, Web-UI, ACP). The following invariants are load-bearing:

  • I1: no change to agent.NewAgent / agent.Run signatures. This package builds its OWN agent.Service instances through the already-exported constructors, exactly like internal/app/app.go does.
  • I2: app.CoderAgent is never read or mutated here. TUI/Web-UI/ACP and the api BackgroundSessionManager keep using it untouched.
  • I3: no AG-UI prompt ever reaches a desktop user. The runtime creates its own permission.Service and userinput.Service, so approvals and questions raised by a web run stay inside this adapter.
  • I4: no new event types in agent.AgentEvent. Translation to AG-UI events is one-way and lives in translate.go.
  • I5: off by default and removable. Deleting this package plus the ~90 lines of wiring in config/app/api restores the previous tree.
  • I6: no *app.App import (that would also be an import cycle); dependencies arrive through the narrow Deps struct.
  • I7: its own route namespace, its own auth/CORS policy, optionally its own listener.

Implementation status

P0 (protocol layer), P1 (runtime, agent pool, thread map, translation, endpoint), P2 (shared state), P3 (frontend tools) and P4 (human in the loop) are implemented:

  • P2: STATE_SNAPSHOT after every RUN_STARTED and STATE_DELTA (RFC-6902) for todos, token usage and touched files. See state.go.

  • P3: RunAgentInput.Tools become blocking tools.BaseTool proxies; a call suspends the run with RUN_FINISHED{outcome:"interrupt"} and is resolved by the tool message of the next request on the thread. Runs are therefore detached from their HTTP request; see run.go and frontend_tool.go.

  • P4: permission prompts reach the client as a synthetic pando_permission_request tool call, and AskUserQuestion is substituted by a tool that waits on the client instead of on a local overlay. Both fail closed (deny / cancel) when nobody answers. Gated by Config.HumanInTheLoop; with it off the pre-P4 policy applies. See hitl.go.

  • P5: durable thread->session mapping in the adapter-owned agui_threads table (threads.go), hardened /info discovery, and the dedicated listener of invariant I7 (listener.go), reachable through `pando serve --agui-port` or the standalone `pando agui-serve` process.

  • P6: the client half, outside this package — the `@pando-ai/sdk/agui` subpath export and the Next.js application under examples/copilotkit. No Go code was involved, which is the point: the protocol is the contract.

  • P7: mesnada sub-agents are projected into the shared-state document as StateDoc.SubAgents (subagents.go), derived from the mesnada_* tool traffic the adapter already observes — the orchestrator is never consulted and no agent event type was added.

  • P8 (PANDO-EP-0003): the native thread API — GET {path}/threads (paginated, newest-first, scoped to the agui_threads table this adapter owns), GET {path}/threads/{id}/messages and DELETE {path}/threads/{id} — replaces the interim workaround of proxying the Web-UI REST API's session endpoints, so a browser client can rebuild a conversation without co-mounting it (see threads.go). The AG-UI Message[] conversion it needed (transcript.go) is reused by MESSAGES_SNAPSHOT: the first run this process serves for a pre-existing thread's attach resynchronises the client in-band, right after STATE_SNAPSHOT, capped to a configured message count/byte budget and flagged truncated when it is (server.go's runPrelude).

Tool calls that never reach the event stream

agent.processEvent only publishes AgentEventTypeToolCall for providers that stream tool use incrementally (EventToolUseStart/Delta/Stop). A provider that reports its tool calls in one final EventComplete leaves the assistant message correct while the event stream stays silent about them. Every other surface renders from the database and never notices; this adapter has only the stream, so it must reconstruct what the stream omits: a suspending call carries its own name and arguments (suspension.call) so the handler can emit START/ARGS/END before the interrupt, and a tool result for a call that was never opened opens it first (translate.go), because a bare TOOL_CALL_END is rejected by AG-UI clients as a protocol error that aborts the whole run.

Tool results are not JSON

tools.NewStructuredResponse renders TOON when it can, TOML next and indented JSON only as a last resort, and it chooses per value. Any code here that reads a tool result structurally must go through decodeToolResult (subagents.go); json.Unmarshal alone silently sees nothing, which is what kept the sub-agent board empty until 2026-07-29.

The state document belongs to the thread

StateDoc is per thread and lives in the Runtime's stateStore, not in the run: a conversation's todos, touched files and sub-agents must survive its turns. A per-run document made them vanish on every new message. The store is memory only — the durable half is the thread->session binding in threads.go — and it evicts the least recently used threads past maxStateThreads.

Deliberately not implemented

CopilotKit's own runtime protocol (GraphQL) is not served by Pando. The remaining half of P7 was "skip the Node hop" by embedding that runtime; it is declined on purpose. AG-UI is the protocol every agent backend implements and CopilotKit's runtime already translates it, so reimplementing that runtime in Go would buy one process hop at the cost of tracking a second, faster-moving protocol — and it would move the API token into the browser, which is the one place it must not be.

The AG-UI spec revision targeted here is the one documented at https://docs.ag-ui.com as of 2026-07-28.

Run lifetime and durability (PANDO-EP-0003)

  • PANDO-US-0017: a browser disconnecting mid-run no longer cancels it. The run's underlying agent event stream is drained by one long-lived goroutine, pump (run.go), started once per run and independent of any particular HTTP request; a disconnect merely detaches the request and, once the last attach is gone, arms Config.DisconnectGrace before tearing the run down. Every event the pump produces is also appended to a bounded ring (eventBuffer) for a later reattach to replay; once full it drops the oldest event and marks itself lossy rather than growing.

  • PANDO-US-0018: GET {path}/threads/{id}/stream (and a POST carrying no new user message) reattaches to a thread's live run: subscribe, replay the buffer, then continue live — any number of attaches (the original stream, a reattach, read-only followers) may be registered on one run at once. Only the pump ever touches a run's translator, which is what keeps translate.go's single-writer, stateful design safe under concurrent attaches; a resumption after an interrupt installs a new translator (Runtime.beginResumeSegment) strictly before delivering the tool result that would otherwise let the pump observe a new segment's event through the old, already-closed-out one. handleRun's decide-whether-to-resume/reject/start-a-run section is serialized per thread (runStore.lockThread), closing the TOCTOU where two POSTs could both see no live run and both race into svc.Run.

  • PANDO-US-0019: POST {path}/runs/{id}/cancel ends a thread's run, live or parked, from any request — not only the one streaming it. pendingRegistry.cancelAll force-delivers a cancellation to every call still waiting on the client, releasing a permission/question wait (which selects on the adapter's base context, not the run's) that a mere context cancellation would not reach. Cancellation, like a natural finish, is applied by the pump (activeRun.requestCancel wakes it via a dedicated signal channel) so RUN_ERROR{code:"cancelled"} reaches every attached stream through the one translator, never raced against it from the HTTP handler's own goroutine.

Operability for embedded deployments (PANDO-EP-0004)

  • PANDO-US-0020: GET {path}/healthz is the one route Register mounts outside authorize() — see server.go's handleHealthz. It answers 200 with status, version, uptime and (below) the concurrency gauge and draining flag, and nothing else: no agent/profile list, no origins, no token, no session or thread identifier.

  • PANDO-US-0021: Config.MaxConcurrentRuns caps runs the adapter admits, enforced by runAdmission (admission.go) before any session, agent instance or thread binding is created. A slot is held for a run's whole lifetime, including while suspended waiting on a client — a parked run still occupies one — and released exactly once, by Runtime.finishRun, guarded by activeRun.stop()'s once-only return so a run finalized from more than one path never double-releases. A resumption of an already-admitted run never calls tryAdmit again. Over the cap, handleRun answers 503 + Retry-After through Runtime.rejectOverCapacity before opening any stream.

  • PANDO-US-0022: Config.ShutdownGrace bounds how long Runtime.Close waits for in-flight runs to finish before falling back to the hard cancel it always did for whatever is left, logging the cut count. Close begins by draining (Runtime.StartDraining), so handleRun rejects every new run through the same PANDO-US-0021 path for the rest of shutdown. A suspended run is never waited on — nobody is going to answer a human-in-the-loop prompt inside a shutdown window — so it is released immediately, cancelAll'd first so a hitl.go wait (which selects on the adapter's base context, not the run's) is actually unblocked. cmd/agui_serve.go orders listener.Shutdown before the deferred Runtime.Close, both bounded by the same configured grace.

  • PANDO-US-0023: agui-serve resolves its bearer token in precedence --token > --token-file > PANDO_AGUI_TOKEN > generated (cmd/agui_serve.go's resolveAGUIToken), rejecting an explicitly supplied-but-empty source as a startup error rather than falling through. The token is never logged at any level, including the startup config dump (New's "AG-UI adapter ready" line never carries Deps.Token), and is printed to stdout only in the generated case — the only one the operator has no other way to learn it.

Reverse-proxy contract (PANDO-US-0024)

Everything a product's own backend needs to know to sit between a browser and this adapter. Each fact is pinned to the line that enforces it today, verified against the source at the time this section was written — if a future refactor moves these lines, update the citations, not just the prose.

  • Origin: authorize (server.go:71-95) skips the AllowedOrigins check entirely when the Origin header is absent (server.go:72-73: "origin != \"\" && !r.originAllowed(origin)") — a server-to-server proxy that does not forward the browser's own Origin needs no Config.AllowedOrigins entry at all, and that is the recommended shape: the browser authenticates to the proxy, the proxy talks to Pando, and Pando never sees a browser Origin to check. agui-serve's startup warning "AG-UI server has no allowed origins" (cmd/agui_serve.go:125-127) is therefore correct to ignore in this deployment shape — it exists to warn about the OTHER shape, a browser reaching this adapter directly. If a proxy instead forwards the browser's Origin verbatim, the exact string must be listed: originAllowed (server.go:109-116) is exact, case-insensitive match (strings.EqualFold) or the literal "*", with no wildcard subdomain or port pattern support.

  • Token: bearerToken (server.go:97-103) accepts "Authorization: Bearer <token>" first, falling back to a "?token=" query parameter. The fallback exists only because the browser's native EventSource API cannot set request headers, and it must never be used from a browser-originated request: a query string is captured in access logs, in the Referer header of any same-page navigation, and in browser history. A proxy in front of this adapter should strip any inbound "?token=" and set the real "Authorization" header itself, keeping the Pando token entirely server-side — the browser authenticates to the PROXY under whatever scheme the product already uses, and never learns Pando's own token. A RequireToken deployment with no Deps.Token configured fails closed with 500 rather than silently degrading into an open endpoint (server.go:83-88).

  • Streaming: NewSSEWriter (sse.go:33-46) sets "X-Accel-Buffering: no" (sse.go:43) and flushes after every event (Write/Comment both call flusher.Flush(), sse.go:63,88) plus a ": keep-alive" comment every 15s of otherwise-silent heartbeat (defaultHeartbeat, deps.go:162; ticker armed in attachLoop, server.go:790-791; emitted at server.go:801-803 — an SSE comment, invisible to a client parsing "data:" frames). A Go httputil.ReverseProxy therefore needs FlushInterval: -1 (flush after every write, never batch), and any intermediary must not buffer the response body at all. The dedicated listener sets WriteTimeout: 0 on purpose, because a run's response is exactly as long-lived as the agent takes (listener.go:88-92); a proxy's own write/idle timeout must be 0 or comfortably above the 15s heartbeat — below it, a slow tool call reads as a dead connection and the intermediary cuts the stream.

  • /info URL rewriting: requestBaseURL (server.go:346-358) builds every Agents[].url from req.Host, honouring X-Forwarded-Proto for the scheme ONLY when the request did not already arrive over TLS, and deliberately never reads X-Forwarded-Host (an attacker-controlled value there would let /info hand out URLs pointing at somebody else's server). Behind a proxy that rewrites the request path (e.g. strips a "/pando" prefix) those URLs come back wrong to use as-is: either rewrite the Host header upstream to the public host the browser actually used, or ignore /info's URLs entirely and construct the run endpoint yourself from the proxy's own configured origin plus the path /info reports.

  • TLS: agui-serve self-signs a certificate into the data directory unless --no-tls is given (cmd/agui_serve.go:144-157); --tls-cert / --tls-key substitute a certificate of your own. On loopback behind a proxy that already terminates TLS for the browser, --no-tls is the pragmatic choice for the Pando-facing hop; across any other network boundary, pin the certificate instead of disabling TLS.

  • Body limit: a RunAgentInput body over defaultMaxRequestBytes (8 MiB, sse.go:13) is truncated by the io.LimitReader DecodeRunAgentInput wraps the request body in (input.go:169-182) and then fails to decode — a proxy must not impose a tighter body-size limit of its own without raising it to match, or a legitimate long conversation's resent transcript can be cut off before Pando ever sees it.

A copy-pasteable Go proxy implementing all of the above (newReverseProxy) lives at examples/vite-react/proxy/main.go, compiled by examples/vite-react/proxy/example_test.go so it cannot silently stop building; the same snippet is mirrored in sdk/typescript/README.md for SDK consumers who never clone this repository. examples/vite-react/ is the worked SPA client for it — see PANDO-US-0024's story for scope.

Index

Constants

View Source
const (
	// OutcomeSuccess marks a run that completed on its own.
	OutcomeSuccess = "success"
	// OutcomeInterrupt marks a run that stopped waiting for the client (a
	// frontend tool result, a human decision). The agent-side run may still be
	// alive; the client resumes it with the next request on the same thread.
	OutcomeInterrupt = "interrupt"
)

Run outcomes reported by RunFinishedEvent.

View Source
const (
	RoleDeveloper = "developer"
	RoleSystem    = "system"
	RoleAssistant = "assistant"
	RoleUser      = "user"
	RoleTool      = "tool"
	RoleActivity  = "activity"
	RoleReasoning = "reasoning"
)

Message roles defined by AG-UI.

View Source
const (
	ContentText     = "text"
	ContentImage    = "image"
	ContentAudio    = "audio"
	ContentVideo    = "video"
	ContentDocument = "document"
)

Multimodal input content kinds.

View Source
const (
	FileActionRead    = "read"
	FileActionWrite   = "write"
	FileActionEdit    = "edit"
	FileActionPatched = "patch"
)

File actions reported in StateDoc.Files.

Variables

View Source
var ErrInvalidInput = errors.New("agui: invalid RunAgentInput")

ErrInvalidInput is returned by DecodeRunAgentInput when the payload does not satisfy the protocol's minimum requirements.

View Source
var ErrStreamingUnsupported = errors.New("agui: streaming unsupported by the response writer")

ErrStreamingUnsupported is returned when the ResponseWriter cannot flush, in which case SSE cannot be served at all.

Functions

This section is empty.

Types

type ActivitySnapshotEvent

type ActivitySnapshotEvent struct {
	BaseEvent
	MessageID    string `json:"messageId"`
	ActivityType string `json:"activityType"`
	Content      any    `json:"content"`
	Replace      bool   `json:"replace,omitempty"`
}

func NewActivitySnapshot

func NewActivitySnapshot(messageID, activityType string, content any) ActivitySnapshotEvent

type AgentDescriptor

type AgentDescriptor struct {
	Name        string           `json:"name"`
	Description string           `json:"description,omitempty"`
	URL         string           `json:"url"`
	Model       *ModelDescriptor `json:"model,omitempty"`
}

AgentDescriptor is one entry of the /info response.

type BaseEvent

type BaseEvent struct {
	Type      EventType `json:"type"`
	Timestamp int64     `json:"timestamp,omitempty"`
	RawEvent  any       `json:"rawEvent,omitempty"`
}

BaseEvent carries the fields every AG-UI event shares.

func (BaseEvent) EventType

func (b BaseEvent) EventType() EventType

EventType implements Event.

type Capabilities

type Capabilities struct {
	// FrontendTools reports whether RunAgentInput.tools are proxied.
	FrontendTools bool `json:"frontendTools"`
	// HumanInTheLoop reports whether permission prompts and agent questions
	// reach the client as tool calls.
	HumanInTheLoop bool `json:"humanInTheLoop"`
	// SharedState reports STATE_SNAPSHOT / STATE_DELTA support.
	SharedState bool `json:"sharedState"`
	// Interrupts reports RUN_FINISHED{outcome:"interrupt"} and resumption.
	Interrupts bool `json:"interrupts"`
}

Capabilities advertises the optional halves of the protocol this adapter implements, so a client can tell "not supported" from "nothing happened" without probing.

type Config

type Config struct {
	Path string
	// Port serves the adapter on its own listener when > 0. Zero means the
	// adapter is mounted on the API server's mux.
	Port           int
	Agents         []config.AgentName
	AllowedOrigins []string
	RequireToken   bool
	FrontendTools  bool
	AgentPoolSize  int
	AgentPoolTTL   time.Duration
	AutoApprove    bool
	// HumanInTheLoop surfaces permission prompts and questions to the AG-UI
	// client. With it off (and AutoApprove off) a run that needs approval is
	// denied instead of asking, which is the pre-P4 behaviour.
	HumanInTheLoop bool
	// Persona names a persona injected into the system prompt of every run, via
	// a per-session persona override. Empty means no override: the
	// process-wide active persona (or auto-selection) applies as usual.
	Persona string
	// Tools is the adapter-wide glob allow-list applied to every agent's tool
	// set (see config.AGUIConfig.Tools). Empty means no restriction.
	Tools []string
	// Mesnada gates mesnada_* delegation tools independent of Tools. Resolved
	// from config.AGUIConfig.Mesnada (a *bool) to its documented default of
	// true here, so every other consumer of this already-resolved Config can
	// treat it as a plain switch.
	Mesnada bool
	// Profiles holds every declared [AGUI.Profiles.<name>] entry, resolved
	// against the adapter-wide fields above exactly the way this Config is
	// resolved from config.AGUIConfig: every fallback (Tools, Mesnada,
	// Persona) is already applied, so the pool and the /info handler treat
	// each Profile's fields as already effective. Keyed by profile name
	// (the route segment a client's POST {path}/<name> resolves against).
	Profiles map[string]Profile
	// MessagesSnapshotMaxMessages caps how many of a thread's most recent
	// messages MESSAGES_SNAPSHOT carries (PANDO-US-0016). ConfigFromApp
	// always resolves this to defaultMessagesSnapshotMaxMessages; <= 0 means
	// no count limit (only MessagesSnapshotMaxBytes applies).
	MessagesSnapshotMaxMessages int
	// MessagesSnapshotMaxBytes caps the snapshot's total JSON-encoded size,
	// in addition to MessagesSnapshotMaxMessages. ConfigFromApp always
	// resolves this to defaultMessagesSnapshotMaxBytes; <= 0 means no byte
	// limit (only MessagesSnapshotMaxMessages applies).
	MessagesSnapshotMaxBytes int
	// DisconnectGrace bounds how long a run stays parked after its last
	// attached client disconnects before it is torn down (PANDO-US-0017).
	// ConfigFromApp always resolves this to defaultDisconnectGrace.
	DisconnectGrace time.Duration
	// MaxConcurrentRuns caps how many runs the adapter admits at once
	// (PANDO-US-0021). A run holds its slot for its whole lifetime,
	// including while suspended waiting on a client. <= 0 means unlimited,
	// the pre-PANDO-US-0021 behaviour and the default.
	MaxConcurrentRuns int
	// ShutdownGrace bounds how long Runtime.Close waits for in-flight runs
	// to finish before cancelling whatever is left (PANDO-US-0022).
	// ConfigFromApp always resolves this to defaultShutdownGrace unless the
	// config file set an explicit value -- including an explicit 0, which
	// reproduces the pre-PANDO-US-0022 immediate-cancel behaviour.
	ShutdownGrace time.Duration
}

Config is the adapter's resolved configuration.

func ConfigFromApp

func ConfigFromApp(c config.AGUIConfig) Config

ConfigFromApp resolves an AGUIConfig into the adapter's own Config, applying the defaults the adapter guarantees even when the config file predates this feature.

type Context

type Context struct {
	Description string `json:"description"`
	Value       string `json:"value"`
}

Context is ambient information the page attaches to the run.

type CustomEvent

type CustomEvent struct {
	BaseEvent
	Name  string `json:"name"`
	Value any    `json:"value,omitempty"`
}

func NewCustom

func NewCustom(name string, value any) CustomEvent

NewCustom builds a CUSTOM event. Pando-specific signals that have no AG-UI counterpart are emitted this way, namespaced as "pando.<something>", so a generic AG-UI client can ignore them safely.

type Deps

type Deps struct {
	Sessions     session.Service
	Messages     message.Service
	History      history.Service
	Skills       *skills.SkillManager
	Gateway      *mcpgateway.Gateway
	Orchestrator *orchestrator.Orchestrator
	Remembrances *rag.RemembrancesService
	// LSP is the language-server provider used by the edit/view tools.
	// *app.App satisfies it.
	LSP tools.LSPProvider
	// DB is the shared SQLite connection, used only for the adapter's own
	// agui_threads table (thread -> session bindings). It is optional: with a nil
	// DB the mapping is in-memory and does not survive a restart.
	DB *sql.DB
	// Token is the API bearer token clients must present when
	// Config.RequireToken is set. It is supplied by the caller so the adapter
	// shares the API server's token instead of minting a second one.
	Token string
}

Deps are the collaborators the adapter needs to build its own agents. It mirrors the arguments internal/app/app.go already passes to agent.CoderAgentToolsWithMesnada, minus the two services the adapter creates itself (permissions and user input, see invariant I3 in doc.go) and minus the agent itself (invariant I2).

The struct exists so this package never imports *app.App: that would be an import cycle and would also make the adapter reachable from the rest of the application, which is exactly what the isolation invariants forbid.

type Event

type Event interface {
	EventType() EventType
}

Event is any AG-UI event. Implementations embed BaseEvent.

type EventType

type EventType string

EventType is the AG-UI event discriminator. Values are serialized uppercase with underscores, matching the protocol's EventType enum.

const (
	EventTextMessageStart   EventType = "TEXT_MESSAGE_START"
	EventTextMessageContent EventType = "TEXT_MESSAGE_CONTENT"
	EventTextMessageEnd     EventType = "TEXT_MESSAGE_END"
	EventTextMessageChunk   EventType = "TEXT_MESSAGE_CHUNK"

	EventToolCallStart  EventType = "TOOL_CALL_START"
	EventToolCallArgs   EventType = "TOOL_CALL_ARGS"
	EventToolCallEnd    EventType = "TOOL_CALL_END"
	EventToolCallResult EventType = "TOOL_CALL_RESULT"

	EventStateSnapshot    EventType = "STATE_SNAPSHOT"
	EventStateDelta       EventType = "STATE_DELTA"
	EventMessagesSnapshot EventType = "MESSAGES_SNAPSHOT"

	EventActivitySnapshot EventType = "ACTIVITY_SNAPSHOT"
	EventActivityDelta    EventType = "ACTIVITY_DELTA"

	EventRaw    EventType = "RAW"
	EventCustom EventType = "CUSTOM"

	EventRunStarted  EventType = "RUN_STARTED"
	EventRunFinished EventType = "RUN_FINISHED"
	EventRunError    EventType = "RUN_ERROR"

	EventStepStarted  EventType = "STEP_STARTED"
	EventStepFinished EventType = "STEP_FINISHED"

	EventReasoningStart          EventType = "REASONING_START"
	EventReasoningMessageStart   EventType = "REASONING_MESSAGE_START"
	EventReasoningMessageContent EventType = "REASONING_MESSAGE_CONTENT"
	EventReasoningMessageEnd     EventType = "REASONING_MESSAGE_END"
	EventReasoningEnd            EventType = "REASONING_END"
)

type FileState

type FileState struct {
	Path   string `json:"path"`
	Name   string `json:"name"`
	Action string `json:"action"`
}

FileState is one workspace file the run touched.

type HealthResponse added in v0.715.6

type HealthResponse struct {
	Status  string `json:"status"`
	Version string `json:"version"`
	// UptimeSeconds is how long this adapter instance has been running.
	UptimeSeconds float64 `json:"uptimeSeconds"`
	// ActiveRuns is the number of runs currently holding an admission slot
	// (PANDO-US-0021): running, or suspended waiting on a client -- a parked
	// run still occupies its slot, so it is included here too.
	ActiveRuns int `json:"activeRuns"`
	// MaxConcurrentRuns is the configured cap (Config.MaxConcurrentRuns); 0
	// means unlimited, matching the config's own default semantics.
	MaxConcurrentRuns int `json:"maxConcurrentRuns"`
	// Draining reports whether the adapter is shutting down and no longer
	// admitting new runs (PANDO-US-0022), so a load balancer can take this
	// instance out of rotation.
	Draining bool `json:"draining"`
}

HealthResponse is the GET {path}/healthz payload (PANDO-US-0020). It is deliberately minimal and carries nothing an unauthenticated caller shouldn't see: no agent/profile names, no configured path, no allowed origins, no token or anything token-derived, and no session or thread identifier. ActiveRuns/MaxConcurrentRuns (PANDO-US-0021) and Draining (PANDO-US-0022) are the one aggregate signal about user activity this payload carries -- a concurrency gauge and a drain flag, never a count or list that could identify a particular user or conversation.

type InfoResponse

type InfoResponse struct {
	Protocol     string            `json:"protocol"`
	Version      string            `json:"version,omitempty"`
	Path         string            `json:"path"`
	Agents       []AgentDescriptor `json:"agents"`
	Capabilities Capabilities      `json:"capabilities"`
}

InfoResponse is the agent-discovery payload.

type InputContent

type InputContent struct {
	Type string `json:"type"`
	Text string `json:"text,omitempty"`
	// URL/Data carry non-text parts. Only text is consumed today; other kinds
	// are preserved so P2+ can map them onto message.Attachment.
	URL      string `json:"url,omitempty"`
	Data     string `json:"data,omitempty"`
	MimeType string `json:"mimeType,omitempty"`
}

InputContent is one part of a multimodal user message.

type JSONPatchOperation

type JSONPatchOperation struct {
	Op    string `json:"op"`
	Path  string `json:"path"`
	Value any    `json:"value,omitempty"`
	From  string `json:"from,omitempty"`
}

JSONPatchOperation is a single RFC-6902 operation as carried by StateDelta.

type Listener

type Listener struct {
	// contains filtered or unexported fields
}

Listener is a running dedicated AG-UI listener.

func (*Listener) Addr

func (l *Listener) Addr() string

Addr is the address the listener actually bound to.

func (*Listener) Shutdown

func (l *Listener) Shutdown(ctx context.Context) error

Shutdown stops the listener. It does not close the Runtime: the caller owns that, because the same Runtime may also be mounted elsewhere.

func (*Listener) URL

func (l *Listener) URL() string

URL is the base URL clients should point CopilotKit at.

func (*Listener) Wait

func (l *Listener) Wait() error

Wait blocks until the listener stops, returning the reason. A clean shutdown reports nil.

type ListenerOptions

type ListenerOptions struct {
	// Host to bind to. Empty means localhost, never all interfaces: this
	// surface drives an agent that executes code, so exposing it on 0.0.0.0 has
	// to be a deliberate act.
	Host string
	// CertFile and KeyFile enable TLS. Both or neither.
	CertFile string
	KeyFile  string
}

ListenerOptions describes the dedicated listener requested by Config.Port.

type Message

type Message struct {
	ID           string         `json:"id"`
	Role         string         `json:"role"`
	Content      MessageContent `json:"content,omitempty"`
	Name         string         `json:"name,omitempty"`
	ToolCalls    []ToolCall     `json:"toolCalls,omitempty"`
	ToolCallID   string         `json:"toolCallId,omitempty"`
	Error        string         `json:"error,omitempty"`
	ActivityType string         `json:"activityType,omitempty"`
}

Message is one entry of the AG-UI conversation.

type MessageContent

type MessageContent struct {
	Text  string
	Parts []InputContent
}

MessageContent holds either a plain string or a list of multimodal parts. AG-UI allows both shapes on user messages.

func (MessageContent) HasNonTextParts

func (m MessageContent) HasNonTextParts() bool

HasNonTextParts reports whether the message carries content this adapter cannot forward to the agent yet.

func (MessageContent) MarshalJSON

func (m MessageContent) MarshalJSON() ([]byte, error)

MarshalJSON writes back the shape the content was received in.

func (MessageContent) String

func (m MessageContent) String() string

String flattens the content to plain text, joining the text parts of a multimodal message. Non-text parts are skipped (see doc.go, P2).

func (*MessageContent) UnmarshalJSON

func (m *MessageContent) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts either a JSON string or an array of InputContent.

type MessagesSnapshotEvent

type MessagesSnapshotEvent struct {
	BaseEvent
	Messages []Message `json:"messages"`
	// Truncated reports whether Messages was cut down to the adapter's
	// configured cap (max message count and/or max bytes), keeping the most
	// recent messages (PANDO-US-0016). A client that sees it true knows the
	// transcript is partial and must not treat index 0 as the conversation's
	// start.
	Truncated bool `json:"truncated,omitempty"`
}

func NewMessagesSnapshot

func NewMessagesSnapshot(msgs []Message, truncated bool) MessagesSnapshotEvent

type ModelDescriptor

type ModelDescriptor struct {
	ID            string `json:"id"`
	Name          string `json:"name,omitempty"`
	Provider      string `json:"provider,omitempty"`
	ContextWindow int64  `json:"contextWindow,omitempty"`
}

ModelDescriptor tells the client what it is talking to, so a dashboard can show the model and size its own context budget. It carries no credentials and no provider endpoint: the provider is named, never located.

type ModelState

type ModelState struct {
	ID            string `json:"id"`
	Name          string `json:"name,omitempty"`
	Provider      string `json:"provider,omitempty"`
	ContextWindow int64  `json:"contextWindow,omitempty"`
}

ModelState describes the model backing the thread.

type Profile added in v0.715.6

type Profile struct {
	// Name is the profile's route name: the map key in
	// config.AGUIConfig.Profiles and in Config.Profiles.
	Name string
	// Base is the built-in agent this profile's model/token configuration is
	// inherited from.
	Base config.AgentName
	// Model overrides Base's configured model for runs served under this
	// profile. Empty means inherit Base's own configured model.
	Model models.ModelID
	// Persona overrides the adapter-wide Config.Persona. Already resolved:
	// empty here means neither the profile nor the adapter declared one, so
	// callers must not fall back to Config.Persona a second time.
	Persona string
	// Prompt is extra system-prompt text for runs served under this profile.
	// There is no adapter-wide equivalent to fall back to.
	Prompt string
	// Tools is this profile's resolved glob allow-list: the profile's own
	// declared list (even an explicit empty one) when it set one, otherwise
	// the adapter-wide Config.Tools. Already resolved: pass it straight to
	// filterAGUITools, the fallback has already been applied.
	Tools []string
	// DenyTools is this profile's glob deny-list. There is no adapter-wide
	// equivalent; nil/empty both mean no deny-list.
	DenyTools []string
	// Mesnada is this profile's resolved Mesnada switch: the profile's own
	// value when it set one, otherwise the adapter-wide Config.Mesnada.
	Mesnada bool
}

Profile is one resolved AG-UI profile: a Base built-in agent plus the overrides (persona, prompt, model, tool allow/deny lists, mesnada switch) that let one process serve several restricted assistants without running one process per profile. Resolved from config.AGUIProfile in ConfigFromApp, the same way Config is resolved from config.AGUIConfig.

type RawEvent

type RawEvent struct {
	BaseEvent
	Event  any    `json:"event"`
	Source string `json:"source,omitempty"`
}

func NewRaw

func NewRaw(event any, source string) RawEvent

type ReasoningEndEvent

type ReasoningEndEvent struct {
	BaseEvent
	MessageID string `json:"messageId"`
}

func NewReasoningEnd

func NewReasoningEnd(messageID string) ReasoningEndEvent

type ReasoningMessageContentEvent

type ReasoningMessageContentEvent struct {
	BaseEvent
	MessageID string `json:"messageId"`
	Delta     string `json:"delta"`
}

func NewReasoningMessageContent

func NewReasoningMessageContent(messageID, delta string) ReasoningMessageContentEvent

type ReasoningMessageEndEvent

type ReasoningMessageEndEvent struct {
	BaseEvent
	MessageID string `json:"messageId"`
}

func NewReasoningMessageEnd

func NewReasoningMessageEnd(messageID string) ReasoningMessageEndEvent

type ReasoningMessageStartEvent

type ReasoningMessageStartEvent struct {
	BaseEvent
	MessageID string `json:"messageId"`
	Role      string `json:"role"`
}

func NewReasoningMessageStart

func NewReasoningMessageStart(messageID string) ReasoningMessageStartEvent

type ReasoningStartEvent

type ReasoningStartEvent struct {
	BaseEvent
	MessageID string `json:"messageId"`
}

func NewReasoningStart

func NewReasoningStart(messageID string) ReasoningStartEvent

type RunAgentInput

type RunAgentInput struct {
	ThreadID       string    `json:"threadId"`
	RunID          string    `json:"runId"`
	ParentRunID    string    `json:"parentRunId,omitempty"`
	State          any       `json:"state,omitempty"`
	Messages       []Message `json:"messages,omitempty"`
	Tools          []Tool    `json:"tools,omitempty"`
	Context        []Context `json:"context,omitempty"`
	ForwardedProps any       `json:"forwardedProps,omitempty"`
}

RunAgentInput is the request body of an AG-UI run. It is sent in full on every turn: the client owns the visible transcript, so `Messages` carries the whole conversation even though Pando keeps its own history server-side.

func DecodeRunAgentInput

func DecodeRunAgentInput(r io.Reader, maxBytes int64) (*RunAgentInput, error)

DecodeRunAgentInput reads and validates a RunAgentInput from an HTTP body. maxBytes caps the payload; pass 0 for the default.

func (*RunAgentInput) ContextBlock

func (in *RunAgentInput) ContextBlock() string

ContextBlock renders the client-supplied context entries as a text block that can be prefixed to the prompt. Returns "" when there is no context.

func (*RunAgentInput) LastUserMessage

func (in *RunAgentInput) LastUserMessage() (Message, bool)

LastUserMessage returns the trailing user message, which is the only part of the transcript this adapter forwards to Pando: the agent keeps its own history, so replaying the whole array would duplicate context and defeat compaction and prompt caching.

func (*RunAgentInput) TrailingToolMessages

func (in *RunAgentInput) TrailingToolMessages() []Message

TrailingToolMessages returns the tool messages that appear after the last user message. They are frontend tool results resolving a previously interrupted run rather than a new prompt (P3 consumes them).

func (*RunAgentInput) Validate

func (in *RunAgentInput) Validate() error

Validate enforces the protocol's required fields.

type RunErrorEvent

type RunErrorEvent struct {
	BaseEvent
	Message string `json:"message"`
	Code    string `json:"code,omitempty"`
}

func NewRunError

func NewRunError(msg, code string) RunErrorEvent

type RunFinishedEvent

type RunFinishedEvent struct {
	BaseEvent
	ThreadID string `json:"threadId"`
	RunID    string `json:"runId"`
	Outcome  string `json:"outcome,omitempty"`
	Result   any    `json:"result,omitempty"`
}

func NewRunFinished

func NewRunFinished(threadID, runID, outcome string, result any) RunFinishedEvent

type RunStartedEvent

type RunStartedEvent struct {
	BaseEvent
	ThreadID    string `json:"threadId"`
	RunID       string `json:"runId"`
	ParentRunID string `json:"parentRunId,omitempty"`
}

func NewRunStarted

func NewRunStarted(threadID, runID string) RunStartedEvent

type Runtime

type Runtime struct {
	// contains filtered or unexported fields
}

Runtime is the AG-UI adapter. It owns everything the protocol needs that Pando does not already provide: its own agent instances, its own permission and user-input services, and the thread bookkeeping AG-UI clients expect.

Nothing outside this package holds a reference to those objects, which is what keeps the adapter isolated from the TUI, Web-UI and ACP surfaces.

func New

func New(deps Deps, cfg Config) (*Runtime, error)

New builds the adapter. It does not start any listener; call Register to mount it on a mux.

func (*Runtime) Close

func (r *Runtime) Close()

Close begins draining (see StartDraining) so no new run is admitted, then gives every already-admitted, non-suspended run up to Config.ShutdownGrace to finish naturally -- letting the normal per-chunk message-store writes internal/llm/agent already performs while streaming catch up -- before falling back to the hard cancel this method always did for whatever is still running. A suspended run (parked on a permission prompt) is never waited on: nobody is going to answer a human-in-the-loop prompt inside a shutdown window, so it is checkpointed and released immediately regardless of grace -- its accumulated messages are already durably persisted by the same per-chunk writes, so releasing it early loses nothing that waiting would have preserved. Pooled agents are left to the garbage collector.

ShutdownGrace <= 0 reproduces the pre-PANDO-US-0022 behaviour: every run is cancelled immediately, with no wait.

func (*Runtime) Handler

func (r *Runtime) Handler() http.Handler

Handler returns a standalone mux carrying only the AG-UI routes, for serving the adapter on its own listener (Config.Port > 0).

func (*Runtime) Register

func (r *Runtime) Register(mux *http.ServeMux)

Register mounts the adapter's routes on mux:

GET    {path}/healthz               unauthenticated liveness probe (PANDO-US-0020)
POST   {path}/{agent}               run an agent, streaming AG-UI events over SSE
GET    {path}/info                  agent discovery, consumed by CopilotKit's runtime
GET    {path}/threads               list this adapter's threads, paginated newest-first
GET    {path}/threads/{id}/messages read a thread's transcript, AG-UI Message[] shaped
GET    {path}/threads/{id}/stream   reattach to a thread's live run (PANDO-US-0018)
DELETE {path}/threads/{id}          delete a thread: its messages, session and binding
POST   {path}/runs/{id}/cancel      cancel a thread's live or parked run (PANDO-US-0019)

It is the only place this package touches the outside world's routing, and it is called only when the feature is enabled. The thread routes (PANDO-US-0015) let a browser client rebuild a conversation without co-mounting the Web-UI REST API: they work on the dedicated `agui-serve` listener, which carries no REST API by design (see cmd/agui_serve.go).

/healthz is the one route that does not go through authorize(): it is registered directly against handleHealthz, never wrapped by the bearer-token check every other handler below performs on its own first line. That is a deliberate, narrow exception -- see handleHealthz's doc comment for what it is and is not allowed to expose -- and nothing else may bypass authorize() this way.

func (*Runtime) StartDraining added in v0.715.6

func (r *Runtime) StartDraining()

StartDraining stops the adapter from admitting new runs (PANDO-US-0022): handleRun starts answering every new-run POST with 503 + Retry-After, reusing the PANDO-US-0021 rejection path, while runs already in flight are left to keep streaming. It is idempotent and safe to call before Close -- Close calls it itself, first thing, but a caller that wants the /healthz draining flag to flip before the rest of teardown begins (e.g. so a load balancer notices sooner) may call it directly.

func (*Runtime) StartListener

func (r *Runtime) StartListener(opts ListenerOptions) (*Listener, error)

StartListener serves the adapter on its own port, carrying only the AG-UI routes.

This is deployment shape 2 of invariant I7: a browser origin that can reach the AG-UI endpoint then cannot reach the Web-UI API at all — no sessions list, no config, no file endpoints, no static UI. It is the recommended shape for anything beyond localhost.

The listener is bound synchronously so a port clash is reported to the caller instead of being lost in a goroutine.

type SSEWriter

type SSEWriter struct {
	// contains filtered or unexported fields
}

SSEWriter serializes AG-UI events to the Server-Sent Events wire format.

AG-UI puts the discriminator inside the JSON payload rather than in the SSE `event:` field, so every frame is a bare `data:` line. Clients (including AG-UI's HttpAgent) parse the JSON and switch on `type`.

func NewSSEWriter

func NewSSEWriter(w http.ResponseWriter) (*SSEWriter, error)

NewSSEWriter writes the SSE response headers and returns a writer bound to w. Headers other than the streaming ones (CORS, auth) must already be set.

func (*SSEWriter) Close

func (s *SSEWriter) Close()

Close marks the stream finished. Further writes are refused; the HTTP handler returning is what actually terminates the response.

func (*SSEWriter) Comment

func (s *SSEWriter) Comment(text string) error

Comment emits an SSE comment. Used as a heartbeat to keep intermediaries from dropping an idle connection while the agent is busy in a long tool call.

func (*SSEWriter) Write

func (s *SSEWriter) Write(ev Event) error

Write encodes and flushes a single event.

func (*SSEWriter) WriteAll

func (s *SSEWriter) WriteAll(events []Event) error

WriteAll encodes a batch, stopping at the first failure.

type StateDeltaEvent

type StateDeltaEvent struct {
	BaseEvent
	Delta []JSONPatchOperation `json:"delta"`
}

func NewStateDelta

func NewStateDelta(ops []JSONPatchOperation) StateDeltaEvent

type StateDoc

type StateDoc struct {
	Thread     string           `json:"thread"`
	Session    string           `json:"session"`
	Agent      string           `json:"agent"`
	Model      ModelState       `json:"model"`
	Todos      []tools.TodoItem `json:"todos"`
	TokenUsage *TokenUsageState `json:"tokenUsage"`
	Files      []FileState      `json:"files"`
	// SubAgents lists the mesnada tasks this thread delegated (P7). See
	// subagents.go: it is derived from the mesnada_* tool traffic, never read
	// from the orchestrator.
	SubAgents []SubAgentState `json:"subAgents"`
	// Client echoes RunAgentInput.state. It is read-only context the page pushed
	// into the run; the adapter never writes it back into Pando's config.
	Client any `json:"client,omitempty"`
}

StateDoc is the document published to the client. Field order is irrelevant to the protocol; the JSON pointer paths used by the deltas are what matter, so the tags below are part of the contract.

type StateSnapshotEvent

type StateSnapshotEvent struct {
	BaseEvent
	Snapshot any `json:"snapshot"`
}

func NewStateSnapshot

func NewStateSnapshot(snapshot any) StateSnapshotEvent

type StepFinishedEvent

type StepFinishedEvent struct {
	BaseEvent
	StepName string `json:"stepName"`
}

func NewStepFinished

func NewStepFinished(name string) StepFinishedEvent

type StepStartedEvent

type StepStartedEvent struct {
	BaseEvent
	StepName string `json:"stepName"`
}

func NewStepStarted

func NewStepStarted(name string) StepStartedEvent

type SubAgentState

type SubAgentState struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	// Role is set for tasks created by mesnada_swarm, which assigns fixed roles.
	Role     string `json:"role,omitempty"`
	Prompt   string `json:"prompt,omitempty"`
	Engine   string `json:"engine,omitempty"`
	Model    string `json:"model,omitempty"`
	Persona  string `json:"persona,omitempty"`
	Error    string `json:"error,omitempty"`
	ExitCode *int   `json:"exitCode,omitempty"`
	// Conclusion is the delegated task's self-reported outcome
	// (success|partial|failed|blocked), present once the task concluded.
	Conclusion string `json:"conclusion,omitempty"`
	// Summary is the conclusion's one-paragraph summary, when captured.
	Summary string `json:"summary,omitempty"`
}

SubAgentState is one delegated mesnada task as published to the client. Field names are camelCase because the JSON pointers built from them are part of the protocol contract, like the rest of StateDoc.

type TextMessageContentEvent

type TextMessageContentEvent struct {
	BaseEvent
	MessageID string `json:"messageId"`
	Delta     string `json:"delta"`
}

func NewTextMessageContent

func NewTextMessageContent(messageID, delta string) TextMessageContentEvent

type TextMessageEndEvent

type TextMessageEndEvent struct {
	BaseEvent
	MessageID string `json:"messageId"`
}

func NewTextMessageEnd

func NewTextMessageEnd(messageID string) TextMessageEndEvent

type TextMessageStartEvent

type TextMessageStartEvent struct {
	BaseEvent
	MessageID string `json:"messageId"`
	Role      string `json:"role"`
}

func NewTextMessageStart

func NewTextMessageStart(messageID string) TextMessageStartEvent

type ThreadSummary added in v0.715.6

type ThreadSummary struct {
	ThreadID  string `json:"threadId"`
	SessionID string `json:"sessionId"`
	Agent     string `json:"agent"`
	UpdatedAt string `json:"updatedAt"`
}

ThreadSummary is one entry of GET {path}/threads.

type TokenUsageState

type TokenUsageState struct {
	PromptTokens     int64   `json:"promptTokens"`
	CompletionTokens int64   `json:"completionTokens"`
	ContextWindow    int64   `json:"contextWindow"`
	Estimated        bool    `json:"estimated"`
	CacheReadTokens  int64   `json:"cacheReadTokens,omitempty"`
	CacheWriteTokens int64   `json:"cacheWriteTokens,omitempty"`
	ReasoningTokens  int64   `json:"reasoningTokens,omitempty"`
	Cost             float64 `json:"cost,omitempty"`
}

TokenUsageState is the live context budget, mirroring agent.TokenUsageInfo in the protocol's camelCase convention.

type Tool

type Tool struct {
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`
	Parameters  any    `json:"parameters,omitempty"`
}

Tool is a frontend-declared tool. The agent may call it; the browser executes it and returns the outcome as a tool message on the next run.

type ToolCall

type ToolCall struct {
	ID       string           `json:"id"`
	Type     string           `json:"type,omitempty"`
	Function ToolCallFunction `json:"function"`
}

ToolCall is an assistant-issued call as echoed back by the client.

type ToolCallArgsEvent

type ToolCallArgsEvent struct {
	BaseEvent
	ToolCallID string `json:"toolCallId"`
	Delta      string `json:"delta"`
}

func NewToolCallArgs

func NewToolCallArgs(id, delta string) ToolCallArgsEvent

type ToolCallEndEvent

type ToolCallEndEvent struct {
	BaseEvent
	ToolCallID string `json:"toolCallId"`
}

func NewToolCallEnd

func NewToolCallEnd(id string) ToolCallEndEvent

type ToolCallFunction

type ToolCallFunction struct {
	Name      string `json:"name"`
	Arguments string `json:"arguments"`
}

ToolCallFunction is the OpenAI-shaped function payload of a tool call.

type ToolCallResultEvent

type ToolCallResultEvent struct {
	BaseEvent
	MessageID  string `json:"messageId"`
	ToolCallID string `json:"toolCallId"`
	Content    string `json:"content"`
	Role       string `json:"role,omitempty"`
}

func NewToolCallResult

func NewToolCallResult(messageID, toolCallID, content string) ToolCallResultEvent

type ToolCallStartEvent

type ToolCallStartEvent struct {
	BaseEvent
	ToolCallID      string `json:"toolCallId"`
	ToolCallName    string `json:"toolCallName"`
	ParentMessageID string `json:"parentMessageId,omitempty"`
}

func NewToolCallStart

func NewToolCallStart(id, name, parentMessageID string) ToolCallStartEvent

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL