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
- Variables
- type ActivitySnapshotEvent
- type AgentDescriptor
- type BaseEvent
- type Capabilities
- type Config
- type Context
- type CustomEvent
- type Deps
- type Event
- type EventType
- type FileState
- type HealthResponse
- type InfoResponse
- type InputContent
- type JSONPatchOperation
- type Listener
- type ListenerOptions
- type Message
- type MessageContent
- type MessagesSnapshotEvent
- type ModelDescriptor
- type ModelState
- type Profile
- type RawEvent
- type ReasoningEndEvent
- type ReasoningMessageContentEvent
- type ReasoningMessageEndEvent
- type ReasoningMessageStartEvent
- type ReasoningStartEvent
- type RunAgentInput
- type RunErrorEvent
- type RunFinishedEvent
- type RunStartedEvent
- type Runtime
- type SSEWriter
- type StateDeltaEvent
- type StateDoc
- type StateSnapshotEvent
- type StepFinishedEvent
- type StepStartedEvent
- type SubAgentState
- type TextMessageContentEvent
- type TextMessageEndEvent
- type TextMessageStartEvent
- type ThreadSummary
- type TokenUsageState
- type Tool
- type ToolCall
- type ToolCallArgsEvent
- type ToolCallEndEvent
- type ToolCallFunction
- type ToolCallResultEvent
- type ToolCallStartEvent
Constants ¶
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.
const ( RoleDeveloper = "developer" RoleSystem = "system" RoleAssistant = "assistant" RoleUser = "user" RoleTool = "tool" RoleActivity = "activity" RoleReasoning = "reasoning" )
Message roles defined by AG-UI.
const ( ContentText = "text" ContentImage = "image" ContentAudio = "audio" ContentVideo = "video" ContentDocument = "document" )
Multimodal input content kinds.
const ( FileActionRead = "read" FileActionWrite = "write" FileActionEdit = "edit" FileActionPatched = "patch" )
File actions reported in StateDoc.Files.
Variables ¶
var ErrInvalidInput = errors.New("agui: invalid RunAgentInput")
ErrInvalidInput is returned by DecodeRunAgentInput when the payload does not satisfy the protocol's minimum requirements.
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.
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 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 CustomEvent ¶
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) Shutdown ¶
Shutdown stops the listener. It does not close the Runtime: the caller owns that, because the same Runtime may also be mounted elsewhere.
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 ReasoningEndEvent ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
func NewStateSnapshot ¶
func NewStateSnapshot(snapshot any) StateSnapshotEvent
type StepFinishedEvent ¶
func NewStepFinished ¶
func NewStepFinished(name string) StepFinishedEvent
type StepStartedEvent ¶
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 ¶
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 ¶
func NewToolCallEnd ¶
func NewToolCallEnd(id string) ToolCallEndEvent
type ToolCallFunction ¶
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