Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions renderers/angular/src/v0_9/core/component-host.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,18 @@ describe('ComponentHostComponent', () => {
components: new Map([['TestType', { component: TestChildComponent }]]),
};

const compMap = new Map([
['comp1', { id: 'comp1', type: 'TestType', properties: { text: 'Hello' } }],
]);
const mockOnCreated = jasmine.createSpyObj('onCreated', ['subscribe']);
mockOnCreated.subscribe.and.returnValue({ unsubscribe: () => {} });

mockSurface = {
componentsModel: new Map([
['comp1', { id: 'comp1', type: 'TestType', properties: { text: 'Hello' } }],
]),
componentsModel: {
get: (id: string) => compMap.get(id),
clear: () => compMap.clear(),
onCreated: mockOnCreated,
},
catalog: mockCatalog,
};

Expand Down Expand Up @@ -134,7 +142,7 @@ describe('ComponentHostComponent', () => {

// @ts-ignore
expect(component.componentType).toBeNull();
expect(consoleWarnSpy).toHaveBeenCalledWith('Component comp1 not found in surface surf1');
expect(consoleWarnSpy).toHaveBeenCalledWith('Component comp1 not found in surface surf1. Waiting for it...');
});

it('should error and return if component type not in catalog', () => {
Expand Down
46 changes: 40 additions & 6 deletions renderers/angular/src/v0_9/core/component-host.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@

import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
HostBinding,
OnInit,
Type,
inject,
input,
signal,
} from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { ComponentContext } from '@a2ui/web_core/v0_9';
Expand All @@ -48,11 +51,11 @@ import { ComponentBinder } from './component-binder.service';
*ngComponentOutlet="
componentType;
inputs: {
props: props,
surfaceId: surfaceId(),
componentId: resolvedComponentId,
dataContextPath: resolvedDataContextPath,
}
props: props,
surfaceId: surfaceId(),
componentId: resolvedComponentId,
dataContextPath: resolvedDataContextPath,
}
"
></ng-container>
}
Expand All @@ -69,10 +72,18 @@ export class ComponentHostComponent implements OnInit {
private rendererService = inject(A2uiRendererService);
private binder = inject(ComponentBinder);
private destroyRef = inject(DestroyRef);
private cdr = inject(ChangeDetectorRef);

protected componentType: Type<any> | null = null;
protected props: any = {};
private context?: ComponentContext;
protected weight = signal<string | number | null>(null);

@HostBinding('style.flex')
get flexStyle() {
const w = this.weight();
return w ? `${w}` : '';
}
protected resolvedComponentId: string = '';
protected resolvedDataContextPath: string = '/';

Expand Down Expand Up @@ -101,10 +112,29 @@ export class ComponentHostComponent implements OnInit {
const componentModel = surface.componentsModel.get(id);

if (!componentModel) {
console.warn(`Component ${id} not found in surface ${this.surfaceId()}`);
console.warn(`Component ${id} not found in surface ${this.surfaceId()}. Waiting for it...`);

const sub = surface.componentsModel.onCreated.subscribe((comp) => {
if (comp.id === id) {
this.initializeComponent(surface, comp, id, basePath);
this.cdr.markForCheck();
sub.unsubscribe();
}
});

this.destroyRef.onDestroy(() => sub.unsubscribe());
return;
}

this.initializeComponent(surface, componentModel, id, basePath);
}

private initializeComponent(
surface: any,
componentModel: any,
id: string,
basePath: string,
): void {
// Resolve component from the surface's catalog
const catalog = surface.catalog as AngularCatalog;
const api = catalog.components.get(componentModel.type);
Expand All @@ -120,6 +150,10 @@ export class ComponentHostComponent implements OnInit {
this.props = this.binder.bind(this.context);
this.resolvedDataContextPath = this.context.dataContext.path;

if (componentModel.weight) {
this.weight.set(componentModel.weight);
}

this.destroyRef.onDestroy(() => {
// ComponentContext itself doesn't have a dispose, but its inner components might.
// However, SurfaceModel takes care of component disposal.
Expand Down
1 change: 1 addition & 0 deletions renderers/angular/src/v0_9/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export * from './core/surface.component';
export * from './core/component-binder.service';
export * from './core/types';
export * from './core/utils';
export * from './core/markdown';

// Catalog Types and Implementations
export * from './catalog/types';
Expand Down
10 changes: 9 additions & 1 deletion renderers/lit/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion renderers/markdown/markdown-it/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions renderers/react/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions renderers/web_core/src/v0_9/rendering/data-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ describe('DataContext', () => {
});

it('resolves absolute paths', () => {
assert.strictEqual(context.resolveDynamicValue({path: '/list/0'}), 'a');
const rootContext = createTestDataContext(model, '/');
assert.strictEqual(rootContext.resolveDynamicValue({path: '/list/0'}), 'a');
});

it('resolves nested paths', () => {
Expand Down Expand Up @@ -108,7 +109,8 @@ describe('DataContext', () => {
assert.strictEqual(context.resolveDynamicValue({path: 'name'}), 'Alice');

// Absolute Path
assert.strictEqual(context.resolveDynamicValue({path: '/list/0'}), 'a');
const rootContext = createTestDataContext(model, '/');
assert.strictEqual(rootContext.resolveDynamicValue({path: '/list/0'}), 'a');
});

it('resolves literal arrays', () => {
Expand Down
16 changes: 9 additions & 7 deletions renderers/web_core/src/v0_9/rendering/data-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ export class DataContext {
* @param path The absolute path in the DataModel that this context is scoped to (its "current working directory").
*/
constructor(
readonly surface: SurfaceModel<any>,
readonly path: string,
public readonly surface: SurfaceModel<any>,
public readonly path: string,
) {
this.dataModel = surface.dataModel;
this.functionInvoker = surface.catalog.invoker;
Expand Down Expand Up @@ -360,10 +360,12 @@ export class DataContext {
}

private resolvePath(path: string): string {
if (path.startsWith('/')) {
return path;
}
if (path === '' || path === '.') {
// In A2UI v0.9, all paths generated by the prompt-first LLM must begin with a leading `/`
// to satisfy RFC 6901 JSON Pointer validation.
// Thus, paths like `/imageUrl` are effectively relative to this DataContext.
let relPath = path.startsWith('/') ? path.substring(1) : path;

if (relPath === '' || relPath === '.') {
return this.path;
}

Expand All @@ -373,6 +375,6 @@ export class DataContext {
}
if (base === '/') base = '';

return `${base}/${path}`;
return `${base}/${relPath}`;
}
}
38 changes: 37 additions & 1 deletion samples/agent/adk/restaurant_finder/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@

logger = logging.getLogger(__name__)

from google.adk.utils import instructions_utils

# Monkey patch to work around ADK issue where it tries to interpolate A2UI
# syntax (like ${...}) as session state.
# Bug filed against ADK: https://github.com/google/adk-python/issues/5179
original_inject = instructions_utils.inject_session_state


async def custom_inject_session_state(template: str, context: Any) -> str:
# Protect A2UI interpolation syntax from ADK
protected = template.replace("${", "__PROTECTED_BRACE__")
# Run original ADK injection
result = await original_inject(protected, context)
# Restore syntax for the model
return result.replace("__PROTECTED_BRACE__", "${")


# Apply the monkeypatch
instructions_utils.inject_session_state = custom_inject_session_state
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

Monkey-patching instructions_utils.inject_session_state is a fragile approach that can lead to unexpected behavior if the underlying google-adk library changes. While it's a clever workaround, it would be more robust to explore if the library can be configured to ignore this syntax, or to contribute an upstream change to support this use case directly. For a sample, this might be acceptable, but it's a practice to avoid in production code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, it's not wrong. This is because of a missing feature in the ADK: It has no way to turn off or escape curly braces.



class RestaurantAgent:
"""An agent that finds restaurants based on user criteria."""
Expand Down Expand Up @@ -158,6 +178,20 @@ def _build_llm_agent(
else get_text_prompt()
)

if schema_manager:
instruction += (
"\n\nMANDATORY: Every single response from you MUST start with a"
" `createSurface` message followed by an `updateComponents` message, before"
" any `updateDataModel` messages. The client requires them to render the"
" interface on every turn.\nCRITICAL: You MUST ALWAYS use an ARRAY for"
" `items` in a list, do NOT use an object with keys like `item1`.\nCRITICAL:"
" When updating the list of restaurants, use `updateDataModel` with `path:"
' "/items"` so that you do not overwrite the `title` property at the'
" root.\nCRITICAL: You MUST output the entire `updateDataModel` message with"
" all items at once at the end of your response. Do NOT output partial lists"
" or stream items one by one."
)

return LlmAgent(
model=LiteLlm(model=LITELLM_MODEL),
name="restaurant_agent",
Expand Down Expand Up @@ -236,6 +270,7 @@ async def stream(
)

full_content_list = []
parts_streamed = False

async def token_stream():
async for event in runner.run_async(
Expand All @@ -262,6 +297,7 @@ async def token_stream():
if len(self._parsers) > self._max_parsers:
self._parsers.popitem(last=False)

parts_streamed = True
async for part in stream_response_to_parts(
self._parsers[session_id],
token_stream(),
Expand Down Expand Up @@ -339,7 +375,7 @@ async def token_stream():

yield {
"is_task_complete": True,
"parts": final_parts,
"parts": [] if parts_streamed else final_parts,
}
return # We're done, exit the generator

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@
{
"id": "root",
"component": "Column",
"children": [
"title-heading",
"item-list"
]
"children": ["title-heading", "item-list"]
},
{
"id": "title-heading",
Expand All @@ -48,14 +45,12 @@
{
"id": "card-layout",
"component": "Row",
"children": [
"template-image",
"card-details"
]
"children": ["card-image", "card-details"]
},
{
"id": "template-image",
"id": "card-image",
"component": "Image",
"variant": "mediumFeature",
"weight": 1,
"url": {
"path": "imageUrl"
Expand Down Expand Up @@ -138,25 +133,19 @@
"surfaceId": "default",
"path": "/",
"value": {
"items": {
"item1": {
"items": [
{
"name": "The Fancy Place",
"rating": 4.8,
"detail": "Fine dining experience",
"infoLink": "https://example.com/fancy",
"imageUrl": "https://example.com/fancy.jpg",
"address": "123 Main St"
"detail": "Fine dining experience"
},
"item2": {
{
"name": "Quick Bites",
"rating": 4.2,
"detail": "Casual and fast",
"infoLink": "https://example.com/quick",
"imageUrl": "https://example.com/quick.jpg",
"address": "456 Oak Ave"
"detail": "Casual and fast"
}
}
]
}
}
}
]
]
Loading
Loading