@datalayer/agent-runtimes 1.3.11 โ†’ 1.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +1 -2
  2. package/lib/AgentNode.js +3 -3
  3. package/lib/api/runtimes/healthz.js +4 -2
  4. package/lib/api/spacer/healthz.js +4 -2
  5. package/lib/chat/base/ChatBase.js +39 -25
  6. package/lib/chat/base/modelChoice.d.ts +62 -0
  7. package/lib/chat/base/modelChoice.js +56 -0
  8. package/lib/chat/messages/ChatMessageList.js +13 -5
  9. package/lib/chat/messages/FormSubmissionMessage.d.ts +17 -0
  10. package/lib/chat/messages/FormSubmissionMessage.js +49 -0
  11. package/lib/chat/messages/formSubmission.d.ts +41 -0
  12. package/lib/chat/messages/formSubmission.js +90 -0
  13. package/lib/chat/prompt/menus/ModelSelector.js +11 -4
  14. package/lib/chat/styles/streamdownStyles.js +40 -0
  15. package/lib/client/mixins/RuntimesMixin.js +1 -1
  16. package/lib/client/mixins/SpacerMixin.js +3 -3
  17. package/lib/components/a2ui/A2uiMarkdownProvider.d.ts +11 -0
  18. package/lib/components/a2ui/A2uiMarkdownProvider.js +13 -0
  19. package/lib/components/a2ui/index.d.ts +1 -0
  20. package/lib/components/a2ui/index.js +1 -0
  21. package/lib/examples/A2UiAgentExample.js +36 -223
  22. package/lib/examples/A2UiJupyterOutputExample.js +11 -1
  23. package/lib/examples/A2UiViewerExample.d.ts +9 -2
  24. package/lib/examples/A2UiViewerExample.js +33 -3
  25. package/lib/examples/main.js +37 -35
  26. package/lib/examples/utils/a2uiMarkdownProvider.d.ts +1 -7
  27. package/lib/examples/utils/a2uiMarkdownProvider.js +6 -8
  28. package/lib/loop/core/index.d.ts +33 -2
  29. package/lib/loop/core/index.js +29 -2
  30. package/lib/loop/embed/LoopEmbed.js +1 -0
  31. package/lib/loop/index.d.ts +1 -1
  32. package/lib/loop/index.js +4 -1
  33. package/lib/loop/plugins/a2ui-surface/InlineSurface.d.ts +22 -0
  34. package/lib/loop/plugins/a2ui-surface/InlineSurface.js +64 -0
  35. package/lib/loop/plugins/a2ui-surface/SurfaceToolResult.d.ts +38 -0
  36. package/lib/loop/plugins/a2ui-surface/SurfaceToolResult.js +64 -0
  37. package/lib/loop/plugins/a2ui-surface/index.d.ts +34 -0
  38. package/lib/loop/plugins/a2ui-surface/index.js +91 -0
  39. package/lib/loop/plugins/a2ui-surface/toolResult.d.ts +52 -0
  40. package/lib/loop/plugins/a2ui-surface/toolResult.js +106 -0
  41. package/lib/loop/plugins/chat/ChatView.js +81 -10
  42. package/lib/loop/plugins/chat/index.d.ts +14 -0
  43. package/lib/loop/plugins/chat/index.js +1 -0
  44. package/lib/loop/plugins/document/index.js +4 -0
  45. package/lib/loop/plugins/notebook/index.js +4 -0
  46. package/lib/loop/plugins/shell/useEditorPreviews.js +7 -0
  47. package/lib/loop/presets.d.ts +5 -0
  48. package/lib/loop/presets.js +7 -1
  49. package/lib/models/environments.generated.d.ts +7 -1
  50. package/lib/models/environments.generated.js +1 -1
  51. package/lib/specs/agents/agents.d.ts +1 -0
  52. package/lib/specs/agents/agents.js +112 -0
  53. package/lib/specs/models.js +3 -3
  54. package/lib/tools/adapters/copilotkit/CopilotKitToolAdapter.js +1 -1
  55. package/package.json +10 -4
  56. package/patches/@primer+react+37.31.0.patch +390 -0
  57. package/patches/eslint-plugin-react+7.37.5.patch +65 -0
  58. package/scripts/apply-patches.sh +15 -1
  59. package/scripts/codegen/compose.py +4 -4
  60. package/scripts/codegen/generate_agents.py +1 -2
  61. package/scripts/codegen/generate_frontend_tools.py +1 -1
  62. package/scripts/codegen/generate_models.py +33 -8
package/README.md CHANGED
@@ -10,8 +10,7 @@
10
10
 
11
11
  # ๐Ÿค– ๐Ÿš€ Agent Runtimes
12
12
 
13
- [![Github Actions Status](https://github.com/datalayer/agent-runtimes/actions/workflows/build.yml/badge.svg)](https://github.com/datalayer/agent-runtimes/actions/workflows/build.yml)
14
- [![Netlify Status](https://api.netlify.com/api/v1/badges/f7f9e08a-884f-4f76-b20d-666d5873716c/deploy-status)](https://app.netlify.com/projects/agent-runtimes/deploys)
13
+ [![Github Actions Status](https://github.com/datalayer/agent-runtimes/actions/workflows/build.yaml/badge.svg)](https://github.com/datalayer/agent-runtimes/actions/workflows/build.yaml)
15
14
  [![PyPI - Version](https://img.shields.io/pypi/v/agent-runtimes)](https://pypi.org/project/agent-runtimes)
16
15
 
17
16
  **Agent Runtimes** is a unified library for deploying, managing, and interacting with AI agents across multiple protocols and frameworks. It provides both a Python server for hosting agents and React components for seamless integration into web and desktop applications.
package/lib/AgentNode.js CHANGED
@@ -819,8 +819,8 @@ export function AgentNode() {
819
819
  const aiAgentsUrl = getConfigUrlFromDocument('aiAgentsUrl') ||
820
820
  import.meta.env?.VITE_DATALAYER_AI_AGENTS_URL ||
821
821
  'https://r1.datalayer.run';
822
- const jupyterMcpServerUrl = getConfigUrlFromDocument('jupyterMcpServerUrl') ||
823
- import.meta.env?.VITE_DATALAYER_JUPYTER_MCP_SERVER_URL ||
822
+ const datalayerMcpServerUrl = getConfigUrlFromDocument('datalayerMcpServerUrl') ||
823
+ import.meta.env?.VITE_DATALAYER_MCP_SERVER_URL ||
824
824
  'https://mcp.datalayer.run/mcp';
825
825
  // Seed all per-service URLs to match the main UI login behavior.
826
826
  const coreApi = coreStore.getState();
@@ -832,7 +832,7 @@ export function AgentNode() {
832
832
  libraryUrl: iamUrl,
833
833
  aiAgentsUrl,
834
834
  aiInferenceUrl: aiInferenceUrl,
835
- jupyterMcpServerUrl,
835
+ datalayerMcpServerUrl,
836
836
  otelUrl: iamUrl,
837
837
  growthUrl: iamUrl,
838
838
  successUrl: iamUrl,
@@ -36,9 +36,11 @@ export const ping = async (baseUrl = DEFAULT_SERVICE_URLS.RUNTIMES) => {
36
36
  // Check if it's a response error with status code information
37
37
  if (error.response) {
38
38
  const status = error.response.status;
39
- throw new Error(`Health check failed: Service unhealthy (status ${status}) - ${error.message}`);
39
+ throw new Error(`Health check failed: Service unhealthy (status ${status}) - ${error.message}`, { cause: error });
40
40
  }
41
41
  // Re-throw other errors (network errors, etc.)
42
- throw new Error(`Health check failed: ${error.message}`);
42
+ throw new Error(`Health check failed: ${error.message}`, {
43
+ cause: error,
44
+ });
43
45
  }
44
46
  };
@@ -36,9 +36,11 @@ export const ping = async (baseUrl = DEFAULT_SERVICE_URLS.SPACER) => {
36
36
  // Check if it's a response error with status code information
37
37
  if (error.response) {
38
38
  const status = error.response.status;
39
- throw new Error(`Health check failed: Service unhealthy (status ${status}) - ${error.message}`);
39
+ throw new Error(`Health check failed: Service unhealthy (status ${status}) - ${error.message}`, { cause: error });
40
40
  }
41
41
  // Re-throw other errors (network errors, etc.)
42
- throw new Error(`Health check failed: ${error.message}`);
42
+ throw new Error(`Health check failed: ${error.message}`, {
43
+ cause: error,
44
+ });
43
45
  }
44
46
  };
@@ -31,7 +31,7 @@ import { useChatStore } from '../../stores/chatStore';
31
31
  import { useConversationStore } from '../../stores/conversationStore';
32
32
  import { generateMessageId, createUserMessage, createAssistantMessage, } from '../../types/messages';
33
33
  import { AgentDetails } from '../../agents/AgentDetails';
34
- import { AI_MODEL_CATALOGUE } from '../../specs/models';
34
+ import { AI_MODEL_CATALOGUE, DEFAULT_MODEL } from '../../specs/models';
35
35
  import { internalQueryClient, isToolCallMessage, convertHistoryToDisplayItems, createProtocolAdapter, getApiBaseFromConfig, sanitizeAssistantContent, } from '../../utils';
36
36
  import { useConfig, useSkills, useSkillActions, useContextSnapshot, useSandbox, } from '../../hooks';
37
37
  import { useAgentRuntimeWebSocket } from '../../hooks/useAgentRuntimes';
@@ -48,6 +48,7 @@ import { InputPrompt } from '../prompt/InputPrompt';
48
48
  import { isFloatingChatViewMode } from '../viewModes';
49
49
  import { ToolApprovalBanner, ToolApprovalDialog, } from '../tools';
50
50
  import { EphemeralNotebook } from '../notebook/EphemeralNotebook';
51
+ import { initialModelId, isOffered, usableModels } from './modelChoice';
51
52
  // EphemeralDocument statically imports `@datalayer/jupyter-lexical` (which
52
53
  // initialises Lumino-backed nodes on load). Lazy-load it so notebook-only chats
53
54
  // never pull lexical into the bundle or trigger its module side effects.
@@ -1378,9 +1379,10 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1378
1379
  ? (protocol?.options).model
1379
1380
  : undefined;
1380
1381
  const offeredModels = useMemo(() => {
1382
+ // Only what can actually be called โ€” see `modelChoice` for the rule.
1381
1383
  const fromConfig = availableModels || configQuery.data?.models;
1382
1384
  if (fromConfig?.length) {
1383
- return fromConfig;
1385
+ return usableModels(fromConfig);
1384
1386
  }
1385
1387
  /*
1386
1388
  * The catalogue, filtered to what is worth offering.
@@ -1402,7 +1404,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1402
1404
  provider: model.provider,
1403
1405
  }));
1404
1406
  if (catalogued.length > 0) {
1405
- return catalogued;
1407
+ return usableModels(catalogued);
1406
1408
  }
1407
1409
  return browserModel
1408
1410
  ? [{ id: browserModel, name: browserModel, provider: 'inference' }]
@@ -1721,7 +1723,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1721
1723
  const timer = setTimeout(adjustTextareaHeight, 0);
1722
1724
  return () => clearTimeout(timer);
1723
1725
  }, [adjustTextareaHeight]);
1724
- // ---- Initialize model and tools when config is available ----
1726
+ // ---- The model, whenever what is offered changes ----
1725
1727
  useEffect(() => {
1726
1728
  /*
1727
1729
  * `offeredModels`, not the two sources it is built from.
@@ -1731,28 +1733,40 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1731
1733
  * sources, so that model was offered by the menu and never selected. The
1732
1734
  * menu draws nothing without a selection, so a browser agent had a model
1733
1735
  * list of one and no model control at all.
1736
+ *
1737
+ * Re-picked, not picked once. Before the server has answered, the offer
1738
+ * is the browser's catalogue, and the chat chose from it โ€” the first row,
1739
+ * Alibaba, which the catalogue marks available because the platform is
1740
+ * entitled to it. The server then answered with the one model this
1741
+ * process can call, the guess was not in it, and nothing looked again:
1742
+ * the trigger read "none selected" while every turn was still sent to
1743
+ * Alibaba and came back asking for its key. A selection stands only while
1744
+ * it is offered and usable; otherwise the preferred model โ€” the agent's
1745
+ * own, from the server โ€” or the first usable one takes its place.
1734
1746
  */
1735
- if (offeredModels.length > 0 && !selectedModel) {
1736
- const modelsList = offeredModels;
1737
- const preferredModel = initialModel || configQuery.data?.defaultModel;
1738
- if (preferredModel) {
1739
- const modelExists = modelsList.some(m => m.id === preferredModel);
1740
- if (modelExists) {
1741
- setSelectedModel(preferredModel);
1742
- }
1743
- else {
1744
- const firstAvailableModel = modelsList.find(m => m.isAvailable !== false);
1745
- const firstModel = firstAvailableModel || modelsList[0];
1746
- if (firstModel)
1747
- setSelectedModel(firstModel.id);
1748
- }
1749
- }
1750
- else {
1751
- const firstAvailableModel = modelsList.find(m => m.isAvailable !== false);
1752
- const firstModel = firstAvailableModel || modelsList[0];
1753
- if (firstModel)
1754
- setSelectedModel(firstModel.id);
1755
- }
1747
+ if (offeredModels.length === 0 || isOffered(offeredModels, selectedModel)) {
1748
+ return;
1749
+ }
1750
+ // The host's word first, then the server's default, then the platform's:
1751
+ // the first row of a catalogue is an order of listing, not a choice, and
1752
+ // opening on it sent the first message to Alibaba while the footer โ€”
1753
+ // drawn from the host's own model โ€” said Claude.
1754
+ const opening = initialModelId(offeredModels, initialModel || configQuery.data?.defaultModel || DEFAULT_MODEL);
1755
+ if (opening && opening !== selectedModel) {
1756
+ setSelectedModel(opening);
1757
+ }
1758
+ }, [
1759
+ offeredModels,
1760
+ selectedModel,
1761
+ initialModel,
1762
+ configQuery.data?.defaultModel,
1763
+ ]);
1764
+ // ---- Initialize tools when config is available ----
1765
+ const toolsInitializedRef = useRef(false);
1766
+ useEffect(() => {
1767
+ // Once: a later change to the offer must not undo what the reader toggled.
1768
+ if (offeredModels.length > 0 && !toolsInitializedRef.current) {
1769
+ toolsInitializedRef.current = true;
1756
1770
  const allToolIds = configQuery.data?.builtinTools?.map(tool => tool.id) || [];
1757
1771
  setEnabledTools(allToolIds);
1758
1772
  if (configQuery.data?.mcpServers) {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Which models a chat offers, and which one it opens on.
3
+ *
4
+ * Two rules, kept out of `ChatBase` so they can be read and tested without
5
+ * rendering a chat:
6
+ *
7
+ * - **Only what can be called is offered.** A server answers with its whole
8
+ * catalogue and a flag per model, and the menu used to draw all of it, most
9
+ * rows greyed out with a reason. Thirty models to choose two from reads as
10
+ * a broken menu, and the one thing it guarantees is that a reader's first
11
+ * click lands on something that cannot answer.
12
+ * - **The default has to be one of them.** A default naming a model this
13
+ * deployment cannot use โ€” a spec claiming the default while marked
14
+ * unavailable โ€” opened every chat on it, and the first message came back
15
+ * as the provider's complaint about a missing key.
16
+ *
17
+ * @module chat/base/modelChoice
18
+ */
19
+ import type { ModelConfig } from '../../types/chat';
20
+ /** Whether a model can be picked: the flag says no, or says nothing. */
21
+ export declare const isUsable: (model: ModelConfig) => boolean;
22
+ /**
23
+ * The models to offer: the usable ones.
24
+ *
25
+ * When none is usable the list is kept whole. An empty menu says nothing,
26
+ * while the rows and their reasons say which key is missing.
27
+ */
28
+ export declare function usableModels(models: ModelConfig[]): ModelConfig[];
29
+ /** Whether `id` is on offer and usable โ€” the selection may stand. */
30
+ export declare const isOffered: (offered: ModelConfig[], id?: string) => boolean;
31
+ /**
32
+ * The model a chat opens on.
33
+ *
34
+ * The preferred one when it is offered *and* usable; otherwise the first
35
+ * usable one; otherwise the first there is. `undefined` when there is none.
36
+ */
37
+ export declare function initialModelId(offered: ModelConfig[], preferred?: string | null): string | undefined;
38
+ /**
39
+ * One row of `/api/v1/configure/models`, as the server writes it.
40
+ *
41
+ * Snake case and its own flag names: the route is read by more than this
42
+ * chat, and its shape is the server's to keep.
43
+ */
44
+ export type ServerCatalogueModel = {
45
+ id: string;
46
+ name?: string;
47
+ available?: boolean;
48
+ missing_env_vars?: string[];
49
+ reason?: string;
50
+ warning?: string | null;
51
+ };
52
+ /**
53
+ * The server's catalogue in the chat's shape.
54
+ *
55
+ * The route says `available`; the chat, the menu and the rules above read
56
+ * `isAvailable`. Handed over unmapped, every row looked usable, the menu
57
+ * offered all thirty-one, and the first message of a chat that opened before
58
+ * its spec was known went to the first row โ€” Alibaba, and its missing key.
59
+ */
60
+ export declare function readServerCatalogue(payload: {
61
+ models?: ServerCatalogueModel[];
62
+ } | null | undefined): ModelConfig[];
@@ -0,0 +1,56 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /** Whether a model can be picked: the flag says no, or says nothing. */
6
+ export const isUsable = (model) => model.isAvailable !== false;
7
+ /**
8
+ * The models to offer: the usable ones.
9
+ *
10
+ * When none is usable the list is kept whole. An empty menu says nothing,
11
+ * while the rows and their reasons say which key is missing.
12
+ */
13
+ export function usableModels(models) {
14
+ const usable = models.filter(isUsable);
15
+ return usable.length > 0 ? usable : models;
16
+ }
17
+ /** Whether `id` is on offer and usable โ€” the selection may stand. */
18
+ export const isOffered = (offered, id) => Boolean(id) && offered.some(m => m.id === id && isUsable(m));
19
+ /**
20
+ * The model a chat opens on.
21
+ *
22
+ * The preferred one when it is offered *and* usable; otherwise the first
23
+ * usable one; otherwise the first there is. `undefined` when there is none.
24
+ */
25
+ export function initialModelId(offered, preferred) {
26
+ if (preferred && offered.some(m => m.id === preferred && isUsable(m))) {
27
+ return preferred;
28
+ }
29
+ const first = offered.find(isUsable) ?? offered[0];
30
+ return first?.id;
31
+ }
32
+ /**
33
+ * The server's catalogue in the chat's shape.
34
+ *
35
+ * The route says `available`; the chat, the menu and the rules above read
36
+ * `isAvailable`. Handed over unmapped, every row looked usable, the menu
37
+ * offered all thirty-one, and the first message of a chat that opened before
38
+ * its spec was known went to the first row โ€” Alibaba, and its missing key.
39
+ */
40
+ export function readServerCatalogue(payload) {
41
+ return (payload?.models ?? []).map(model => {
42
+ const missing = model.missing_env_vars ?? [];
43
+ const reason = model.reason ??
44
+ (missing.length > 0 ? `Set ${missing.join(', ')}` : undefined) ??
45
+ model.warning ??
46
+ undefined;
47
+ return {
48
+ id: model.id,
49
+ name: model.name ?? model.id,
50
+ isAvailable: model.available !== false,
51
+ ...(reason && model.available === false
52
+ ? { unavailableReason: reason }
53
+ : {}),
54
+ };
55
+ });
56
+ }
@@ -18,6 +18,8 @@ import { streamdownMarkdownStyles, streamdownCodeBlockStyles, } from '../styles/
18
18
  import { ToolCallDisplay } from '../tools/ToolCallDisplay';
19
19
  import { TurnFooter } from './TurnFooter';
20
20
  import { normalizeAssistantMarkdown } from './assistantMarkdown';
21
+ import { parseFormSubmission } from './formSubmission';
22
+ import { FormSubmissionMessage } from './FormSubmissionMessage';
21
23
  import { isToolCallMessage, getMessageText } from '../../utils';
22
24
  import { A2AAgentDialog, a2aAgentDetails } from '../tools/A2AAgentDialog';
23
25
  import { SUBAGENT_STOPPED } from '../../types/stream';
@@ -697,11 +699,17 @@ export function ChatMessageList({ displayItems, isLoading, isStreaming, showLoad
697
699
  display: 'inline-flex',
698
700
  gap: 1,
699
701
  alignItems: 'center',
700
- }, children: [_jsx(ToolsIcon, { size: 10 }), " ", label] }) }, `${label}-${chipIndex}`))) })) : null, isUser ? (_jsx(Text, { sx: {
701
- fontSize: 1,
702
- whiteSpace: 'pre-wrap',
703
- wordBreak: 'break-word',
704
- }, children: getMessageText(message) })) : message.live && !getMessageText(message) ? (_jsx(TypingDots, { size: 6 })) : (_jsx(Box, { sx: streamdownMarkdownStyles, children: _jsx(Streamdown, { children: normalizeAssistantMarkdown(getMessageText(message) || (isStreaming ? '...' : '')) }) }))] }), renderMessageFooter ? (_jsx(Box, { sx: {
702
+ }, children: [_jsx(ToolsIcon, { size: 10 }), " ", label] }) }, `${label}-${chipIndex}`))) })) : null, isUser ? ((() => {
703
+ // A submitted form is drawn as the form, not as the JSON
704
+ // the agent reads โ€” see `formSubmission`.
705
+ const text = getMessageText(message);
706
+ const submission = parseFormSubmission(text);
707
+ return submission ? (_jsx(FormSubmissionMessage, { submission: submission })) : (_jsx(Text, { sx: {
708
+ fontSize: 1,
709
+ whiteSpace: 'pre-wrap',
710
+ wordBreak: 'break-word',
711
+ }, children: text }));
712
+ })()) : message.live && !getMessageText(message) ? (_jsx(TypingDots, { size: 6 })) : (_jsx(Box, { sx: streamdownMarkdownStyles, children: _jsx(Streamdown, { children: normalizeAssistantMarkdown(getMessageText(message) || (isStreaming ? '...' : '')) }) }))] }), renderMessageFooter ? (_jsx(Box, { sx: {
705
713
  display: 'flex',
706
714
  gap: 2,
707
715
  alignItems: 'center',
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A submitted form, as a card in the transcript.
3
+ *
4
+ * Drawn where the reader's turn would be, in the shape a tool call has: a
5
+ * header that names the form and says how many fields it carried, a chevron
6
+ * that opens it, and the fields as a two-column table underneath. The JSON
7
+ * the agent reads stays in the turn's text; the reader sees the form they
8
+ * filled in.
9
+ *
10
+ * @module chat/messages/FormSubmissionMessage
11
+ */
12
+ import type { JSX } from 'react';
13
+ import { type FormSubmission } from './formSubmission';
14
+ export declare function FormSubmissionMessage({ submission, }: {
15
+ submission: FormSubmission;
16
+ }): JSX.Element;
17
+ export default FormSubmissionMessage;
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Text } from '@primer/react';
4
+ import { Box } from '@datalayer/primer-addons';
5
+ import { ChecklistIcon, ChevronDownIcon, ChevronRightIcon, } from '@primer/octicons-react';
6
+ import { displayValue, labelOfFieldId, } from './formSubmission';
7
+ export function FormSubmissionMessage({ submission, }) {
8
+ const [expanded, setExpanded] = useState(true);
9
+ const entries = Object.entries(submission.values);
10
+ const count = entries.length;
11
+ return (_jsxs(Box, { sx: {
12
+ width: '100%',
13
+ minWidth: 280,
14
+ border: '1px solid',
15
+ borderColor: 'border.default',
16
+ borderRadius: '12px',
17
+ overflow: 'hidden',
18
+ backgroundColor: 'canvas.default',
19
+ color: 'fg.default',
20
+ }, children: [_jsxs(Box, { as: "button", type: "button", "aria-expanded": expanded, onClick: () => setExpanded(value => !value), sx: {
21
+ width: '100%',
22
+ display: 'flex',
23
+ alignItems: 'center',
24
+ gap: 2,
25
+ padding: 2,
26
+ backgroundColor: 'canvas.subtle',
27
+ border: 'none',
28
+ borderBottom: expanded ? '1px solid' : 'none',
29
+ borderColor: 'border.default',
30
+ cursor: 'pointer',
31
+ textAlign: 'left',
32
+ color: 'fg.default',
33
+ '&:hover': { backgroundColor: 'neutral.muted' },
34
+ }, children: [_jsx(Box, { sx: { color: 'fg.muted', flexShrink: 0 }, children: expanded ? (_jsx(ChevronDownIcon, { size: 16 })) : (_jsx(ChevronRightIcon, { size: 16 })) }), _jsx(Box, { sx: { color: 'fg.muted', flexShrink: 0 }, children: _jsx(ChecklistIcon, { size: 16 }) }), _jsxs(Text, { sx: { fontWeight: 'semibold', fontSize: 1 }, children: ["Submitted ", submission.title] }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted', ml: 'auto' }, children: [count, " field", count === 1 ? '' : 's'] })] }), expanded ? (_jsx(Box, { as: "table", sx: { width: '100%', borderCollapse: 'collapse' }, children: _jsx(Box, { as: "tbody", children: entries.map(([id, value]) => (_jsxs(Box, { as: "tr", sx: {
35
+ '& + tr td': {
36
+ borderTop: '1px solid',
37
+ borderColor: 'border.muted',
38
+ },
39
+ }, children: [_jsx(Box, { as: "td", sx: {
40
+ px: 3,
41
+ py: 1,
42
+ fontSize: 0,
43
+ color: 'fg.muted',
44
+ whiteSpace: 'nowrap',
45
+ verticalAlign: 'top',
46
+ width: '40%',
47
+ }, children: labelOfFieldId(id) }), _jsx(Box, { as: "td", sx: { px: 3, py: 1, fontSize: 1, wordBreak: 'break-word' }, children: displayValue(value) })] }, id))) }) })) : null] }));
48
+ }
49
+ export default FormSubmissionMessage;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A form submission as a chat turn: written for the agent, drawn for the
3
+ * reader.
4
+ *
5
+ * When a reader submits a surface the agent drew, the values go back to the
6
+ * agent as the reader's next turn โ€” that is how the confirmation lands in
7
+ * the conversation. But a turn is text, and the reader was shown that text:
8
+ * a JSON blob under "here are the values", which is the agent's reading and
9
+ * nobody else's. So the turn carries the submission in a fenced block of its
10
+ * own language, `a2ui-submission`. The agent reads the JSON inside it as
11
+ * before; the transcript recognises the fence and draws a card instead โ€”
12
+ * the form's name and its fields, the way a tool call is drawn.
13
+ *
14
+ * Pure, so the two halves โ€” the sender's writing, the transcript's reading โ€”
15
+ * are tested together without a chat.
16
+ *
17
+ * @module chat/messages/formSubmission
18
+ */
19
+ /** The fence's language: what marks a turn as a submission. */
20
+ export declare const FORM_SUBMISSION_LANGUAGE = "a2ui-submission";
21
+ export type FormSubmission = {
22
+ /** The form's title, as the surface named it. */
23
+ title: string;
24
+ /** The surface the values came from, when known. */
25
+ surfaceId?: string;
26
+ /** The values, by field id. */
27
+ values: Record<string, unknown>;
28
+ };
29
+ /** The turn to send: a sentence, the fenced submission, the ask. */
30
+ export declare function encodeFormSubmission(submission: FormSubmission): string;
31
+ /** The submission a turn carries, or `null` for any other turn. */
32
+ export declare function parseFormSubmission(text: string): FormSubmission | null;
33
+ /**
34
+ * A field id as a label: `party-size` โ†’ `Party size`.
35
+ *
36
+ * The turn carries ids, not labels โ€” the values are keyed by them โ€” and an
37
+ * id is a readable enough label once its dashes and underscores are spaces.
38
+ */
39
+ export declare function labelOfFieldId(id: string): string;
40
+ /** A value as a reader would write it; `โ€”` for nothing. */
41
+ export declare function displayValue(value: unknown): string;
@@ -0,0 +1,90 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * A form submission as a chat turn: written for the agent, drawn for the
7
+ * reader.
8
+ *
9
+ * When a reader submits a surface the agent drew, the values go back to the
10
+ * agent as the reader's next turn โ€” that is how the confirmation lands in
11
+ * the conversation. But a turn is text, and the reader was shown that text:
12
+ * a JSON blob under "here are the values", which is the agent's reading and
13
+ * nobody else's. So the turn carries the submission in a fenced block of its
14
+ * own language, `a2ui-submission`. The agent reads the JSON inside it as
15
+ * before; the transcript recognises the fence and draws a card instead โ€”
16
+ * the form's name and its fields, the way a tool call is drawn.
17
+ *
18
+ * Pure, so the two halves โ€” the sender's writing, the transcript's reading โ€”
19
+ * are tested together without a chat.
20
+ *
21
+ * @module chat/messages/formSubmission
22
+ */
23
+ /** The fence's language: what marks a turn as a submission. */
24
+ export const FORM_SUBMISSION_LANGUAGE = 'a2ui-submission';
25
+ const FENCE = new RegExp('```' + FORM_SUBMISSION_LANGUAGE + '\\s*\\n([\\s\\S]*?)\\n```');
26
+ /** The turn to send: a sentence, the fenced submission, the ask. */
27
+ export function encodeFormSubmission(submission) {
28
+ const json = JSON.stringify(submission, null, 2);
29
+ return (`I just submitted "${submission.title}".\n\n` +
30
+ '```' +
31
+ FORM_SUBMISSION_LANGUAGE +
32
+ '\n' +
33
+ json +
34
+ '\n```\n\n' +
35
+ 'Please confirm you received it and briefly say what happens next.');
36
+ }
37
+ /** The submission a turn carries, or `null` for any other turn. */
38
+ export function parseFormSubmission(text) {
39
+ const match = FENCE.exec(text);
40
+ if (!match) {
41
+ return null;
42
+ }
43
+ try {
44
+ const parsed = JSON.parse(match[1]);
45
+ if (!parsed ||
46
+ typeof parsed !== 'object' ||
47
+ typeof parsed.title !== 'string' ||
48
+ !parsed.values ||
49
+ typeof parsed.values !== 'object') {
50
+ return null;
51
+ }
52
+ return {
53
+ title: parsed.title,
54
+ ...(parsed.surfaceId ? { surfaceId: String(parsed.surfaceId) } : {}),
55
+ values: parsed.values,
56
+ };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * A field id as a label: `party-size` โ†’ `Party size`.
64
+ *
65
+ * The turn carries ids, not labels โ€” the values are keyed by them โ€” and an
66
+ * id is a readable enough label once its dashes and underscores are spaces.
67
+ */
68
+ export function labelOfFieldId(id) {
69
+ const words = id.replace(/[-_]+/g, ' ').trim();
70
+ return words ? words[0].toUpperCase() + words.slice(1) : id;
71
+ }
72
+ /** A value as a reader would write it; `โ€”` for nothing. */
73
+ export function displayValue(value) {
74
+ if (value === null || value === undefined) {
75
+ return 'โ€”';
76
+ }
77
+ if (typeof value === 'string') {
78
+ return value.trim() === '' ? 'โ€”' : value;
79
+ }
80
+ if (Array.isArray(value)) {
81
+ return value.length === 0 ? 'โ€”' : value.map(displayValue).join(', ');
82
+ }
83
+ if (typeof value === 'boolean') {
84
+ return value ? 'Yes' : 'No';
85
+ }
86
+ if (typeof value === 'number') {
87
+ return String(value);
88
+ }
89
+ return JSON.stringify(value);
90
+ }
@@ -13,15 +13,22 @@ import { Box } from '@datalayer/primer-addons';
13
13
  import { AiModelIcon } from '@primer/octicons-react';
14
14
  export function ModelSelector({ models, selectedModel, onModelSelect, isA2AProtocol, }) {
15
15
  const active = models.find(model => model.id === selectedModel);
16
+ /*
17
+ * A count, not a name โ€” the same trigger as tools, skills, agents and
18
+ * suggestions beside it. The name is the longest label on the row and it
19
+ * changes width as the model changes, which pushed everything beside it
20
+ * about; the tooltip says which one, and how many there are to choose from.
21
+ */
22
+ const summary = isA2AProtocol
23
+ ? 'set by the agent config'
24
+ : `${active?.name ?? 'none selected'} ยท ${models.length} to choose from`;
16
25
  return (_jsxs(Box, { sx: {
17
26
  display: 'flex',
18
27
  flexDirection: 'column',
19
28
  alignItems: 'flex-end',
20
- }, children: [_jsxs(ActionMenu, { children: [_jsx(ActionMenu.Anchor, { children: _jsx(Tooltip, { text: isA2AProtocol
21
- ? 'This protocol does not take a model override'
22
- : `Model โ€” ${active?.name ?? 'none selected'}`, direction: "n", children: _jsx(Button, { type: "button", variant: "invisible", size: "small", "aria-label": `Model โ€” ${active?.name ?? 'none selected'}`, leadingVisual: AiModelIcon, disabled: isA2AProtocol, sx: isA2AProtocol
29
+ }, children: [_jsxs(ActionMenu, { children: [_jsx(ActionMenu.Anchor, { children: _jsx(Tooltip, { text: `Model โ€” ${summary}`, direction: "n", children: _jsx(Button, { type: "button", variant: "invisible", size: "small", "aria-label": `Model โ€” ${summary}`, leadingVisual: AiModelIcon, disabled: isA2AProtocol, sx: isA2AProtocol
23
30
  ? { opacity: 0.5, cursor: 'not-allowed' }
24
- : undefined }) }) }), _jsx(ActionMenu.Overlay, { side: "outside-top", align: "end", children: _jsx(ActionList, { selectionVariant: "single", children: models.map(modelItem => (_jsxs(ActionList.Item, { selected: selectedModel === modelItem.id, onSelect: () => onModelSelect(modelItem.id), disabled: modelItem.isAvailable === false || isA2AProtocol, sx: modelItem.isAvailable === false
31
+ : undefined, children: _jsx(Text, { sx: { fontSize: 0 }, children: models.length }) }) }) }), _jsx(ActionMenu.Overlay, { side: "outside-top", align: "end", children: _jsx(ActionList, { selectionVariant: "single", children: models.map(modelItem => (_jsxs(ActionList.Item, { selected: selectedModel === modelItem.id, onSelect: () => onModelSelect(modelItem.id), disabled: modelItem.isAvailable === false || isA2AProtocol, sx: modelItem.isAvailable === false
25
32
  ? { color: 'fg.muted' }
26
33
  : undefined, children: [modelItem.name, modelItem.isAvailable === false && (_jsx(ActionList.Description, { variant: "block", children: modelItem.unavailableReason ?? 'Missing API key' }))] }, modelItem.id))) }) })] }), isA2AProtocol && (_jsx(Text, { sx: { fontSize: 0, color: 'attention.fg', mt: 1 }, children: "A2A: Model set by agent config" }))] }));
27
34
  }
@@ -250,6 +250,46 @@ export const streamdownMarkdownStyles = {
250
250
  marginTop: '1em',
251
251
  marginBottom: '1em',
252
252
  },
253
+ /*
254
+ * The controls Streamdown draws over a table, an image and a diagram: copy
255
+ * and download, and the download's own menu.
256
+ *
257
+ * They are plain `<button>`s with Tailwind classes, and Tailwind's preflight
258
+ * โ€” which we do not load โ€” is what strips a button of its default chrome.
259
+ * Without it the browser drew them as it draws any button: a grey face and
260
+ * a bevelled border, in a message whose ground is the theme's. The reset
261
+ * here is the one the code block's header already has, and the colours are
262
+ * Primer's, so the row reads as part of the message in either colour mode.
263
+ */
264
+ '& [data-streamdown="table-wrapper"] button, & [data-streamdown="image-wrapper"] button, & [data-streamdown="mermaid-block"] button': {
265
+ background: 'transparent',
266
+ border: 'none',
267
+ font: 'inherit',
268
+ cursor: 'pointer',
269
+ color: 'fg.muted',
270
+ borderRadius: '4px',
271
+ '&:hover': {
272
+ backgroundColor: 'neutral.muted',
273
+ color: 'fg.default',
274
+ },
275
+ '&:disabled': { cursor: 'not-allowed', opacity: 0.5 },
276
+ },
277
+ '& [data-streamdown="table-wrapper"] > .flex': {
278
+ marginBottom: '4px',
279
+ },
280
+ // The download menu, and its rows: the popover's own ground and rule, not
281
+ // the button reset above.
282
+ '& [data-streamdown="table-wrapper"] .bg-background, & [data-streamdown="mermaid-block"] .bg-background': {
283
+ backgroundColor: 'canvas.overlay',
284
+ borderColor: 'border.default',
285
+ color: 'fg.default',
286
+ boxShadow: 'shadow.medium',
287
+ },
288
+ '& [data-streamdown="table-wrapper"] .bg-background button, & [data-streamdown="mermaid-block"] .bg-background button': {
289
+ color: 'fg.default',
290
+ borderRadius: 0,
291
+ '&:hover': { backgroundColor: 'neutral.muted' },
292
+ },
253
293
  // Include Tailwind utilities for Streamdown output
254
294
  ...tailwindUtilities,
255
295
  };
@@ -246,7 +246,7 @@ export function RuntimesMixin(Base) {
246
246
  async checkRuntimesHealth() {
247
247
  const startTime = Date.now();
248
248
  const errors = [];
249
- let status = 'unknown';
249
+ let status;
250
250
  let healthy = false;
251
251
  try {
252
252
  // Test basic connectivity by listing environments (lightweight operation)
@@ -244,9 +244,9 @@ export function SpacerMixin(Base) {
244
244
  // If getItem throws (e.g., 404), wrap in descriptive error
245
245
  if (error.message?.includes('404') ||
246
246
  error.message?.includes('not found')) {
247
- throw new Error(`Failed to delete space item '${itemId}': Item not found`);
247
+ throw new Error(`Failed to delete space item '${itemId}': Item not found`, { cause: error });
248
248
  }
249
- throw new Error(`Failed to delete space item '${itemId}': ${error.message}`);
249
+ throw new Error(`Failed to delete space item '${itemId}': ${error.message}`, { cause: error });
250
250
  }
251
251
  // Item exists, proceed with deletion
252
252
  const response = await items.deleteItem(token, itemId, spacerUrl);
@@ -291,7 +291,7 @@ export function SpacerMixin(Base) {
291
291
  async checkSpacerHealth() {
292
292
  const startTime = Date.now();
293
293
  const errors = [];
294
- let status = 'unknown';
294
+ let status;
295
295
  let healthy = false;
296
296
  try {
297
297
  // Test basic connectivity by getting user spaces (lightweight operation)