Skip to content

feat(go/a2ui): add A2UI plugin for streaming interactive UI - #5850

Draft
pavelgj wants to merge 2 commits into
pj/a2uifrom
pj/a2ui-go
Draft

feat(go/a2ui): add A2UI plugin for streaming interactive UI#5850
pavelgj wants to merge 2 commits into
pj/a2uifrom
pj/a2ui-go

Conversation

@pavelgj

@pavelgj pavelgj commented Jul 29, 2026

Copy link
Copy Markdown
Member

Add experimental A2UI ("Agent to UI") plugin for Genkit Go, enabling agents to stream rich, interactive UI surfaces via the JSON-based A2UI protocol.

The plugin provides middleware that injects catalog capabilities into the system prompt, intercepts model output to extract and validate a2ui fenced blocks, and rewrites them into a2ui data parts using the mime type application/a2ui+json. This maps 1:1 onto the A2A binding, making envelopes byte-compatible with the JS and Dart plugins.

Includes a bundled basic catalog, custom catalog support via the Genkit registry, configurable validation modes, and helpers for extracting envelopes from message parts.

Add experimental A2UI ("Agent to UI") plugin for Genkit Go, enabling
agents to stream rich, interactive UI surfaces via the JSON-based A2UI
protocol.

The plugin provides middleware that injects catalog capabilities into
the system prompt, intercepts model output to extract and validate a2ui
fenced blocks, and rewrites them into a2ui data parts using the mime
type `application/a2ui+json`. This maps 1:1 onto the A2A binding, making
envelopes byte-compatible with the JS and Dart plugins.

Includes a bundled basic catalog, custom catalog support via the Genkit
registry, configurable validation modes, and helpers for extracting
envelopes from message parts.
@github-actions github-actions Bot added docs Improvements or additions to documentation go labels Jul 29, 2026
@pavelgj
pavelgj requested a review from apascal07 July 29, 2026 21:04

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the experimental a2ui ("Agent to UI") plugin and middleware for Genkit Go, enabling agents to stream rich, interactive UI surfaces. It updates the core Part struct to support arbitrary structured data and adds the necessary parser, catalog, and loader components. The review feedback provides valuable recommendations to ensure thread safety in surfaceIDReplay using a mutex, optimize streaming performance by reducing character lag and caching component lookups, prevent potential nil-pointer panics, and improve the clarity of the README documentation.

Comment thread go/plugins/a2ui/a2ui.go Outdated
Comment on lines +213 to +238
type surfaceIDReplay struct {
base func() string
generated []string
cursor int
}

func replayableSurfaceIDs(base func() string) *surfaceIDReplay {
return &surfaceIDReplay{base: base}
}

func (r *surfaceIDReplay) next() string {
id := r.base()
r.generated = append(r.generated, id)
return id
}

func (r *surfaceIDReplay) replayNext() string {
if r.cursor < len(r.generated) {
id := r.generated[r.cursor]
r.cursor++
return id
}
return r.next()
}

func (r *surfaceIDReplay) reset() { r.cursor = 0 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

surfaceIDReplay is not thread-safe. If the streaming callback is invoked concurrently or across multiple goroutines, concurrent access to r.generated and r.cursor can cause a data race. Adding a sync.Mutex ensures thread safety.

type surfaceIDReplay struct {
	mu        sync.Mutex
	base      func() string
	generated []string
	cursor    int
}

func replayableSurfaceIDs(base func() string) *surfaceIDReplay {
	return &surfaceIDReplay{base: base}
}

func (r *surfaceIDReplay) next() string {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.nextLocked()
}

func (r *surfaceIDReplay) nextLocked() string {
	id := r.base()
	r.generated = append(r.generated, id)
	return id
}

func (r *surfaceIDReplay) replayNext() string {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.cursor 8< len(r.generated) {
		id := r.generated[r.cursor]
		r.cursor++
		return id
	}
	return r.nextLocked()
}

func (r *surfaceIDReplay) reset() {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.cursor = 0
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6ab2519 - added a sync.Mutex to surfaceIDReplay (with a nextLocked helper). In practice the streamed parse and the final-message parse never overlap, but the lock is cheap insurance. Verified with go test -race.

Comment thread go/plugins/a2ui/a2ui.go
Comment on lines +19 to +29
import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/core/api"
"github.com/firebase/genkit/go/genkit"
"github.com/google/uuid"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add the sync package to the imports to support thread-safe operations in surfaceIDReplay.

Suggested change
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/core/api"
"github.com/firebase/genkit/go/genkit"
"github.com/google/uuid"
)
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/core/api"
"github.com/firebase/genkit/go/genkit"
"github.com/google/uuid"
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - added the sync import in 6ab2519.

Comment thread go/plugins/a2ui/parser.go
Comment on lines +75 to +93
type streamParser struct {
opts parserOptions
buffer string
inBlock bool
// currentSurfaceID is the stable surface id for the current block
// (placeholders map to this).
currentSurfaceID string
hasSurfaceID bool
}

func newStreamParser(opts parserOptions) *streamParser {
if opts.validate == "" {
opts.validate = ValidateWarn
}
if opts.version == "" {
opts.version = DefaultVersion
}
return &streamParser{opts: opts}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid rebuilding the component names map on every updateComponents envelope validation, we can cache the map in streamParser during initialization.

type streamParser struct {
	opts            parserOptions
	buffer          string
	inBlock         bool
	// currentSurfaceID is the stable surface id for the current block
	// (placeholders map to this).
	currentSurfaceID string
	hasSurfaceID     bool
	knownComponents  map[string]bool
}

func newStreamParser(opts parserOptions) *streamParser {
	if opts.validate == "" {
		opts.validate = ValidateWarn
	}
	if opts.version == "" {
		opts.version = DefaultVersion
	}
	var known map[string]bool
	if opts.catalog != nil {
		known = opts.catalog.componentNames()
	}
	return &streamParser{opts: opts, knownComponents: known}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6ab2519 - the component-name set is now computed once in newStreamParser and cached on the parser as knownComponents.

Comment thread go/plugins/a2ui/parser.go
Comment on lines +130 to +138
keep := maxPartialFence
if keep > len(p.buffer) {
keep = len(p.buffer)
}
safeLen := len(p.buffer) - keep
if safeLen > 0 {
proseBuf += p.buffer[:safeLen]
p.buffer = p.buffer[safeLen:]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation always holds back maxPartialFence (8) characters during streaming, which introduces a constant 8-character lag in the streamed output even when the tail of the buffer does not resemble a fence. We can optimize this by only holding back characters if the buffer ends with a potential prefix of the fence (i.e., contains a backtick within the last maxPartialFence characters).

					keep := 0
					if idx := strings.LastIndexByte(p.buffer, 96); idx >= 0 {
						dist := len(p.buffer) - idx
						if dist <= maxPartialFence {
							keep = dist
						}
					}
					safeLen := len(p.buffer) - keep
					if safeLen > 0 {
						proseBuf += p.buffer[:safeLen]
						p.buffer = p.buffer[safeLen:]
					}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not taking this one - the suggested code is subtly incorrect. It keeps from the last backtick (LastIndexByte), so for a fence split mid-opener like "hello a2ui" it would flush "hello ``" and retain only "`a2ui"; once reassembled with the next chunk the opener has a single backtick and no longer matches a2ui, so the block is missed. The constant 8-char holdback is intentional (it mirrors the JS parser) and the lag is at most a few chars until the next chunk arrives, so I'd rather keep the simple, correct version.

Comment thread go/plugins/a2ui/parser.go Outdated
Comment on lines +340 to +349
func (p *streamParser) validateComponents(components any) string {
catalog := p.opts.catalog
if catalog == nil {
return ""
}
arr, ok := components.([]any)
if !ok {
return "updateComponents.components must be an array."
}
known := catalog.componentNames()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the cached knownComponents map instead of calling catalog.componentNames() repeatedly.

Suggested change
func (p *streamParser) validateComponents(components any) string {
catalog := p.opts.catalog
if catalog == nil {
return ""
}
arr, ok := components.([]any)
if !ok {
return "updateComponents.components must be an array."
}
known := catalog.componentNames()
func (p *streamParser) validateComponents(components any) string {
catalog := p.opts.catalog
if catalog == nil {
return ""
}
arr, ok := components.([]any)
if !ok {
return "updateComponents.components must be an array."
}
known := p.knownComponents

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - validateComponents now uses the cached knownComponents map (6ab2519).

Comment thread go/plugins/a2ui/loader.go
Comment on lines +114 to +118
if v := genkit.LookupValue(g, catalogRegistryKey(id)); v != nil {
if c, ok := v.(*Catalog); ok {
return c, nil
}
return nil, fmt.Errorf("a2ui: registry value %q is not an *a2ui.Catalog", catalogRegistryKey(id))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a catalog is registered as a value instead of a pointer, v.(*Catalog) will fail. We can make this more robust by also checking for Catalog value and taking its address.

		if v := genkit.LookupValue(g, catalogRegistryKey(id)); v != nil {
			if c, ok := v.(*Catalog); ok {
				return c, nil
			}
			if c, ok := v.(Catalog); ok {
				return &c, nil
			}
			return nil, fmt.Errorf("a2ui: registry value %q is not an *a2ui.Catalog", catalogRegistryKey(id))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving as-is. The only registration path (LoadCatalog) always stores a *Catalog, so a Catalog-by-value case would be guarding against a misuse this package never creates. The current type assertion already returns a clear error for anything unexpected.

Comment on lines +235 to +237
func RenderCatalogInstructions(catalog *Catalog) string {
var componentDocs strings.Builder
for i, c := range catalog.Components {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since RenderCatalogInstructions is an exported function, it can be called with a nil catalog, which would cause a panic. Adding a nil check prevents this.

Suggested change
func RenderCatalogInstructions(catalog *Catalog) string {
var componentDocs strings.Builder
for i, c := range catalog.Components {
func RenderCatalogInstructions(catalog *Catalog) string {
if catalog == nil {
return ""
}
var componentDocs strings.Builder
for i, c := range catalog.Components {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - added a nil-catalog guard returning "" in 6ab2519.

Comment thread go/plugins/a2ui/README.md Outdated
Comment on lines +65 to +66
| `Instructions` | `InstructionsSystem` | Where to inject catalog capabilities. `InstructionsNone` injects nothing. |
| `Validate` | `ValidateWarn` | Validate emitted envelopes. `ValidateWarn` logs and drops bad blocks; `ValidateStrict` returns an error; `ValidateOff` skips checking. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It would be clearer to specify the actual string values (e.g., "system" and "warn") as the defaults in the table, rather than the Go constant names, to make it easier for users configuring the plugin via JSON/YAML or other non-Go environments.

Suggested change
| `Instructions` | `InstructionsSystem` | Where to inject catalog capabilities. `InstructionsNone` injects nothing. |
| `Validate` | `ValidateWarn` | Validate emitted envelopes. `ValidateWarn` logs and drops bad blocks; `ValidateStrict` returns an error; `ValidateOff` skips checking. |
| Instructions | "system" | Where to inject catalog capabilities. InstructionsNone injects nothing. |
| Validate | "warn" | Validate emitted envelopes. ValidateWarn logs and drops bad blocks; ValidateStrict returns an error; ValidateOff skips checking. |

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - the options table now shows the string values ("system", "warn", etc.) instead of the Go constant names (6ab2519).

- Guard surfaceIDReplay with a mutex for thread safety.
- Cache the catalog component-name set in the stream parser instead of
  rebuilding it per updateComponents envelope.
- Add a nil-catalog guard to RenderCatalogInstructions.
- Show string default values in the README options table.
@cabljac
cabljac self-requested a review July 31, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation go

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant