From 67ac33750c072863c609688884deaebcd447056d Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Mon, 30 Mar 2026 16:19:14 +0200 Subject: [PATCH 1/7] docs: add agent presentation resolution architecture --- ...nt-presentation-resolution-architecture.md | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 docs/2026-03-30-agent-presentation-resolution-architecture.md diff --git a/docs/2026-03-30-agent-presentation-resolution-architecture.md b/docs/2026-03-30-agent-presentation-resolution-architecture.md new file mode 100644 index 0000000..e1b4bbf --- /dev/null +++ b/docs/2026-03-30-agent-presentation-resolution-architecture.md @@ -0,0 +1,394 @@ +--- +date: 2026-03-30 +author: Onur Solmaz +title: Agent Presentation Resolution Architecture +tags: [spritz, agents, ui, api, architecture] +--- + +## Overview + +This document defines a provider-agnostic model for rendering a Spritz instance +as an agent with deployment-owned presentation metadata such as: + +- display name +- avatar URL + +The goal is to let deployment-owned systems tell Spritz how an instance should +be presented in the UI without making ACP runtime identity or deployment-wide +branding carry that responsibility. + +The design keeps three concepts separate: + +- deployment-wide product branding +- per-instance agent presentation +- ACP runtime identity + +## Goals + +- Make per-instance agent presentation a first-class Spritz concept. +- Keep the contract provider-agnostic and safe for open-source use. +- Preserve a clean control-plane split between desired state and resolved + external state. +- Give all Spritz UIs one canonical read shape for display name and avatar. +- Allow deployment-owned systems to resolve presentation using the extension + framework. +- Keep ACP `agentInfo` focused on runtime protocol identity, not UI branding. + +## Non-goals + +- Do not add deployment-specific business logic to Spritz core. +- Do not turn ACP `agentInfo` into a mutable branding surface. +- Do not make browser clients call deployment-owned systems directly to fetch + presentation data. +- Do not introduce per-tenant or per-user global UI theming here. +- Do not require every instance to have an external agent reference. + +## Problem statement + +Today, Spritz has two nearby but different concepts: + +- deployment-wide UI branding documented in + [2026-03-20-ui-branding-customization.md](2026-03-20-ui-branding-customization.md) +- ACP runtime identity in `status.acp.agentInfo` + +Neither is the right home for per-instance agent presentation. + +Deployment-wide branding is too coarse because one Spritz install may host many +instances that should present as different agents. + +ACP `agentInfo` is also the wrong source because it represents runtime protocol +identity exposed by the image or ACP adapter. It should not be overloaded to +carry deployment-owned display choices such as: + +- "show this instance as Research Assistant" +- "use this avatar from an external agent catalog" + +If Spritz keeps using ACP metadata for rendering, UI identity becomes coupled +to runtime image behavior instead of control-plane state. + +## Design principles + +### Presentation is control-plane data + +Per-instance presentation should be resolved and stored in Spritz control-plane +state, not fetched by the browser at render time. + +### Desired and observed state stay separate + +Deployment-owned references and local overrides belong in `spec`. + +Resolved display values from external systems belong in `status`. + +### Presentation is not runtime identity + +ACP metadata continues to answer: + +- what runtime is this +- what protocol version and capabilities does it expose + +Presentation answers: + +- what should the UI call this instance +- what avatar should the UI show + +### UIs should read one canonical resolved shape + +Native Spritz UI and embedded consumers should use the same precedence and the +same resolved fields. + +### External resolution should be explicit + +If a deployment wants Spritz to present an instance as an external agent, the +instance should contain an explicit opaque reference instead of encoding that +knowledge indirectly in annotations or ACP metadata. + +## Canonical resource model + +The recommended model is: + +```yaml +spec: + agentRef: + type: external + provider: example-catalog + id: agent-123 + presentationOverrides: + displayName: "Example Assistant" + avatarUrl: "https://console.example.com/assets/example-assistant.png" + +status: + resolvedPresentation: + displayName: "Example Assistant" + avatarUrl: "https://console.example.com/assets/example-assistant.png" + source: resolved + observedGeneration: 7 + resolver: deployment-agent-presentation + lastResolvedAt: "2026-03-30T12:00:00Z" +``` + +Recommended types: + +- `spec.agentRef` + - optional + - opaque reference to a deployment-owned agent or catalog entry + - Spritz validates shape, not business semantics + - use `type` for the internal field name; if an external payload sends + `kind`, convert it at the boundary +- `spec.presentationOverrides` + - optional + - operator or caller supplied local override values + - highest-priority desired-state input +- `status.resolvedPresentation` + - canonical UI output + - what every UI should read + +## Proposed type definitions + +Suggested CRD additions: + +```go +type SpritzAgentRef struct { + Type string `json:"type,omitempty"` + Provider string `json:"provider,omitempty"` + ID string `json:"id,omitempty"` +} + +type SpritzPresentation struct { + DisplayName string `json:"displayName,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` +} + +type SpritzResolvedPresentation struct { + DisplayName string `json:"displayName,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + Source string `json:"source,omitempty"` + Resolver string `json:"resolver,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + LastResolvedAt *metav1.Time `json:"lastResolvedAt,omitempty"` + LastError string `json:"lastError,omitempty"` +} +``` + +Suggested placements: + +- `spritz.spec.agentRef` +- `spritz.spec.presentationOverrides` +- `spritz.status.resolvedPresentation` + +If conversation resources need presentation snapshots later, they should carry +their own optional resolved snapshot as a derived cache, not as the canonical +source of truth. + +## Why this model is preferred + +This model is cleaner than storing resolved presentation in `spec` because: + +- `spec` remains caller intent +- `status` remains observed and reconciled state +- the system can refresh external presentation later without rewriting desired + state +- UIs can trust one stable resolved output +- overrides remain explicit and inspectable + +This is also cleaner than using only `metadata.annotations` because: + +- annotations are untyped +- validation is weaker +- UI consumers need field-specific parsing logic +- the contract becomes harder to evolve safely + +## Resolution model + +Spritz should add one resolver operation for presentation: + +- `agent.presentation.resolve` + +Its input should contain only the facts needed for resolution: + +```json +{ + "version": "v1", + "extensionId": "deployment-agent-presentation", + "type": "resolver", + "operation": "agent.presentation.resolve", + "context": { + "namespace": "spritz-system", + "instanceClassId": "personal-agent" + }, + "input": { + "owner": { "id": "user-123" }, + "agentRef": { + "type": "external", + "provider": "example-catalog", + "id": "agent-123" + }, + "presentationOverrides": { + "displayName": "Example Assistant" + } + } +} +``` + +The response should be narrow: + +```json +{ + "status": "resolved", + "output": { + "presentation": { + "displayName": "Example Assistant", + "avatarUrl": "https://console.example.com/assets/example-assistant.png" + } + } +} +``` + +The resolver should return resolved presentation only. It should not mutate +arbitrary resource state. + +## Precedence rules + +Canonical precedence should be: + +1. `spec.presentationOverrides` +2. resolved extension output from `agent.presentation.resolve` +3. ACP `agentInfo.title` +4. ACP `agentInfo.name` +5. `metadata.name` + +This precedence should be materialized into `status.resolvedPresentation` so +the UI does not need to re-implement the logic in multiple places. + +That means the browser should normally read: + +- `status.resolvedPresentation.displayName` +- `status.resolvedPresentation.avatarUrl` + +and only fall back further if `resolvedPresentation` is absent. + +## Conversation model + +The canonical source of per-instance presentation should stay on the instance +resource, not on `SpritzConversation`. + +Conversation resources already reference the parent instance by `spritzName`. +Native UI and embedded consumers can join against the parent instance when +needed. + +If later profiling shows that repeated joins are too expensive, Spritz can add +an optional derived snapshot to conversation state. That snapshot should still +be treated as a cache of instance presentation, not the source of truth. + +## API and controller changes + +### API changes + +- extend `operator/api/v1/spritz_types.go` with: + - `SpritzAgentRef` + - `SpritzPresentation` + - `SpritzResolvedPresentation` +- update public API serialization so `resolvedPresentation` is included in + instance reads and lists +- keep `status.acp.agentInfo` unchanged + +### Extension framework changes + +- add `agent.presentation.resolve` as a supported operation in the extension + registry +- define a typed request and response envelope for presentation resolution +- validate that the resolver can only return presentation fields + +### Reconciliation changes + +Spritz needs a control-plane component that computes +`status.resolvedPresentation`. + +Recommended sequence: + +1. normalize `spec.agentRef` and `spec.presentationOverrides` +2. if overrides fully satisfy presentation, use them directly +3. else, if `agentRef` is present, call `agent.presentation.resolve` +4. merge using the canonical precedence rules +5. write the result to `status.resolvedPresentation` +6. record resolution metadata such as: + - `source` + - `resolver` + - `observedGeneration` + - `lastResolvedAt` + - `lastError` + +The first implementation can run this logic in the API create/update path plus +an explicit refresh endpoint if needed. + +The long-term preferred implementation is a reconciliation loop that keeps +`status.resolvedPresentation` current whenever: + +- `spec.agentRef` changes +- `spec.presentationOverrides` changes +- a caller requests refresh + +## Suggested implementation phases + +### Phase 1: typed model and UI read path + +- add typed `agentRef`, `presentationOverrides`, and `resolvedPresentation` +- add UI helpers that prefer `status.resolvedPresentation` +- keep ACP metadata as fallback only + +This phase creates the durable contract first. + +### Phase 2: resolver integration + +- add `agent.presentation.resolve` +- resolve presentation during create and update +- materialize the merged result into `status.resolvedPresentation` + +This phase gives deployments a provider-agnostic hook. + +### Phase 3: refresh and reconciliation + +- add explicit refresh semantics +- reconcile stale or missing presentation after create +- support background re-resolution without rewriting `spec` + +This phase makes external presentation durable over time instead of treating it +as a one-time create artifact. + +## Validation + +Required validation: + +- unit tests for precedence logic +- unit tests for merge behavior between overrides, resolved presentation, ACP + metadata, and instance name +- API tests for instance list and get responses +- extension tests for: + - resolved + - unresolved + - forbidden + - invalid +- reconciliation tests proving `status.resolvedPresentation` updates when + `spec.presentationOverrides` changes +- UI tests proving: + - resolved presentation is preferred + - ACP metadata remains a fallback + - instance name remains the final fallback + +## Migration notes + +Existing installations may already render from ACP metadata or instance name. + +Migration should therefore be additive: + +1. introduce the new fields +2. ship UIs that prefer `status.resolvedPresentation` +3. start writing `resolvedPresentation` +4. keep ACP fallback behavior until the new field is broadly available + +This avoids breaking existing runtimes or forcing immediate deployment-specific +resolver adoption. + +## References + +- [2026-03-19-unified-extension-framework-architecture.md](2026-03-19-unified-extension-framework-architecture.md) +- [2026-03-20-ui-branding-customization.md](2026-03-20-ui-branding-customization.md) From 4ebc2a4866960065761e6d579624fcaefade00ef Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Mon, 30 Mar 2026 16:28:47 +0200 Subject: [PATCH 2/7] docs: rename agent presentation doc to agent profile api --- ...ure.md => 2026-03-30-agent-profile-api.md} | 227 +++++++++--------- 1 file changed, 116 insertions(+), 111 deletions(-) rename docs/{2026-03-30-agent-presentation-resolution-architecture.md => 2026-03-30-agent-profile-api.md} (55%) diff --git a/docs/2026-03-30-agent-presentation-resolution-architecture.md b/docs/2026-03-30-agent-profile-api.md similarity index 55% rename from docs/2026-03-30-agent-presentation-resolution-architecture.md rename to docs/2026-03-30-agent-profile-api.md index e1b4bbf..60e0c70 100644 --- a/docs/2026-03-30-agent-presentation-resolution-architecture.md +++ b/docs/2026-03-30-agent-profile-api.md @@ -1,36 +1,36 @@ --- date: 2026-03-30 author: Onur Solmaz -title: Agent Presentation Resolution Architecture +title: Agent Profile API tags: [spritz, agents, ui, api, architecture] --- ## Overview -This document defines a provider-agnostic model for rendering a Spritz instance -as an agent with deployment-owned presentation metadata such as: +This document defines a provider-agnostic agent profile API for rendering a +Spritz instance with deployment-owned cosmetic metadata such as: -- display name -- avatar URL +- name +- image URL The goal is to let deployment-owned systems tell Spritz how an instance should -be presented in the UI without making ACP runtime identity or deployment-wide +appear in the UI without making ACP runtime identity or deployment-wide branding carry that responsibility. The design keeps three concepts separate: - deployment-wide product branding -- per-instance agent presentation +- per-instance agent profile - ACP runtime identity ## Goals -- Make per-instance agent presentation a first-class Spritz concept. +- Make per-instance agent profile data a first-class Spritz concept. - Keep the contract provider-agnostic and safe for open-source use. -- Preserve a clean control-plane split between desired state and resolved +- Preserve a clean control-plane split between desired state and synced external state. -- Give all Spritz UIs one canonical read shape for display name and avatar. -- Allow deployment-owned systems to resolve presentation using the extension +- Give all Spritz UIs one canonical read shape for agent name and image. +- Allow deployment-owned systems to sync profile data using the extension framework. - Keep ACP `agentInfo` focused on runtime protocol identity, not UI branding. @@ -39,7 +39,7 @@ The design keeps three concepts separate: - Do not add deployment-specific business logic to Spritz core. - Do not turn ACP `agentInfo` into a mutable branding surface. - Do not make browser clients call deployment-owned systems directly to fetch - presentation data. + profile data. - Do not introduce per-tenant or per-user global UI theming here. - Do not require every instance to have an external agent reference. @@ -51,7 +51,7 @@ Today, Spritz has two nearby but different concepts: [2026-03-20-ui-branding-customization.md](2026-03-20-ui-branding-customization.md) - ACP runtime identity in `status.acp.agentInfo` -Neither is the right home for per-instance agent presentation. +Neither is the right home for per-instance agent profile data. Deployment-wide branding is too coarse because one Spritz install may host many instances that should present as different agents. @@ -61,16 +61,16 @@ identity exposed by the image or ACP adapter. It should not be overloaded to carry deployment-owned display choices such as: - "show this instance as Research Assistant" -- "use this avatar from an external agent catalog" +- "use this image from an external agent catalog" If Spritz keeps using ACP metadata for rendering, UI identity becomes coupled to runtime image behavior instead of control-plane state. ## Design principles -### Presentation is control-plane data +### Profile data is control-plane data -Per-instance presentation should be resolved and stored in Spritz control-plane +Per-instance profile data should be synced and stored in Spritz control-plane state, not fetched by the browser at render time. ### Desired and observed state stay separate @@ -79,26 +79,26 @@ Deployment-owned references and local overrides belong in `spec`. Resolved display values from external systems belong in `status`. -### Presentation is not runtime identity +### Profile data is not runtime identity ACP metadata continues to answer: - what runtime is this - what protocol version and capabilities does it expose -Presentation answers: +Profile data answers: - what should the UI call this instance -- what avatar should the UI show +- what image should the UI show -### UIs should read one canonical resolved shape +### UIs should read one canonical profile shape Native Spritz UI and embedded consumers should use the same precedence and the -same resolved fields. +same profile fields. -### External resolution should be explicit +### External sync should be explicit -If a deployment wants Spritz to present an instance as an external agent, the +If a deployment wants Spritz to show an instance as an external agent, the instance should contain an explicit opaque reference instead of encoding that knowledge indirectly in annotations or ACP metadata. @@ -112,18 +112,18 @@ spec: type: external provider: example-catalog id: agent-123 - presentationOverrides: - displayName: "Example Assistant" - avatarUrl: "https://console.example.com/assets/example-assistant.png" + profileOverrides: + name: "Example Assistant" + imageUrl: "https://console.example.com/assets/example-assistant.png" status: - resolvedPresentation: - displayName: "Example Assistant" - avatarUrl: "https://console.example.com/assets/example-assistant.png" - source: resolved + profile: + name: "Example Assistant" + imageUrl: "https://console.example.com/assets/example-assistant.png" + source: synced observedGeneration: 7 - resolver: deployment-agent-presentation - lastResolvedAt: "2026-03-30T12:00:00Z" + syncer: deployment-agent-profile + lastSyncedAt: "2026-03-30T12:00:00Z" ``` Recommended types: @@ -134,11 +134,11 @@ Recommended types: - Spritz validates shape, not business semantics - use `type` for the internal field name; if an external payload sends `kind`, convert it at the boundary -- `spec.presentationOverrides` +- `spec.profileOverrides` - optional - - operator or caller supplied local override values + - operator- or caller-supplied local override values - highest-priority desired-state input -- `status.resolvedPresentation` +- `status.profile` - canonical UI output - what every UI should read @@ -153,18 +153,18 @@ type SpritzAgentRef struct { ID string `json:"id,omitempty"` } -type SpritzPresentation struct { - DisplayName string `json:"displayName,omitempty"` - AvatarURL string `json:"avatarUrl,omitempty"` +type SpritzAgentProfile struct { + Name string `json:"name,omitempty"` + ImageURL string `json:"imageUrl,omitempty"` } -type SpritzResolvedPresentation struct { - DisplayName string `json:"displayName,omitempty"` - AvatarURL string `json:"avatarUrl,omitempty"` +type SpritzAgentProfileStatus struct { + Name string `json:"name,omitempty"` + ImageURL string `json:"imageUrl,omitempty"` Source string `json:"source,omitempty"` - Resolver string `json:"resolver,omitempty"` + Syncer string `json:"syncer,omitempty"` ObservedGeneration int64 `json:"observedGeneration,omitempty"` - LastResolvedAt *metav1.Time `json:"lastResolvedAt,omitempty"` + LastSyncedAt *metav1.Time `json:"lastSyncedAt,omitempty"` LastError string `json:"lastError,omitempty"` } ``` @@ -172,22 +172,22 @@ type SpritzResolvedPresentation struct { Suggested placements: - `spritz.spec.agentRef` -- `spritz.spec.presentationOverrides` -- `spritz.status.resolvedPresentation` +- `spritz.spec.profileOverrides` +- `spritz.status.profile` -If conversation resources need presentation snapshots later, they should carry -their own optional resolved snapshot as a derived cache, not as the canonical -source of truth. +If conversation resources need profile snapshots later, they should carry their +own optional derived snapshot as a cache, not as the canonical source of +truth. ## Why this model is preferred -This model is cleaner than storing resolved presentation in `spec` because: +This model is cleaner than storing synced profile data in `spec` because: - `spec` remains caller intent - `status` remains observed and reconciled state -- the system can refresh external presentation later without rewriting desired +- the system can refresh external profile data later without rewriting desired state -- UIs can trust one stable resolved output +- UIs can trust one stable profile output - overrides remain explicit and inspectable This is also cleaner than using only `metadata.annotations` because: @@ -197,20 +197,20 @@ This is also cleaner than using only `metadata.annotations` because: - UI consumers need field-specific parsing logic - the contract becomes harder to evolve safely -## Resolution model +## Sync model -Spritz should add one resolver operation for presentation: +Spritz should add one extension operation for agent profile sync: -- `agent.presentation.resolve` +- `agent.profile.sync` -Its input should contain only the facts needed for resolution: +Its input should contain only the facts needed to compute the profile: ```json { "version": "v1", - "extensionId": "deployment-agent-presentation", + "extensionId": "deployment-agent-profile", "type": "resolver", - "operation": "agent.presentation.resolve", + "operation": "agent.profile.sync", "context": { "namespace": "spritz-system", "instanceClassId": "personal-agent" @@ -222,8 +222,8 @@ Its input should contain only the facts needed for resolution: "provider": "example-catalog", "id": "agent-123" }, - "presentationOverrides": { - "displayName": "Example Assistant" + "profileOverrides": { + "name": "Example Assistant" } } } @@ -233,42 +233,49 @@ The response should be narrow: ```json { - "status": "resolved", + "status": "synced", "output": { - "presentation": { - "displayName": "Example Assistant", - "avatarUrl": "https://console.example.com/assets/example-assistant.png" + "profile": { + "name": "Example Assistant", + "imageUrl": "https://console.example.com/assets/example-assistant.png" } } } ``` -The resolver should return resolved presentation only. It should not mutate -arbitrary resource state. +The extension should return profile data only. It should not mutate arbitrary +resource state. ## Precedence rules -Canonical precedence should be: +For the instance name, canonical precedence should be: -1. `spec.presentationOverrides` -2. resolved extension output from `agent.presentation.resolve` +1. `spec.profileOverrides.name` +2. synced extension output from `agent.profile.sync` 3. ACP `agentInfo.title` 4. ACP `agentInfo.name` 5. `metadata.name` -This precedence should be materialized into `status.resolvedPresentation` so -the UI does not need to re-implement the logic in multiple places. +For the image URL, canonical precedence should be: + +1. `spec.profileOverrides.imageUrl` +2. synced extension output from `agent.profile.sync` +3. no image URL + +This precedence should be materialized into `status.profile` so the UI does not +need to re-implement the logic in multiple places. That means the browser should normally read: -- `status.resolvedPresentation.displayName` -- `status.resolvedPresentation.avatarUrl` +- `status.profile.name` +- `status.profile.imageUrl` -and only fall back further if `resolvedPresentation` is absent. +If `status.profile.imageUrl` is empty, the UI can fall back to initials or a +generic placeholder. ## Conversation model -The canonical source of per-instance presentation should stay on the instance +The canonical source of per-instance profile data should stay on the instance resource, not on `SpritzConversation`. Conversation resources already reference the parent instance by `spritzName`. @@ -277,7 +284,7 @@ needed. If later profiling shows that repeated joins are too expensive, Spritz can add an optional derived snapshot to conversation state. That snapshot should still -be treated as a cache of instance presentation, not the source of truth. +be treated as a cache of instance profile data, not the source of truth. ## API and controller changes @@ -285,73 +292,71 @@ be treated as a cache of instance presentation, not the source of truth. - extend `operator/api/v1/spritz_types.go` with: - `SpritzAgentRef` - - `SpritzPresentation` - - `SpritzResolvedPresentation` -- update public API serialization so `resolvedPresentation` is included in + - `SpritzAgentProfile` + - `SpritzAgentProfileStatus` +- update public API serialization so `profile` is included in instance reads and lists - keep `status.acp.agentInfo` unchanged ### Extension framework changes -- add `agent.presentation.resolve` as a supported operation in the extension - registry -- define a typed request and response envelope for presentation resolution -- validate that the resolver can only return presentation fields +- add `agent.profile.sync` as a supported operation in the extension registry +- define a typed request and response envelope for profile sync +- validate that the extension can only return profile fields ### Reconciliation changes -Spritz needs a control-plane component that computes -`status.resolvedPresentation`. +Spritz needs a control-plane component that computes `status.profile`. Recommended sequence: -1. normalize `spec.agentRef` and `spec.presentationOverrides` -2. if overrides fully satisfy presentation, use them directly -3. else, if `agentRef` is present, call `agent.presentation.resolve` +1. normalize `spec.agentRef` and `spec.profileOverrides` +2. if overrides fully satisfy the profile, use them directly +3. else, if `agentRef` is present, call `agent.profile.sync` 4. merge using the canonical precedence rules -5. write the result to `status.resolvedPresentation` -6. record resolution metadata such as: +5. write the result to `status.profile` +6. record sync metadata such as: - `source` - - `resolver` + - `syncer` - `observedGeneration` - - `lastResolvedAt` + - `lastSyncedAt` - `lastError` The first implementation can run this logic in the API create/update path plus an explicit refresh endpoint if needed. The long-term preferred implementation is a reconciliation loop that keeps -`status.resolvedPresentation` current whenever: +`status.profile` current whenever: - `spec.agentRef` changes -- `spec.presentationOverrides` changes +- `spec.profileOverrides` changes - a caller requests refresh ## Suggested implementation phases ### Phase 1: typed model and UI read path -- add typed `agentRef`, `presentationOverrides`, and `resolvedPresentation` -- add UI helpers that prefer `status.resolvedPresentation` +- add typed `agentRef`, `profileOverrides`, and `profile` +- add UI helpers that prefer `status.profile` - keep ACP metadata as fallback only This phase creates the durable contract first. -### Phase 2: resolver integration +### Phase 2: extension integration -- add `agent.presentation.resolve` -- resolve presentation during create and update -- materialize the merged result into `status.resolvedPresentation` +- add `agent.profile.sync` +- sync profile data during create and update +- materialize the merged result into `status.profile` This phase gives deployments a provider-agnostic hook. ### Phase 3: refresh and reconciliation - add explicit refresh semantics -- reconcile stale or missing presentation after create -- support background re-resolution without rewriting `spec` +- reconcile stale or missing profile data after create +- support background re-sync without rewriting `spec` -This phase makes external presentation durable over time instead of treating it +This phase makes external profile data durable over time instead of treating it as a one-time create artifact. ## Validation @@ -359,18 +364,18 @@ as a one-time create artifact. Required validation: - unit tests for precedence logic -- unit tests for merge behavior between overrides, resolved presentation, ACP +- unit tests for merge behavior between overrides, synced profile data, ACP metadata, and instance name - API tests for instance list and get responses - extension tests for: - - resolved - - unresolved + - synced + - missing - forbidden - invalid -- reconciliation tests proving `status.resolvedPresentation` updates when - `spec.presentationOverrides` changes +- reconciliation tests proving `status.profile` updates when + `spec.profileOverrides` changes - UI tests proving: - - resolved presentation is preferred + - `status.profile` is preferred - ACP metadata remains a fallback - instance name remains the final fallback @@ -381,12 +386,12 @@ Existing installations may already render from ACP metadata or instance name. Migration should therefore be additive: 1. introduce the new fields -2. ship UIs that prefer `status.resolvedPresentation` -3. start writing `resolvedPresentation` +2. ship UIs that prefer `status.profile` +3. start writing `status.profile` 4. keep ACP fallback behavior until the new field is broadly available This avoids breaking existing runtimes or forcing immediate deployment-specific -resolver adoption. +extension adoption. ## References From 8da51400aa1284eb10615d1a60a4be3857de3deb Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Mon, 30 Mar 2026 16:34:30 +0200 Subject: [PATCH 3/7] docs: add plain-language summary to agent profile api --- docs/2026-03-30-agent-profile-api.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/2026-03-30-agent-profile-api.md b/docs/2026-03-30-agent-profile-api.md index 60e0c70..b4c06f6 100644 --- a/docs/2026-03-30-agent-profile-api.md +++ b/docs/2026-03-30-agent-profile-api.md @@ -23,6 +23,22 @@ The design keeps three concepts separate: - per-instance agent profile - ACP runtime identity +## Plain language + +- The external service owns the real agent profile. +- Spritz keeps a synced local copy of that profile for its UIs. +- The UI reads Spritz, not the external service. + +In practice: + +1. a Spritz instance can point at an external agent with `agentRef` +2. Spritz can ask the deployment system for that agent's profile +3. Spritz stores the result on the instance as `status.profile` +4. the UI renders from `status.profile` + +If local overrides exist, Spritz can replace parts of the synced profile +without changing the external source. + ## Goals - Make per-instance agent profile data a first-class Spritz concept. From 222ed52b68f86041d504a34b7dde2530abed9475 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Mon, 30 Mar 2026 17:23:36 +0200 Subject: [PATCH 4/7] feat(agent): sync external profiles into spritz --- api/acp_helpers.go | 11 +- api/acp_test.go | 11 + api/agent_profile.go | 291 ++++++++++++++++++ api/create_admission.go | 5 + api/create_admission_test.go | 120 ++++++-- api/create_request_normalization.go | 5 + api/extensions.go | 8 +- api/extensions_test.go | 20 ++ api/main.go | 7 + api/provisioning_test_helpers_test.go | 5 +- .../spritz.sh_spritzconversations.yaml | 2 +- crd/generated/spritz.sh_spritzes.yaml | 50 +++ helm/spritz/crds/spritz.sh_spritzes.yaml | 4 + operator/api/v1/spritz_types.go | 92 ++++-- operator/api/v1/spritz_types_test.go | 15 + ui/src/components/acp/sidebar.test.tsx | 114 ++++--- ui/src/components/acp/sidebar.tsx | 36 ++- ui/src/components/agent-avatar.tsx | 41 +++ ui/src/components/spritz-list.tsx | 36 ++- ui/src/lib/spritz-profile.test.ts | 89 ++++++ ui/src/lib/spritz-profile.ts | 56 ++++ ui/src/pages/chat.tsx | 22 +- ui/src/types/spritz.ts | 18 ++ 23 files changed, 940 insertions(+), 118 deletions(-) create mode 100644 api/agent_profile.go create mode 100644 ui/src/components/agent-avatar.tsx create mode 100644 ui/src/lib/spritz-profile.test.ts create mode 100644 ui/src/lib/spritz-profile.ts diff --git a/api/acp_helpers.go b/api/acp_helpers.go index 2afbe4a..29d61d8 100644 --- a/api/acp_helpers.go +++ b/api/acp_helpers.go @@ -30,10 +30,13 @@ func spritzSupportsACPConversations(spritz *spritzv1.Spritz) bool { } func displayAgentName(spritz *spritzv1.Spritz) string { - if spritz == nil || spritz.Status.ACP == nil || spritz.Status.ACP.AgentInfo == nil { - if spritz == nil { - return "" - } + if spritz == nil { + return "" + } + if profile := spritz.Status.Profile; profile != nil && strings.TrimSpace(profile.Name) != "" { + return strings.TrimSpace(profile.Name) + } + if spritz.Status.ACP == nil || spritz.Status.ACP.AgentInfo == nil { return spritz.Name } info := spritz.Status.ACP.AgentInfo diff --git a/api/acp_test.go b/api/acp_test.go index a704230..84feb90 100644 --- a/api/acp_test.go +++ b/api/acp_test.go @@ -310,6 +310,17 @@ func TestListACPAgentsUsesStoredStatusOnly(t *testing.T) { } } +func TestDisplayAgentNamePrefersSyncedProfile(t *testing.T) { + spritz := readyACPSpritz("tidy-otter", "user-1") + spritz.Status.Profile = &spritzv1.SpritzAgentProfileStatus{ + Name: "Helpful Otter", + } + + if got := displayAgentName(spritz); got != "Helpful Otter" { + t.Fatalf("expected synced profile name, got %q", got) + } +} + func TestCreateACPConversationGeneratesIndependentConversationID(t *testing.T) { spritz := readyACPSpritz("tidy-otter", "user-1") s := newACPTestServer(t, spritz) diff --git a/api/agent_profile.go b/api/agent_profile.go new file mode 100644 index 0000000..56761d8 --- /dev/null +++ b/api/agent_profile.go @@ -0,0 +1,291 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + spritzv1 "spritz.sh/operator/api/v1" +) + +type agentProfileSyncInput struct { + Owner spritzv1.SpritzOwner `json:"owner"` + AgentRef *spritzv1.SpritzAgentRef `json:"agentRef,omitempty"` + ProfileOverrides *spritzv1.SpritzAgentProfile `json:"profileOverrides,omitempty"` +} + +type agentProfileSyncOutput struct { + Profile *spritzv1.SpritzAgentProfile `json:"profile,omitempty"` +} + +type resolvedAgentProfile struct { + profile *spritzv1.SpritzAgentProfile + syncer string + syncedAt *metav1.Time + lastError string +} + +func normalizeSpritzAgentRef(value *spritzv1.SpritzAgentRef) *spritzv1.SpritzAgentRef { + if value == nil { + return nil + } + normalized := &spritzv1.SpritzAgentRef{ + Type: strings.TrimSpace(value.Type), + Provider: strings.TrimSpace(value.Provider), + ID: strings.TrimSpace(value.ID), + } + if normalized.Type == "" && normalized.Provider == "" && normalized.ID == "" { + return nil + } + return normalized +} + +func validateSpritzAgentRef(value *spritzv1.SpritzAgentRef) error { + normalized := normalizeSpritzAgentRef(value) + if normalized == nil { + return nil + } + if normalized.Type == "" { + return errors.New("spec.agentRef.type is required") + } + if normalized.Provider == "" { + return errors.New("spec.agentRef.provider is required") + } + if normalized.ID == "" { + return errors.New("spec.agentRef.id is required") + } + return nil +} + +func sameSpritzAgentRef(left, right *spritzv1.SpritzAgentRef) bool { + left = normalizeSpritzAgentRef(left) + right = normalizeSpritzAgentRef(right) + switch { + case left == nil && right == nil: + return true + case left == nil || right == nil: + return false + default: + return left.Type == right.Type && left.Provider == right.Provider && left.ID == right.ID + } +} + +func mergeSpritzAgentRefStrict(existing, resolved *spritzv1.SpritzAgentRef) (*spritzv1.SpritzAgentRef, error) { + resolved = normalizeSpritzAgentRef(resolved) + if resolved == nil { + return normalizeSpritzAgentRef(existing), nil + } + if err := validateSpritzAgentRef(resolved); err != nil { + return nil, err + } + existing = normalizeSpritzAgentRef(existing) + if existing != nil && !sameSpritzAgentRef(existing, resolved) { + return nil, errors.New("preset create resolver attempted to overwrite spec.agentRef") + } + return resolved, nil +} + +func normalizeSpritzAgentProfile(value *spritzv1.SpritzAgentProfile) *spritzv1.SpritzAgentProfile { + if value == nil { + return nil + } + normalized := &spritzv1.SpritzAgentProfile{ + Name: strings.TrimSpace(value.Name), + ImageURL: strings.TrimSpace(value.ImageURL), + } + if normalized.Name == "" && normalized.ImageURL == "" { + return nil + } + return normalized +} + +func buildSpritzAgentProfileStatus( + overrides *spritzv1.SpritzAgentProfile, + synced *spritzv1.SpritzAgentProfile, + generation int64, + syncer string, + syncedAt *metav1.Time, + lastError string, +) *spritzv1.SpritzAgentProfileStatus { + overrides = normalizeSpritzAgentProfile(overrides) + synced = normalizeSpritzAgentProfile(synced) + lastError = strings.TrimSpace(lastError) + + status := &spritzv1.SpritzAgentProfileStatus{ + ObservedGeneration: generation, + Syncer: strings.TrimSpace(syncer), + LastError: lastError, + } + + if overrides != nil { + status.Name = overrides.Name + status.ImageURL = overrides.ImageURL + status.Source = "override" + } + if synced != nil { + if status.Name == "" { + status.Name = synced.Name + } + if status.ImageURL == "" { + status.ImageURL = synced.ImageURL + } + if status.Source == "" { + status.Source = "synced" + } + } + if syncedAt != nil { + status.LastSyncedAt = syncedAt.DeepCopy() + } + if status.Name == "" && status.ImageURL == "" && status.LastError == "" { + return nil + } + return status +} + +func parseAgentProfileSyncOutput(raw []byte) (*spritzv1.SpritzAgentProfile, error) { + if len(raw) == 0 { + return nil, nil + } + var payload agentProfileSyncOutput + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("invalid agent profile sync output: %w", err) + } + return normalizeSpritzAgentProfile(payload.Profile), nil +} + +func agentProfileSyncErrorMessage(status extensionResolverStatus) string { + switch status { + case extensionStatusUnresolved: + return "agent profile is unresolved" + case extensionStatusForbidden: + return "agent profile sync is forbidden" + case extensionStatusAmbiguous: + return "agent profile sync is ambiguous" + case extensionStatusInvalid: + return "agent profile sync is invalid" + case extensionStatusUnavailable: + return "agent profile sync is unavailable" + default: + return "" + } +} + +func createAgentProfileRequestContext(namespace string, body *createRequest) extensionRequestContext { + requestContext := extensionRequestContext{ + Namespace: strings.TrimSpace(namespace), + } + if body == nil { + return requestContext + } + requestContext.PresetID = strings.TrimSpace(body.PresetID) + if body.Annotations != nil { + requestContext.InstanceClassID = strings.TrimSpace(body.Annotations[instanceClassAnnotationKey]) + } + return requestContext +} + +func (s *server) resolveAgentProfile( + ctx context.Context, + principal principal, + namespace string, + body *createRequest, +) *resolvedAgentProfile { + if body == nil { + return nil + } + body.Spec.AgentRef = normalizeSpritzAgentRef(body.Spec.AgentRef) + body.Spec.ProfileOverrides = normalizeSpritzAgentProfile(body.Spec.ProfileOverrides) + if body.Spec.AgentRef == nil && body.Spec.ProfileOverrides == nil { + return nil + } + if body.Spec.AgentRef == nil { + return &resolvedAgentProfile{} + } + + requestContext := createAgentProfileRequestContext(namespace, body) + resolver, response, err := s.extensions.resolve( + ctx, + extensionOperationAgentProfileSync, + principal, + body.RequestID, + requestContext, + agentProfileSyncInput{ + Owner: body.Spec.Owner, + AgentRef: body.Spec.AgentRef, + ProfileOverrides: body.Spec.ProfileOverrides, + }, + ) + if err != nil { + lastError := fmt.Sprintf("agent profile sync failed: %v", err) + if resolver != nil { + return &resolvedAgentProfile{ + syncer: resolver.id, + lastError: lastError, + } + } + return &resolvedAgentProfile{lastError: lastError} + } + if resolver == nil { + return &resolvedAgentProfile{} + } + + result := &resolvedAgentProfile{syncer: resolver.id} + switch response.Status { + case "", extensionStatusResolved: + profile, parseErr := parseAgentProfileSyncOutput(response.Output) + if parseErr != nil { + result.lastError = parseErr.Error() + return result + } + result.profile = profile + now := metav1.Now() + result.syncedAt = &now + default: + result.lastError = agentProfileSyncErrorMessage(response.Status) + } + return result +} + +func (s *server) applyResolvedAgentProfileStatus( + ctx context.Context, + spritz *spritzv1.Spritz, + resolved *resolvedAgentProfile, +) (*spritzv1.Spritz, error) { + if spritz == nil { + return nil, nil + } + var statusProfile *spritzv1.SpritzAgentProfileStatus + if resolved != nil { + statusProfile = buildSpritzAgentProfileStatus( + spritz.Spec.ProfileOverrides, + resolved.profile, + spritz.Generation, + resolved.syncer, + resolved.syncedAt, + resolved.lastError, + ) + } else { + statusProfile = buildSpritzAgentProfileStatus( + spritz.Spec.ProfileOverrides, + nil, + spritz.Generation, + "", + nil, + "", + ) + } + if statusProfile == nil { + return spritz, nil + } + + current := spritz.DeepCopy() + current.Status.Profile = statusProfile + if err := s.client.Status().Update(ctx, current); err != nil { + return spritz, err + } + return current, nil +} diff --git a/api/create_admission.go b/api/create_admission.go index c8b136d..572eb49 100644 --- a/api/create_admission.go +++ b/api/create_admission.go @@ -236,6 +236,11 @@ func applyPresetCreateResolverMutations(body *createRequest, response extensionR body.Spec.ServiceAccountName = resolvedServiceAccount result.serviceAccountResolved = true } + mergedAgentRef, err := mergeSpritzAgentRefStrict(body.Spec.AgentRef, response.Mutations.Spec.AgentRef) + if err != nil { + return presetCreateMutationResult{}, err + } + body.Spec.AgentRef = mergedAgentRef } annotations, err := mergeMetadataStrict(body.Annotations, response.Mutations.Annotations, "annotation") if err != nil { diff --git a/api/create_admission_test.go b/api/create_admission_test.go index 2c8d742..10b10fe 100644 --- a/api/create_admission_test.go +++ b/api/create_admission_test.go @@ -17,7 +17,7 @@ import ( spritzv1 "spritz.sh/operator/api/v1" ) -func configurePresetResolverTestServer(s *server, resolverURL string) { +func configurePresetResolverTestServer(s *server, resolverURL, profileResolverURL string) { s.presets = presetCatalog{ byID: []runtimePreset{{ ID: "zeno", @@ -39,9 +39,10 @@ func configurePresetResolverTestServer(s *server, resolverURL string) { }, }, } - if strings.TrimSpace(resolverURL) != "" { - s.extensions = extensionRegistry{ - resolvers: []configuredResolver{{ + if strings.TrimSpace(resolverURL) != "" || strings.TrimSpace(profileResolverURL) != "" { + resolvers := make([]configuredResolver, 0, 2) + if strings.TrimSpace(resolverURL) != "" { + resolvers = append(resolvers, configuredResolver{ id: "runtime-binding", extensionType: extensionTypeResolver, operation: extensionOperationPresetCreateResolve, @@ -52,34 +53,73 @@ func configurePresetResolverTestServer(s *server, resolverURL string) { url: resolverURL, timeout: time.Second, }, - }}, + }) } + if strings.TrimSpace(profileResolverURL) != "" { + resolvers = append(resolvers, configuredResolver{ + id: "agent-profile", + extensionType: extensionTypeResolver, + operation: extensionOperationAgentProfileSync, + match: extensionMatchRule{ + presetIDs: map[string]struct{}{"zeno": {}}, + }, + transport: configuredHTTPTransport{ + url: profileResolverURL, + timeout: time.Second, + }, + }) + } + s.extensions = extensionRegistry{resolvers: resolvers} } } func TestCreateSpritzAppliesPresetCreateResolverForHumanCaller(t *testing.T) { s := newCreateSpritzTestServer(t) - var received map[string]any + var presetReceived map[string]any + var profileReceived map[string]any resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() + var received map[string]any if err := json.NewDecoder(r.Body).Decode(&received); err != nil { t.Fatalf("failed to decode resolver request: %v", err) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "status": "resolved", - "mutations": map[string]any{ - "spec": map[string]any{ - "serviceAccountName": "zeno-agent-ag-123", + switch received["operation"] { + case string(extensionOperationPresetCreateResolve): + presetReceived = received + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "resolved", + "mutations": map[string]any{ + "spec": map[string]any{ + "serviceAccountName": "zeno-agent-ag-123", + "agentRef": map[string]string{ + "type": "external", + "provider": "example-agent-catalog", + "id": "ag-123", + }, + }, + "annotations": map[string]string{ + "spritz.sh/resolved-agent-id": "ag-123", + }, }, - "annotations": map[string]string{ - "spritz.sh/resolved-agent-id": "ag-123", + }) + case string(extensionOperationAgentProfileSync): + profileReceived = received + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "resolved", + "output": map[string]any{ + "profile": map[string]string{ + "name": "Helpful Lake Agent", + "imageUrl": "https://example.com/agent.png", + }, }, - }, - }) + }) + default: + t.Fatalf("unexpected resolver operation %#v", received["operation"]) + } })) defer resolver.Close() - configurePresetResolverTestServer(s, resolver.URL) + configurePresetResolverTestServer(s, resolver.URL, resolver.URL) e := echo.New() secured := e.Group("", s.authMiddleware()) @@ -101,19 +141,19 @@ func TestCreateSpritzAppliesPresetCreateResolverForHumanCaller(t *testing.T) { if rec.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String()) } - if received["operation"] != string(extensionOperationPresetCreateResolve) { - t.Fatalf("expected preset create operation, got %#v", received["operation"]) + if presetReceived["operation"] != string(extensionOperationPresetCreateResolve) { + t.Fatalf("expected preset create operation, got %#v", presetReceived["operation"]) } - contextPayload, ok := received["context"].(map[string]any) + contextPayload, ok := presetReceived["context"].(map[string]any) if !ok { - t.Fatalf("expected resolver context payload, got %#v", received["context"]) + t.Fatalf("expected resolver context payload, got %#v", presetReceived["context"]) } if contextPayload["presetId"] != "zeno" { t.Fatalf("expected resolver presetId zeno, got %#v", contextPayload["presetId"]) } - inputPayload, ok := received["input"].(map[string]any) + inputPayload, ok := presetReceived["input"].(map[string]any) if !ok { - t.Fatalf("expected resolver input payload, got %#v", received["input"]) + t.Fatalf("expected resolver input payload, got %#v", presetReceived["input"]) } presetInputs, ok := inputPayload["presetInputs"].(map[string]any) if !ok { @@ -130,6 +170,27 @@ func TestCreateSpritzAppliesPresetCreateResolverForHumanCaller(t *testing.T) { if stored.Spec.ServiceAccountName != "zeno-agent-ag-123" { t.Fatalf("expected resolved service account name, got %q", stored.Spec.ServiceAccountName) } + if stored.Spec.AgentRef == nil { + t.Fatalf("expected resolved agentRef to be stored") + } + if stored.Spec.AgentRef.Type != "external" || stored.Spec.AgentRef.Provider != "example-agent-catalog" || stored.Spec.AgentRef.ID != "ag-123" { + t.Fatalf("expected resolved agentRef, got %#v", stored.Spec.AgentRef) + } + if stored.Status.Profile == nil { + t.Fatalf("expected synced profile to be stored in status") + } + if stored.Status.Profile.Name != "Helpful Lake Agent" { + t.Fatalf("expected synced profile name, got %#v", stored.Status.Profile.Name) + } + if stored.Status.Profile.ImageURL != "https://example.com/agent.png" { + t.Fatalf("expected synced profile image URL, got %#v", stored.Status.Profile.ImageURL) + } + if stored.Status.Profile.Source != "synced" { + t.Fatalf("expected synced profile source, got %#v", stored.Status.Profile.Source) + } + if stored.Status.Profile.Syncer != "agent-profile" { + t.Fatalf("expected synced profile syncer id, got %#v", stored.Status.Profile.Syncer) + } if stored.Annotations["spritz.sh/resolved-agent-id"] != "ag-123" { t.Fatalf("expected resolver annotation, got %#v", stored.Annotations["spritz.sh/resolved-agent-id"]) } @@ -143,11 +204,22 @@ func TestCreateSpritzAppliesPresetCreateResolverForHumanCaller(t *testing.T) { if err := s.client.Get(context.Background(), client.ObjectKey{Name: "zeno-agent-ag-123", Namespace: s.namespace}, serviceAccount); err != nil { t.Fatalf("expected created service account: %v", err) } + profileInput, ok := profileReceived["input"].(map[string]any) + if !ok { + t.Fatalf("expected profile sync input payload, got %#v", profileReceived["input"]) + } + agentRef, ok := profileInput["agentRef"].(map[string]any) + if !ok { + t.Fatalf("expected profile sync agentRef payload, got %#v", profileInput["agentRef"]) + } + if agentRef["type"] != "external" || agentRef["provider"] != "example-agent-catalog" || agentRef["id"] != "ag-123" { + t.Fatalf("expected synced agentRef payload, got %#v", agentRef) + } } func TestCreateSpritzRejectsPresetInputsWithoutMatchingResolver(t *testing.T) { s := newCreateSpritzTestServer(t) - configurePresetResolverTestServer(s, "") + configurePresetResolverTestServer(s, "", "") e := echo.New() secured := e.Group("", s.authMiddleware()) secured.POST("/api/spritzes", s.createSpritz) @@ -472,7 +544,7 @@ func TestPresetCreateResolverIgnoresOwnerMutation(t *testing.T) { }) })) defer resolver.Close() - configurePresetResolverTestServer(s, resolver.URL) + configurePresetResolverTestServer(s, resolver.URL, "") e := echo.New() secured := e.Group("", s.authMiddleware()) diff --git a/api/create_request_normalization.go b/api/create_request_normalization.go index a1804c7..a5336d2 100644 --- a/api/create_request_normalization.go +++ b/api/create_request_normalization.go @@ -211,6 +211,11 @@ func validateCreateSpec(spec *spritzv1.SpritzSpec) error { return err } } + spec.AgentRef = normalizeSpritzAgentRef(spec.AgentRef) + if err := validateSpritzAgentRef(spec.AgentRef); err != nil { + return err + } + spec.ProfileOverrides = normalizeSpritzAgentProfile(spec.ProfileOverrides) if len(spec.SharedMounts) > 0 { normalized, err := normalizeSharedMounts(spec.SharedMounts) if err != nil { diff --git a/api/extensions.go b/api/extensions.go index 4ef468e..29a34fd 100644 --- a/api/extensions.go +++ b/api/extensions.go @@ -13,6 +13,8 @@ import ( "sort" "strings" "time" + + spritzv1 "spritz.sh/operator/api/v1" ) const ( @@ -33,6 +35,7 @@ type extensionOperation string const ( extensionOperationOwnerResolve extensionOperation = "owner.resolve" extensionOperationPresetCreateResolve extensionOperation = "preset.create.resolve" + extensionOperationAgentProfileSync extensionOperation = "agent.profile.sync" extensionOperationAuthLoginMetadata extensionOperation = "auth.login.metadata" extensionOperationIdentityLinkResolve extensionOperation = "identity.link.resolve" extensionOperationInstanceNotify extensionOperation = "instance.lifecycle.notify" @@ -119,7 +122,8 @@ type extensionResolverMutations struct { } type extensionResolverSpecMutation struct { - ServiceAccountName string `json:"serviceAccountName,omitempty"` + ServiceAccountName string `json:"serviceAccountName,omitempty"` + AgentRef *spritzv1.SpritzAgentRef `json:"agentRef,omitempty"` } type configuredResolver struct { @@ -241,6 +245,8 @@ func normalizeExtensionOperation(raw string) extensionOperation { return extensionOperationOwnerResolve case extensionOperationPresetCreateResolve: return extensionOperationPresetCreateResolve + case extensionOperationAgentProfileSync: + return extensionOperationAgentProfileSync case extensionOperationAuthLoginMetadata: return extensionOperationAuthLoginMetadata case extensionOperationIdentityLinkResolve: diff --git a/api/extensions_test.go b/api/extensions_test.go index 8c212a8..156861d 100644 --- a/api/extensions_test.go +++ b/api/extensions_test.go @@ -59,6 +59,26 @@ func TestNewExtensionRegistryAcceptsChannelRouteResolveOperation(t *testing.T) { } } +func TestNewExtensionRegistryAcceptsAgentProfileSyncOperation(t *testing.T) { + t.Setenv(extensionsEnvKey, `[{ + "id": "agent-profile", + "type": "resolver", + "operation": "agent.profile.sync", + "transport": {"url": "https://example.com/internal/extensions/agent-profile"} + }]`) + + registry, err := newExtensionRegistry() + if err != nil { + t.Fatalf("expected agent profile sync operation to be accepted, got %v", err) + } + if len(registry.resolvers) != 1 { + t.Fatalf("expected one resolver, got %d", len(registry.resolvers)) + } + if registry.resolvers[0].operation != extensionOperationAgentProfileSync { + t.Fatalf("expected agent.profile.sync operation, got %q", registry.resolvers[0].operation) + } +} + func TestNormalizeExtensionMatchSanitizesPresetIDs(t *testing.T) { match, err := normalizeExtensionMatch(extensionMatchInput{PresetIDs: []string{"Zeno", "my_preset"}}) if err != nil { diff --git a/api/main.go b/api/main.go index a4009bb..6d2e435 100644 --- a/api/main.go +++ b/api/main.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "net/url" "os" @@ -518,6 +519,7 @@ func (s *server) createSpritz(c echo.Context) error { if err := s.ensureServiceAccount(c.Request().Context(), namespace, body.Spec.ServiceAccountName); err != nil { return writeError(c, http.StatusInternalServerError, "failed to ensure service account") } + resolvedProfile := s.resolveAgentProfile(c.Request().Context(), principal, namespace, &body) labels := map[string]string{ ownerLabelKey: ownerLabelValue(owner.ID), @@ -644,6 +646,11 @@ func (s *server) createSpritz(c echo.Context) error { } return writeError(c, http.StatusInternalServerError, err.Error()) } + if updated, err := s.applyResolvedAgentProfileStatus(c.Request().Context(), spritz, resolvedProfile); err != nil { + log.Printf("spritz agent profile: failed to persist profile status name=%s namespace=%s err=%v", spritz.Name, spritz.Namespace, err) + } else if updated != nil { + spritz = updated + } if principal.isService() { if err := s.completeIdempotencyReservation(c.Request().Context(), principal.ID, body.IdempotencyKey, spritz); err != nil { return writeError(c, http.StatusInternalServerError, err.Error()) diff --git a/api/provisioning_test_helpers_test.go b/api/provisioning_test_helpers_test.go index 8d21412..378c17f 100644 --- a/api/provisioning_test_helpers_test.go +++ b/api/provisioning_test_helpers_test.go @@ -29,7 +29,10 @@ func newCreateSpritzTestServer(t *testing.T) *server { t.Helper() scheme := newTestSpritzScheme(t) return &server{ - client: fake.NewClientBuilder().WithScheme(scheme).Build(), + client: fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&spritzv1.Spritz{}). + Build(), scheme: scheme, namespace: "spritz-test", controlNamespace: "spritz-test", diff --git a/crd/generated/spritz.sh_spritzconversations.yaml b/crd/generated/spritz.sh_spritzconversations.yaml index 4558404..55db34f 100644 --- a/crd/generated/spritz.sh_spritzconversations.yaml +++ b/crd/generated/spritz.sh_spritzconversations.yaml @@ -36,7 +36,7 @@ spec: schema: openAPIV3Schema: description: SpritzConversation stores ACP conversation metadata for a spritz - workspace. + instance. properties: apiVersion: description: |- diff --git a/crd/generated/spritz.sh_spritzes.yaml b/crd/generated/spritz.sh_spritzes.yaml index ddbf584..dfb6056 100644 --- a/crd/generated/spritz.sh_spritzes.yaml +++ b/crd/generated/spritz.sh_spritzes.yaml @@ -57,6 +57,20 @@ spec: spec: description: SpritzSpec defines the desired state of Spritz. properties: + agentRef: + description: SpritzAgentRef identifies a deployment-owned external + agent record. + properties: + id: + maxLength: 256 + type: string + provider: + maxLength: 128 + type: string + type: + maxLength: 64 + type: string + type: object annotations: additionalProperties: type: string @@ -309,6 +323,17 @@ spec: - name type: object type: array + profileOverrides: + description: ProfileOverrides stores optional local overrides for + UI-facing agent profile fields. + properties: + imageUrl: + maxLength: 2048 + type: string + name: + maxLength: 128 + type: string + type: object repo: description: SpritzRepo describes the repository to clone inside the workload. @@ -687,6 +712,31 @@ spec: - Terminating - Error type: string + profile: + description: SpritzAgentProfileStatus stores the synced UI-facing + profile for an instance. + properties: + imageUrl: + maxLength: 2048 + type: string + lastError: + type: string + lastSyncedAt: + format: date-time + type: string + name: + maxLength: 128 + type: string + observedGeneration: + format: int64 + type: integer + source: + maxLength: 32 + type: string + syncer: + maxLength: 128 + type: string + type: object readyAt: format: date-time type: string diff --git a/helm/spritz/crds/spritz.sh_spritzes.yaml b/helm/spritz/crds/spritz.sh_spritzes.yaml index d041214..ddbf584 100644 --- a/helm/spritz/crds/spritz.sh_spritzes.yaml +++ b/helm/spritz/crds/spritz.sh_spritzes.yaml @@ -456,6 +456,10 @@ spec: More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ type: object type: object + serviceAccountName: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string sharedMounts: description: SharedMounts configures per-spritz shared directories. items: diff --git a/operator/api/v1/spritz_types.go b/operator/api/v1/spritz_types.go index 1952822..6a15ac1 100644 --- a/operator/api/v1/spritz_types.go +++ b/operator/api/v1/spritz_types.go @@ -33,15 +33,18 @@ type SpritzSpec struct { // +kubebuilder:validation:Pattern="^([0-9]+h)?([0-9]+m)?([0-9]+s)?$" TTL string `json:"ttl,omitempty"` // +kubebuilder:validation:Pattern="^([0-9]+h)?([0-9]+m)?([0-9]+s)?$" - IdleTTL string `json:"idleTtl,omitempty"` - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - Owner SpritzOwner `json:"owner"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` - Features *SpritzFeatures `json:"features,omitempty"` - SSH *SpritzSSH `json:"ssh,omitempty"` - Ports []SpritzPort `json:"ports,omitempty"` - Ingress *SpritzIngress `json:"ingress,omitempty"` + IdleTTL string `json:"idleTtl,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Owner SpritzOwner `json:"owner"` + AgentRef *SpritzAgentRef `json:"agentRef,omitempty"` + // ProfileOverrides stores optional local overrides for UI-facing agent profile fields. + ProfileOverrides *SpritzAgentProfile `json:"profileOverrides,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + Features *SpritzFeatures `json:"features,omitempty"` + SSH *SpritzSSH `json:"ssh,omitempty"` + Ports []SpritzPort `json:"ports,omitempty"` + Ingress *SpritzIngress `json:"ingress,omitempty"` } // SpritzRepo describes the repository to clone inside the workload. @@ -76,6 +79,24 @@ type SpritzOwner struct { Team string `json:"team,omitempty"` } +// SpritzAgentRef identifies a deployment-owned external agent record. +type SpritzAgentRef struct { + // +kubebuilder:validation:MaxLength=64 + Type string `json:"type,omitempty"` + // +kubebuilder:validation:MaxLength=128 + Provider string `json:"provider,omitempty"` + // +kubebuilder:validation:MaxLength=256 + ID string `json:"id,omitempty"` +} + +// SpritzAgentProfile stores UI-facing agent profile fields. +type SpritzAgentProfile struct { + // +kubebuilder:validation:MaxLength=128 + Name string `json:"name,omitempty"` + // +kubebuilder:validation:MaxLength=2048 + ImageURL string `json:"imageUrl,omitempty"` +} + // SpritzFeatures toggles optional capabilities. type SpritzFeatures struct { // +kubebuilder:default=false @@ -139,17 +160,33 @@ type SpritzStatus struct { // +kubebuilder:validation:Enum=Provisioning;Ready;Expiring;Expired;Terminating;Error Phase string `json:"phase,omitempty"` // +kubebuilder:validation:Format=uri - URL string `json:"url,omitempty"` - ACP *SpritzACPStatus `json:"acp,omitempty"` - SSH *SpritzSSHInfo `json:"ssh,omitempty"` - Message string `json:"message,omitempty"` - LastActivityAt *metav1.Time `json:"lastActivityAt,omitempty"` - IdleExpiresAt *metav1.Time `json:"idleExpiresAt,omitempty"` - MaxExpiresAt *metav1.Time `json:"maxExpiresAt,omitempty"` - ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` - LifecycleReason string `json:"lifecycleReason,omitempty"` - ReadyAt *metav1.Time `json:"readyAt,omitempty"` - Conditions []metav1.Condition `json:"conditions,omitempty"` + URL string `json:"url,omitempty"` + Profile *SpritzAgentProfileStatus `json:"profile,omitempty"` + ACP *SpritzACPStatus `json:"acp,omitempty"` + SSH *SpritzSSHInfo `json:"ssh,omitempty"` + Message string `json:"message,omitempty"` + LastActivityAt *metav1.Time `json:"lastActivityAt,omitempty"` + IdleExpiresAt *metav1.Time `json:"idleExpiresAt,omitempty"` + MaxExpiresAt *metav1.Time `json:"maxExpiresAt,omitempty"` + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` + LifecycleReason string `json:"lifecycleReason,omitempty"` + ReadyAt *metav1.Time `json:"readyAt,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// SpritzAgentProfileStatus stores the synced UI-facing profile for an instance. +type SpritzAgentProfileStatus struct { + // +kubebuilder:validation:MaxLength=128 + Name string `json:"name,omitempty"` + // +kubebuilder:validation:MaxLength=2048 + ImageURL string `json:"imageUrl,omitempty"` + // +kubebuilder:validation:MaxLength=32 + Source string `json:"source,omitempty"` + // +kubebuilder:validation:MaxLength=128 + Syncer string `json:"syncer,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + LastSyncedAt *metav1.Time `json:"lastSyncedAt,omitempty"` + LastError string `json:"lastError,omitempty"` } // SpritzACPStatus describes ACP discovery state for the workload. @@ -440,6 +477,14 @@ func (in *SpritzSpec) DeepCopyInto(out *SpritzSpec) { copy(out.SharedMounts, in.SharedMounts) } in.Resources.DeepCopyInto(&out.Resources) + if in.AgentRef != nil { + out.AgentRef = &SpritzAgentRef{} + *out.AgentRef = *in.AgentRef + } + if in.ProfileOverrides != nil { + out.ProfileOverrides = &SpritzAgentProfile{} + *out.ProfileOverrides = *in.ProfileOverrides + } if in.Labels != nil { out.Labels = make(map[string]string, len(in.Labels)) for k, v := range in.Labels { @@ -491,6 +536,13 @@ func (in *SpritzSpec) DeepCopyInto(out *SpritzSpec) { func (in *SpritzStatus) DeepCopyInto(out *SpritzStatus) { *out = *in + if in.Profile != nil { + out.Profile = &SpritzAgentProfileStatus{} + *out.Profile = *in.Profile + if in.Profile.LastSyncedAt != nil { + out.Profile.LastSyncedAt = in.Profile.LastSyncedAt.DeepCopy() + } + } if in.ACP != nil { out.ACP = &SpritzACPStatus{} in.ACP.DeepCopyInto(out.ACP) diff --git a/operator/api/v1/spritz_types_test.go b/operator/api/v1/spritz_types_test.go index d11a26a..1c9ba4e 100644 --- a/operator/api/v1/spritz_types_test.go +++ b/operator/api/v1/spritz_types_test.go @@ -11,8 +11,14 @@ func TestSpritzStatusDeepCopyIntoCopiesLifecycleTimestamps(t *testing.T) { idle := metav1.NewTime(time.Date(2026, 3, 11, 12, 0, 0, 0, time.UTC)) max := metav1.NewTime(time.Date(2026, 3, 12, 12, 0, 0, 0, time.UTC)) ready := metav1.NewTime(time.Date(2026, 3, 11, 11, 0, 0, 0, time.UTC)) + synced := metav1.NewTime(time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)) original := &SpritzStatus{ + Profile: &SpritzAgentProfileStatus{ + Name: "Helpful Otter", + ImageURL: "https://console.example.com/otter.png", + LastSyncedAt: &synced, + }, IdleExpiresAt: &idle, MaxExpiresAt: &max, ReadyAt: &ready, @@ -26,6 +32,12 @@ func TestSpritzStatusDeepCopyIntoCopiesLifecycleTimestamps(t *testing.T) { if copied.MaxExpiresAt == original.MaxExpiresAt { t.Fatal("expected max expiry timestamp pointer to be deep-copied") } + if copied.Profile == original.Profile { + t.Fatal("expected profile pointer to be deep-copied") + } + if copied.Profile.LastSyncedAt == original.Profile.LastSyncedAt { + t.Fatal("expected profile lastSyncedAt pointer to be deep-copied") + } updatedIdle := metav1.NewTime(copied.IdleExpiresAt.Add(2 * time.Hour)) updatedMax := metav1.NewTime(copied.MaxExpiresAt.Add(2 * time.Hour)) @@ -38,4 +50,7 @@ func TestSpritzStatusDeepCopyIntoCopiesLifecycleTimestamps(t *testing.T) { if !original.MaxExpiresAt.Equal(&max) { t.Fatalf("expected original max expiry to stay unchanged, got %#v", original.MaxExpiresAt) } + if !original.Profile.LastSyncedAt.Equal(&synced) { + t.Fatalf("expected original profile sync time to stay unchanged, got %#v", original.Profile.LastSyncedAt) + } } diff --git a/ui/src/components/acp/sidebar.test.tsx b/ui/src/components/acp/sidebar.test.tsx index 7441e95..75350ee 100644 --- a/ui/src/components/acp/sidebar.test.tsx +++ b/ui/src/components/acp/sidebar.test.tsx @@ -1,46 +1,12 @@ +import type React from 'react'; import { describe, expect, it, vi } from 'vite-plus/test'; -import { screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import { Sidebar } from './sidebar'; import { renderWithProviders } from '@/test/helpers'; import type { ConversationInfo } from '@/types/acp'; import type { Spritz } from '@/types/spritz'; -describe('Sidebar', () => { - it('uses the branded emphasis treatment for the active conversation', () => { - const spritz = { - metadata: { name: 'claude-code-lucky-tidepool' }, - } as Spritz; - const conversation = { - metadata: { name: 'conv-1' }, - spec: { title: 'Today work', spritzName: 'claude-code-lucky-tidepool' }, - status: {}, - } as ConversationInfo; - - renderWithProviders( - , - ); - - const activeConversation = screen.getByRole('button', { name: 'Today work' }); - expect(activeConversation.className).toContain('bg-[var(--surface-emphasis)]'); - expect(activeConversation.className).toContain('text-primary'); - expect(activeConversation.className).toContain( - 'shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--primary)_14%,transparent)]', - ); -import type React from 'react'; -import { describe, it, expect, vi } from 'vite-plus/test'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { Sidebar } from './sidebar'; - vi.mock('@/components/brand-header', () => ({ BrandHeader: ({ compact }: { compact?: boolean }) => (
{compact ? 'Brand compact' : 'Brand'}
@@ -59,7 +25,7 @@ vi.mock('@/components/ui/tooltip', () => ({ }) => <>{render ?? children}, })); -function createSpritz(name: string) { +function createSpritz(name: string): Spritz { return { metadata: { name, namespace: 'default' }, spec: { image: `example.com/${name}:latest` }, @@ -70,7 +36,11 @@ function createSpritz(name: string) { }; } -function createConversation(name: string, title: string, spritzName: string) { +function createConversation( + name: string, + title: string, + spritzName: string, +): ConversationInfo { return { metadata: { name }, spec: { @@ -87,10 +57,42 @@ function createConversation(name: string, title: string, spritzName: string) { const SidebarWithFocus = Sidebar as unknown as ( props: React.ComponentProps & { focusedSpritzName?: string | null; + focusedSpritz?: Spritz | null; }, ) => React.ReactElement; describe('Sidebar', () => { + it('uses the branded emphasis treatment for the active conversation', () => { + const spritz = { + metadata: { name: 'claude-code-lucky-tidepool' }, + } as Spritz; + const conversation = { + metadata: { name: 'conv-1' }, + spec: { title: 'Today work', spritzName: 'claude-code-lucky-tidepool' }, + status: {}, + } as ConversationInfo; + + renderWithProviders( + , + ); + + const activeConversation = screen.getByRole('button', { name: 'Today work' }); + expect(activeConversation.className).toContain('bg-[var(--surface-emphasis)]'); + expect(activeConversation.className).toContain('text-primary'); + expect(activeConversation.className).toContain( + 'shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--primary)_14%,transparent)]', + ); + }); + it('moves the focused agent to the top, highlights it, and collapses other agents', () => { render( @@ -98,11 +100,15 @@ describe('Sidebar', () => { agents={[ { spritz: createSpritz('alpha'), - conversations: [createConversation('alpha-conv', 'Alpha conversation', 'alpha')], + conversations: [ + createConversation('alpha-conv', 'Alpha conversation', 'alpha'), + ], }, { spritz: createSpritz('beta'), - conversations: [createConversation('beta-conv', 'Beta conversation', 'beta')], + conversations: [ + createConversation('beta-conv', 'Beta conversation', 'beta'), + ], }, ]} selectedConversationId="beta-conv" @@ -117,11 +123,25 @@ describe('Sidebar', () => { , ); - const agentHeaders = screen.getAllByRole('button', { name: / conversations$/i }); + const agentHeaders = screen.getAllByRole('button', { + name: / conversations$/i, + }); expect(agentHeaders[0]?.getAttribute('aria-label')).toBe('beta conversations'); - expect(screen.getByRole('button', { name: 'beta conversations' }).getAttribute('aria-current')).toBe('true'); - expect(screen.getByRole('button', { name: 'beta conversations' }).getAttribute('aria-expanded')).toBe('true'); - expect(screen.getByRole('button', { name: 'alpha conversations' }).getAttribute('aria-expanded')).toBe('false'); + expect( + screen + .getByRole('button', { name: 'beta conversations' }) + .getAttribute('aria-current'), + ).toBe('true'); + expect( + screen + .getByRole('button', { name: 'beta conversations' }) + .getAttribute('aria-expanded'), + ).toBe('true'); + expect( + screen + .getByRole('button', { name: 'alpha conversations' }) + .getAttribute('aria-expanded'), + ).toBe('false'); }); it('shows a selected optimistic provisioning conversation for a focused route before the agent is discoverable', () => { @@ -144,6 +164,8 @@ describe('Sidebar', () => { expect(screen.getByText('zeno-fresh-ridge')).toBeTruthy(); expect(screen.getByText('Creating your agent instance.')).toBeTruthy(); - expect(screen.getByText('Starting…').closest('[aria-current="true"]')).toBeTruthy(); + expect( + screen.getByText('Starting…').closest('[aria-current="true"]'), + ).toBeTruthy(); }); }); diff --git a/ui/src/components/acp/sidebar.tsx b/ui/src/components/acp/sidebar.tsx index ecc88f9..4039c88 100644 --- a/ui/src/components/acp/sidebar.tsx +++ b/ui/src/components/acp/sidebar.tsx @@ -10,6 +10,13 @@ import { import { cn, timeAgo } from '@/lib/utils'; import { describeChatAction } from '@/lib/urls'; import { buildProvisioningPlaceholderSpritz, getProvisioningStatusLine } from '@/lib/provisioning'; +import { + getConversationAgentImageUrl, + getConversationAgentName, + getSpritzProfileImageUrl, + getSpritzProfileName, +} from '@/lib/spritz-profile'; +import { AgentAvatar } from '@/components/agent-avatar'; import { BrandHeader } from '@/components/brand-header'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import type { ConversationInfo } from '@/types/acp'; @@ -231,6 +238,8 @@ function FocusedAgentProvisioningSection({ selectedConversationId: string | null; }) { const name = spritz.metadata.name; + const displayName = getSpritzProfileName(spritz) || name; + const imageUrl = getSpritzProfileImageUrl(spritz); const statusLine = getProvisioningStatusLine(spritz); const conversationLabel = describeChatAction(spritz).label; const conversationSelected = !selectedConversationId; @@ -243,7 +252,8 @@ function FocusedAgentProvisioningSection({ className="flex flex-1 items-center gap-2 rounded-[var(--radius-lg)] bg-sidebar-accent px-3 py-1.5 text-left text-xs font-medium text-foreground" >