@datalayer/agent-runtimes 1.3.86 → 1.3.88

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 (66) hide show
  1. package/lib/components/a2ui/datalayer/Download.d.ts +21 -0
  2. package/lib/components/a2ui/datalayer/Download.js +66 -0
  3. package/lib/components/a2ui/datalayer/drawn.d.ts +1 -1
  4. package/lib/components/a2ui/datalayer/drawn.js +1 -0
  5. package/lib/components/a2ui/datalayer/index.d.ts +2 -1
  6. package/lib/components/a2ui/datalayer/index.js +3 -0
  7. package/lib/components/inspector/a2aSpans.d.ts +9 -0
  8. package/lib/components/inspector/a2aSpans.js +11 -1
  9. package/lib/components/teams/A2ATeamGraph.d.ts +2 -2
  10. package/lib/components/teams/A2ATeamGraph.js +39 -22
  11. package/lib/components/teams/AnswerSurfaces.d.ts +56 -0
  12. package/lib/components/teams/AnswerSurfaces.js +27 -0
  13. package/lib/components/teams/AnswerSurfacesView.d.ts +14 -0
  14. package/lib/components/teams/AnswerSurfacesView.js +30 -0
  15. package/lib/components/teams/index.d.ts +1 -0
  16. package/lib/components/teams/index.js +1 -0
  17. package/lib/components/teams/useA2ATeam.d.ts +56 -2
  18. package/lib/components/teams/useA2ATeam.js +122 -3
  19. package/lib/hooks/useAgentRuntimes.d.ts +13 -0
  20. package/lib/hooks/useAgentRuntimes.js +11 -1
  21. package/lib/loop/apps/AppFeedback.js +11 -2
  22. package/lib/loop/apps/AppRenderer.d.ts +13 -1
  23. package/lib/loop/apps/AppRenderer.js +34 -1
  24. package/lib/loop/apps/AppRuntimePlugins.d.ts +8 -0
  25. package/lib/loop/apps/AppRuntimePlugins.js +87 -0
  26. package/lib/loop/apps/feedback.d.ts +7 -0
  27. package/lib/loop/apps/feedback.js +3 -0
  28. package/lib/loop/apps/pluginPair.d.ts +60 -0
  29. package/lib/loop/apps/pluginPair.js +44 -0
  30. package/lib/loop/apps/threads.d.ts +35 -0
  31. package/lib/loop/apps/threads.js +79 -0
  32. package/lib/loop/embed/LoopEmbed.d.ts +4 -3
  33. package/lib/loop/embed/hostBridge.d.ts +27 -0
  34. package/lib/loop/embed/hostBridge.js +47 -0
  35. package/lib/loop/index.d.ts +1 -1
  36. package/lib/loop/index.js +1 -1
  37. package/lib/loop/plugins/agent-capacity/index.d.ts +7 -0
  38. package/lib/loop/plugins/agent-capacity/index.js +3 -2
  39. package/lib/loop/plugins/agents/DatalayerAgentBridge.js +11 -0
  40. package/lib/loop/plugins/agents/appLaunch.d.ts +15 -0
  41. package/lib/loop/plugins/agents/appLaunch.js +28 -0
  42. package/lib/loop/plugins/chat/ChatView.js +177 -109
  43. package/lib/loop/shell/LoopWorkspace.d.ts +3 -3
  44. package/lib/loop/shell/LoopWorkspace.js +1 -1
  45. package/lib/runtimes/__tests__/fixtures/accounting-agent-card.json +10 -2
  46. package/lib/runtimes/actions.js +23 -0
  47. package/lib/runtimes/apis.d.ts +18 -0
  48. package/lib/runtimes/browser/a2aPeer.d.ts +19 -2
  49. package/lib/runtimes/browser/a2aPeer.js +13 -7
  50. package/lib/specs/apps.js +102 -30
  51. package/lib/specs/envvars.d.ts +2 -0
  52. package/lib/specs/envvars.js +22 -0
  53. package/lib/specs/outputs.d.ts +1 -0
  54. package/lib/specs/outputs.js +12 -0
  55. package/lib/specs/sceneSchema.d.ts +8 -0
  56. package/lib/specs/sceneSchema.js +863 -0
  57. package/lib/specs/scenes.js +238 -121
  58. package/lib/specs/teams/teams.js +23 -7
  59. package/lib/specs/uiPlugins.js +55 -0
  60. package/lib/stores/agentRuntimeStore.js +4 -0
  61. package/lib/types/chat.d.ts +6 -0
  62. package/lib/types/scenes.d.ts +7 -1
  63. package/package.json +1 -1
  64. package/scripts/codegen/generate_envvars.py +1 -1
  65. package/scripts/codegen/generate_mcp_servers.py +11 -7
  66. package/scripts/codegen/generate_scenes.py +43 -1
@@ -22,7 +22,12 @@
22
22
  * and the way each link carries a message as its flow ({@link flowAfter}).
23
23
  * What a peer gives besides words — the formats the page accepts
24
24
  * (`accept`), such as a Jupyter notebook — is kept as its artifacts, and
25
- * the latest notebook kept until another one comes ({@link A2ATeam.notebook}).
25
+ * the latest notebook kept until another one comes ({@link A2ATeam.notebook}),
26
+ * and each surface of components of the catalog a peer shows
27
+ * (`application/json+a2ui`) kept with the peer that gave it, in the order
28
+ * they came ({@link A2ATeam.surfaces}, STUDIO H-02). A button pressed on
29
+ * one is a turn of the conversation ({@link A2ATeam.pressAction}): the
30
+ * person's line to that member, its answer from it, in the entry's history.
26
31
  * The tools a peer calls on its connections (its MCP servers), told over
27
32
  * A2A as it calls them, are kept as the calls running now ({@link callsAfter}).
28
33
  *
@@ -39,9 +44,10 @@
39
44
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
40
45
  import { stepCountIs, ToolLoopAgent } from 'ai';
41
46
  import { createBrowserModel, } from '../../runtimes/browser/model';
42
- import { a2aPeerTool, askA2APeer, NOTEBOOK_MEDIA_TYPE, } from '../../runtimes/browser/a2aPeer';
47
+ import { a2aPeerTool, askA2APeer, A2UI_MEDIA_TYPE, NOTEBOOK_MEDIA_TYPE, } from '../../runtimes/browser/a2aPeer';
43
48
  import { endAgentTurn, endToolCall, startAgentTurn, startToolCall, } from '../inspector/agentSpans';
44
49
  import { callsAfter, flowAfter, toolOwnName, pruneCalls, } from './a2aTeamFlow';
50
+ import { PERSON } from './sceneTranscript';
45
51
  import { toolLineOfStep, } from '../../chat/assistant/toolLine';
46
52
  const NO_CONNECTIONS = [];
47
53
  const NO_PEERS = [];
@@ -55,11 +61,42 @@ export const NOTEBOOK_AND_WORDS = [
55
61
  NOTEBOOK_MEDIA_TYPE,
56
62
  'text/markdown',
57
63
  ];
64
+ /**
65
+ * What a page that draws an answer's components accepts besides: the
66
+ * catalog's surfaces, a notebook, and words in Markdown (STUDIO H-02).
67
+ */
68
+ export const COMPONENTS_NOTEBOOK_AND_WORDS = [
69
+ A2UI_MEDIA_TYPE,
70
+ ...NOTEBOOK_AND_WORDS,
71
+ ];
72
+ /**
73
+ * The surfaces among what a member gave, each once by its surface's id,
74
+ * after those already kept.
75
+ */
76
+ export function surfacesAfter(kept, giver, artifacts) {
77
+ const found = artifacts
78
+ .filter(artifact => artifact.mediaType === A2UI_MEDIA_TYPE)
79
+ .map(artifact => {
80
+ const data = artifact.data;
81
+ return {
82
+ id: data && typeof data.surfaceId === 'string'
83
+ ? data.surfaceId
84
+ : `${giver}-${kept.length}`,
85
+ giver,
86
+ artifact,
87
+ };
88
+ })
89
+ .filter((surface, index, all) => !kept.some(one => one.id === surface.id) &&
90
+ all.findIndex(one => one.id === surface.id) === index);
91
+ return found.length ? [...kept, ...found] : kept;
92
+ }
58
93
  /** What the peer gave, in words: its words, and each of its artifacts. */
59
94
  export function answeredLine(artifacts) {
60
95
  const besides = artifacts.map(artifact => artifact.mediaType === NOTEBOOK_MEDIA_TYPE
61
96
  ? `a notebook, ${artifact.name}`
62
- : artifact.name);
97
+ : artifact.mediaType === A2UI_MEDIA_TYPE
98
+ ? `what it shows, ${artifact.name}`
99
+ : artifact.name);
63
100
  return besides.length ? `the report and ${besides.join(', ')}` : 'the report';
64
101
  }
65
102
  /** The latest notebook among what a peer gave, or none. */
@@ -221,6 +258,7 @@ export function useA2ATeam(options) {
221
258
  const [artifacts, setArtifacts] = useState([]);
222
259
  const [givens, setGivens] = useState({});
223
260
  const [notebook, setNotebook] = useState(null);
261
+ const [surfaces, setSurfaces] = useState([]);
224
262
  const [flows, setFlows] = useState({});
225
263
  const [calls, setCalls] = useState([]);
226
264
  const callsNow = useRef([]);
@@ -291,6 +329,7 @@ export function useA2ATeam(options) {
291
329
  setReports(prev => ({ ...prev, [id]: event.answer }));
292
330
  setArtifacts(event.artifacts);
293
331
  setGivens(prev => ({ ...prev, [id]: event.artifacts }));
332
+ setSurfaces(prev => surfacesAfter(prev, id, event.artifacts));
294
333
  const given = notebookAmong(event.artifacts);
295
334
  if (given) {
296
335
  setNotebook(given);
@@ -575,6 +614,72 @@ export function useA2ATeam(options) {
575
614
  setPersona,
576
615
  peersAtRest,
577
616
  ]);
617
+ const pressAction = useCallback(async (memberId, pressed) => {
618
+ const words = pressed.message.trim();
619
+ const isEntry = memberId === entry.id;
620
+ const one = isEntry ? undefined : peerOf(memberId);
621
+ const name = isEntry ? entry.name : (one?.app.name ?? memberId);
622
+ const to = isEntry ? entryPeer : (one?.peer ?? null);
623
+ if (!to) {
624
+ throw new Error(`${name} is not reached: it cannot be asked.`);
625
+ }
626
+ if (busy) {
627
+ throw new Error('Wait for the answer under way, then choose.');
628
+ }
629
+ if (!words) {
630
+ throw new Error('This button says nothing to send.');
631
+ }
632
+ // The entry's own button is a turn with it; another member's is
633
+ // addressed to that member, and answered by it.
634
+ const member = isEntry ? undefined : { id: memberId, name };
635
+ const withMember = member ? { member } : {};
636
+ setBusy(true);
637
+ setTurns(prev => [...prev, { role: 'user', text: words, ...withMember }]);
638
+ abort.current = new AbortController();
639
+ try {
640
+ const { answer } = await askA2APeer(to, words, {
641
+ signal: abort.current.signal,
642
+ action: pressed.action,
643
+ accept,
644
+ onEvent: event => onMemberEvent(memberId, event),
645
+ });
646
+ setTurns(prev => [
647
+ ...prev,
648
+ { role: 'assistant', text: answer, ...withMember },
649
+ ]);
650
+ if (!entryPeer) {
651
+ // The entry's model reads it at its next turn: what was chosen,
652
+ // and what the member answered.
653
+ history.current = [
654
+ ...history.current,
655
+ { role: 'user', content: words },
656
+ {
657
+ role: 'assistant',
658
+ content: `${name} answered the person directly: ${answer}`,
659
+ },
660
+ ];
661
+ }
662
+ // Answered: back at rest, its words still in its balloon.
663
+ setPersona(memberId, prev => ({ ...prev, state: 'idle' }));
664
+ return answer;
665
+ }
666
+ catch (error) {
667
+ const message = error instanceof Error ? error.message : String(error);
668
+ setTurns(prev => [
669
+ ...prev,
670
+ {
671
+ role: 'assistant',
672
+ text: `Could not answer: ${message}`,
673
+ ...withMember,
674
+ },
675
+ ]);
676
+ throw error;
677
+ }
678
+ finally {
679
+ abort.current = null;
680
+ setBusy(false);
681
+ }
682
+ }, [entry, entryPeer, peerOf, busy, accept, onMemberEvent, setPersona]);
578
683
  const stop = useCallback(() => abort.current?.abort(), []);
579
684
  useEffect(() => () => {
580
685
  abort.current?.abort();
@@ -594,6 +699,18 @@ export function useA2ATeam(options) {
594
699
  role: turn.role,
595
700
  content: turn.text,
596
701
  createdAt: new Date(0),
702
+ // A button pressed on another member's surface: the person's line
703
+ // to it, and its answer, said by it.
704
+ ...(turn.member
705
+ ? turn.role === 'user'
706
+ ? {
707
+ speaker: { id: 'person', name: PERSON },
708
+ directedTo: { id: turn.member.id, name: turn.member.name },
709
+ }
710
+ : {
711
+ speaker: { id: turn.member.id, name: turn.member.name },
712
+ }
713
+ : {}),
597
714
  })), [turns]);
598
715
  const NONE = useMemo(() => [], []);
599
716
  const teamPeers = useMemo(() => peerList.map(one => ({
@@ -623,11 +740,13 @@ export function useA2ATeam(options) {
623
740
  report,
624
741
  artifacts,
625
742
  notebook,
743
+ surfaces,
626
744
  flow: first?.flow ?? 'still',
627
745
  calls,
628
746
  busy,
629
747
  ready: entryPeer ? true : agent !== null,
630
748
  send,
749
+ pressAction,
631
750
  stop,
632
751
  };
633
752
  }
@@ -49,7 +49,20 @@ export interface UseAgentOptions {
49
49
  * connection bootstrap as well as agent creation.
50
50
  */
51
51
  runtimeConnection?: AgentRuntimeConnectionOptions;
52
+ /**
53
+ * The application the runtime is launched for (STUDIO R-19): its Appspec,
54
+ * and the saved application and deployment it runs for. Sent with the
55
+ * launch, as `RuntimesClient.create` sends it in Python, so the runtime is
56
+ * given the secrets its connections declare before its agent is made.
57
+ *
58
+ * An application's runtime is always its own: a running runtime is never
59
+ * looked up and reused for it, since one not launched for the application
60
+ * was not given its secrets.
61
+ */
62
+ appLaunch?: AppLaunch;
52
63
  }
64
+ /** What an application's launch carries besides its environment (R-19). */
65
+ export type AppLaunch = Required<Pick<IRuntimeOptions, 'appSpec'>> & Pick<IRuntimeOptions, 'appUid' | 'deploymentUid'>;
53
66
  /**
54
67
  * Return type for the useAgents hook.
55
68
  */
@@ -362,7 +362,7 @@ function toAgentRuntimeData(raw) {
362
362
  * ```
363
363
  */
364
364
  export function useAgentRuntimes(options = {}) {
365
- const { agentSpecId, agentConfig, autoCreateAgent = true, autoStart = false, agentSpec, variant: variantOption, runtimeCreationTarget, runtimeCreationBaseUrl, runtimeConnection, } = options;
365
+ const { agentSpecId, agentConfig, autoCreateAgent = true, autoStart = false, agentSpec, variant: variantOption, runtimeCreationTarget, runtimeCreationBaseUrl, runtimeConnection, appLaunch, } = options;
366
366
  // Both vocabularies funnel into one value here, so no branch below has to
367
367
  // remember that the old names exist.
368
368
  const variant = toAgentRuntimeVariant(variantOption ?? runtimeCreationTarget);
@@ -484,12 +484,14 @@ export function useAgentRuntimes(options = {}) {
484
484
  const conn = await storeLaunchAgent(runtimeOptions
485
485
  ? {
486
486
  ...runtimeOptions,
487
+ ...appLaunch,
487
488
  runtimesUrl: resolvedRuntimeCreationBaseUrl,
488
489
  }
489
490
  : {
490
491
  environmentName: 'ai-agents-env',
491
492
  creditsLimit: 10,
492
493
  givenName: safeName,
494
+ ...appLaunch,
493
495
  runtimesUrl: resolvedRuntimeCreationBaseUrl,
494
496
  });
495
497
  setLifecycleStatus('ready');
@@ -515,6 +517,7 @@ export function useAgentRuntimes(options = {}) {
515
517
  agentConfig?.name,
516
518
  agentSpec?.name,
517
519
  agentSpecId,
520
+ appLaunch,
518
521
  hasSpec,
519
522
  resolvedRuntimeCreationBaseUrl,
520
523
  storeLaunchAgent,
@@ -654,6 +657,12 @@ export function useAgentRuntimes(options = {}) {
654
657
  if (!hasSpec || runtime || lifecycleStatus !== 'idle') {
655
658
  return;
656
659
  }
660
+ if (appLaunch) {
661
+ // An application's runtime is launched for it (R-19): a running one
662
+ // was not given its connections' secrets, so none is looked for.
663
+ setLookedForExisting(true);
664
+ return;
665
+ }
657
666
  let cancelled = false;
658
667
  const bootstrap = async () => {
659
668
  try {
@@ -736,6 +745,7 @@ export function useAgentRuntimes(options = {}) {
736
745
  lifecycleStatus,
737
746
  getAuthHeaders,
738
747
  agentSpecId,
748
+ appLaunch,
739
749
  storeConnectAgent,
740
750
  ]);
741
751
  // Reset agent creation tracking on disconnect
@@ -6,13 +6,17 @@ import { contribution, definePlugin, signal } from '@datalayer/reactor';
6
6
  import { useContributions, useSignalValue } from '@datalayer/reactor/react';
7
7
  import { useIAMStore } from '../../state';
8
8
  import { LoopChatTurn, LoopPromptPanel, } from '../core';
9
- import { FEEDBACK_WORDS, sendFeedback } from './feedback';
9
+ import { answerIdOf, FEEDBACK_WORDS, sendFeedback } from './feedback';
10
10
  export const APP_FEEDBACK_PLUGIN_NAME = '@datalayer/loop-plugin-app-feedback';
11
11
  /* A signal to read when no chat contributed a turn: the hook needs one. */
12
12
  const NO_TURN = signal({ id: 0, status: 'idle' });
13
+ const NO_CONVERSATION = signal([]);
13
14
  export function AppFeedback({ app, workspace, }) {
14
15
  const turns = useContributions(LoopChatTurn);
15
16
  const turn = useSignalValue(turns[0]?.value.turn ?? NO_TURN);
17
+ // Which answer it is about (P-24): its place among the conversation's answers.
18
+ const conversation = useSignalValue(turns[0]?.value.conversation ?? NO_CONVERSATION);
19
+ const answers = conversation.filter(entry => entry.role === 'assistant').length;
16
20
  const token = useIAMStore(state => state.token);
17
21
  const [liked, setLiked] = useState(null);
18
22
  const [comment, setComment] = useState('');
@@ -31,7 +35,12 @@ export function AppFeedback({ app, workspace, }) {
31
35
  }
32
36
  setSaid({ turn: turn.id, state: 'sending', says: '' });
33
37
  try {
34
- await sendFeedback({ session: thread, liked: choice, comment }, { agentBaseUrl, token });
38
+ await sendFeedback({
39
+ session: thread,
40
+ liked: choice,
41
+ comment,
42
+ message: answerIdOf(answers),
43
+ }, { agentBaseUrl, token });
35
44
  setSaid({
36
45
  turn: turn.id,
37
46
  state: 'kept',
@@ -45,7 +45,7 @@
45
45
  * @module loop/apps/AppRenderer
46
46
  */
47
47
  import { type ComponentType } from 'react';
48
- import type { PluginRef, ReactorPlugin } from '@datalayer/reactor';
48
+ import { type PluginRef, type ReactorExtension, type ReactorPlugin } from '@datalayer/reactor';
49
49
  import type { AppSpec } from '../../types/agentspecs';
50
50
  import type { ThemeOverrides } from '../../types/chat';
51
51
  import { type AppHostPageProps } from '../plugins/app-page';
@@ -58,6 +58,11 @@ export declare const agentIdOf: (app: Pick<AppSpec, "agent" | "team">) => string
58
58
  /**
59
59
  * The plugin that makes a LOOP workspace run an application.
60
60
  *
61
+ * Its page plugin of the pair (LOOP F-15): named `loop-app-<id>`, as its
62
+ * runtime's plugin is, and requiring it — so that it stands down while its
63
+ * runtime does not hold the application, and comes back when it does
64
+ * (`AppRuntimePlugins` follows the runtime).
65
+ *
61
66
  * The agent is created with the application's own document (`app_spec`) in
62
67
  * its payload; a runtime that knows applications configures the agent from
63
68
  * it, and one that does not creates the agent alone — the application is
@@ -68,6 +73,13 @@ export declare const agentIdOf: (app: Pick<AppSpec, "agent" | "team">) => string
68
73
  * is not an agent spec, and is refused here rather than launched as one.
69
74
  */
70
75
  export declare function defineAppPlugin(app: AppSpec, shown?: AppSpec): ReactorPlugin<Record<string, never>, unknown, unknown>;
76
+ /**
77
+ * The application on the page: its plugins delivered as one extension named
78
+ * as its plugin pair is, `loop-app-<id>` — the extension its runtime's plugin
79
+ * says too, so that Reactor's manager ("Delivered by") and graph show the
80
+ * page's plugins and the runtime's as one application (LOOP F-15).
81
+ */
82
+ export declare function appExtension(app: AppSpec, shown: AppSpec, plugins: PluginRef[]): ReactorExtension;
71
83
  /** Which instance of an application runs, as the platform knows it (LOOP R-07). */
72
84
  export type AppInstance = {
73
85
  /** Its `app` item. */
@@ -50,6 +50,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
50
50
  * @module loop/apps/AppRenderer
51
51
  */
52
52
  import { useMemo } from 'react';
53
+ import { defineExtension, } from '@datalayer/reactor';
53
54
  import { loopAccentStyles, useSystemColorMode, useThemeStore, } from '@datalayer/primer-addons';
54
55
  import { defineAgentCapacityPlugin } from '../plugins/agent-capacity';
55
56
  import { APP_PAGE_SURFACE, defineAppHostPagePlugin, defineAppPagePlugin, hasAppPage, } from '../plugins/app-page';
@@ -69,6 +70,8 @@ import { defineAppKeptPlugin } from './AppKept';
69
70
  import { keepsFeedback } from './feedback';
70
71
  import { keptBeforeFirstMessage } from './kept';
71
72
  import { ChatLanguage } from '../../chat/ChatLanguage';
73
+ import { defineAppRuntimePlugin } from './AppRuntimePlugins';
74
+ import { appPluginPair } from './pluginPair';
72
75
  /** The id of an agent or a Cog, without its version. */
73
76
  export const agentIdOf = (app) => {
74
77
  const reference = app.agent || app.team;
@@ -80,6 +83,11 @@ export const agentIdOf = (app) => {
80
83
  /**
81
84
  * The plugin that makes a LOOP workspace run an application.
82
85
  *
86
+ * Its page plugin of the pair (LOOP F-15): named `loop-app-<id>`, as its
87
+ * runtime's plugin is, and requiring it — so that it stands down while its
88
+ * runtime does not hold the application, and comes back when it does
89
+ * (`AppRuntimePlugins` follows the runtime).
90
+ *
83
91
  * The agent is created with the application's own document (`app_spec`) in
84
92
  * its payload; a runtime that knows applications configures the agent from
85
93
  * it, and one that does not creates the agent alone — the application is
@@ -93,8 +101,11 @@ export function defineAppPlugin(app, shown = app) {
93
101
  if (!app.agent) {
94
102
  throw new Error(`“${app.name}” is run by a team, which the workspace does not run as an application yet.`);
95
103
  }
104
+ const pair = appPluginPair(app.id);
96
105
  return defineAgentCapacityPlugin({
97
106
  key: `app-${app.id}`,
107
+ name: pair.page.name,
108
+ requiredBackendPlugins: pair.page.requiredBackendPlugins,
98
109
  // What a person reads is in their language (P-26); what it is created
99
110
  // with is its own Appspec.
100
111
  displayName: shown.name,
@@ -128,6 +139,22 @@ export function defineAppPlugin(app, shown = app) {
128
139
  : {}),
129
140
  });
130
141
  }
142
+ /**
143
+ * The application on the page: its plugins delivered as one extension named
144
+ * as its plugin pair is, `loop-app-<id>` — the extension its runtime's plugin
145
+ * says too, so that Reactor's manager ("Delivered by") and graph show the
146
+ * page's plugins and the runtime's as one application (LOOP F-15).
147
+ */
148
+ export function appExtension(app, shown, plugins) {
149
+ return defineExtension({
150
+ name: appPluginPair(app.id).extension,
151
+ displayName: shown.name,
152
+ description: shown.description || `The ${shown.name} application`,
153
+ emoji: app.emoji,
154
+ version: app.version,
155
+ plugins,
156
+ });
157
+ }
131
158
  /**
132
159
  * The workspace an application's layout asks for (LOOP T-07).
133
160
  *
@@ -205,6 +232,8 @@ export function appPreset(app, options = {}) {
205
232
  return {
206
233
  plugins: [
207
234
  defineAppPlugin(app, shown),
235
+ // What its runtime holds, told to the page (F-15).
236
+ defineAppRuntimePlugin(app.id),
208
237
  ...(withPage ? [defineAppPagePlugin(shown), ...blocks] : []),
209
238
  // The components its developer wrote (LOOP P-17): blocks of its page
210
239
  // and renderers of its page, its elements and its answers — its own.
@@ -353,7 +382,11 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
353
382
  deployed,
354
383
  visitor,
355
384
  ]);
356
- const allPlugins = useMemo(() => ('problem' in preset ? plugins : [...preset.plugins, ...plugins]), [preset, plugins]);
385
+ const allPlugins = useMemo(() => 'problem' in preset
386
+ ? plugins
387
+ : [appExtension(app, shown, preset.plugins), ...plugins],
388
+ // eslint-disable-next-line react-hooks/exhaustive-deps
389
+ [preset, plugins]);
357
390
  /*
358
391
  * On Datalayer, the application runs on a runtime: allocated with a plain
359
392
  * agentspec, its agent created there with the application's spec — so that
@@ -0,0 +1,8 @@
1
+ import { type ReactorPlugin } from '@datalayer/reactor';
2
+ export declare const APP_RUNTIME_PLUGIN_NAME = "@datalayer/loop-plugin-app-runtime";
3
+ /** Follows the application's runtime, and tells the page what it holds. */
4
+ export declare function AppRuntimePlugins({ appId }: {
5
+ appId: string;
6
+ }): null;
7
+ /** The plugin that follows an application's runtime for its page. */
8
+ export declare function defineAppRuntimePlugin(appId: string): ReactorPlugin<Record<string, never>, unknown, unknown>;
@@ -0,0 +1,87 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2025-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
6
+ /**
7
+ * The page told which of an application's plugins its runtime holds (LOOP
8
+ * F-15).
9
+ *
10
+ * The application's page plugin requires the runtime's plugin of the same
11
+ * name (`requiredBackendPlugins`). Reactor stands such a plugin down while
12
+ * the plugin it requires is not running, and brings it back when it is — but
13
+ * only a host that says what runs can make that true. Every place an
14
+ * application appears draws it with `AppRenderer`, so this is said once, for
15
+ * all of them — the Studio's Preview, its address, an embed, the browser
16
+ * alone:
17
+ *
18
+ * - on a runtime, Reactor's `useBackendPluginStream` follows the runtime's
19
+ * `/api/v1/apps/<id>/plugins/state` (and its stream): the page's plugin
20
+ * stands down when the runtime no longer holds the application, and comes
21
+ * back when it does;
22
+ * - with no runtime — the browser alone, or one still starting — the page is
23
+ * its own runtime, and the pair is whole: nothing stands down, nothing is
24
+ * blank.
25
+ *
26
+ * A plugin of its own, with no backend requirement, so that it keeps
27
+ * listening while the application's plugin is stood down. It renders nothing.
28
+ *
29
+ * @module loop/apps/AppRuntimePlugins
30
+ */
31
+ import { useCallback, useEffect } from 'react';
32
+ import { definePlugin } from '@datalayer/reactor';
33
+ import { registerReactor, useBackendPluginStream, useReactorPlatform, useSignalValue, } from '@datalayer/reactor/react';
34
+ import { IDLE_SANDBOX_SNAPSHOT_SIGNAL, IDLE_SANDBOX_TARGET_SIGNAL, LoopSlots, } from '../core';
35
+ import { useOptionalSandboxService } from '../plugins/agents/useSandboxService';
36
+ import { appPluginName, appPluginsBase, appRuntimeOf } from './pluginPair';
37
+ export const APP_RUNTIME_PLUGIN_NAME = '@datalayer/loop-plugin-app-runtime';
38
+ /** A host's answer to "is this runtime plugin running?", from a list. */
39
+ const runningOf = (names) => (name) => names.includes(name);
40
+ /** Follows the application's runtime, and tells the page what it holds. */
41
+ export function AppRuntimePlugins({ appId }) {
42
+ const reactor = useReactorPlatform();
43
+ const service = useOptionalSandboxService();
44
+ const target = useSignalValue(service?.target ?? IDLE_SANDBOX_TARGET_SIGNAL);
45
+ const snapshot = useSignalValue(service?.snapshot ?? IDLE_SANDBOX_SNAPSHOT_SIGNAL);
46
+ const runtime = appRuntimeOf({
47
+ target,
48
+ snapshot,
49
+ serverUrl: service?.serverUrl,
50
+ });
51
+ const name = appPluginName(appId);
52
+ // No runtime: the page is its own, and the pair is whole.
53
+ useEffect(() => {
54
+ if (runtime) {
55
+ return;
56
+ }
57
+ registerReactor(reactor, runningOf([name]));
58
+ void reactor.setBackendPlugins([name]);
59
+ }, [reactor, runtime, name]);
60
+ // On one: what it says, for the slots gated on it too.
61
+ const onState = useCallback((state) => registerReactor(reactor, runningOf(state.plugins
62
+ .filter(plugin => plugin.enabled)
63
+ .map(plugin => plugin.name))), [reactor]);
64
+ useBackendPluginStream(runtime ? appPluginsBase(runtime, appId) : undefined, {
65
+ onState,
66
+ });
67
+ return null;
68
+ }
69
+ /** The plugin that follows an application's runtime for its page. */
70
+ export function defineAppRuntimePlugin(appId) {
71
+ const Follow = () => _jsx(AppRuntimePlugins, { appId: appId });
72
+ return definePlugin({
73
+ name: APP_RUNTIME_PLUGIN_NAME,
74
+ displayName: 'Its runtime',
75
+ description: 'Tells the application’s page which of its plugins its runtime holds.',
76
+ octicon: 'server',
77
+ build: () => ({
78
+ components: [
79
+ {
80
+ slot: LoopSlots.status,
81
+ id: 'app-runtime-plugins',
82
+ Component: Follow,
83
+ },
84
+ ],
85
+ }),
86
+ });
87
+ }
@@ -21,7 +21,14 @@ export type Feedback = {
21
21
  session: string;
22
22
  liked: boolean;
23
23
  comment: string;
24
+ /**
25
+ * The answer it is about (LOOP P-24): `answer-<n>`, its place among the
26
+ * conversation's answers; none for the conversation as a whole.
27
+ */
28
+ message?: string;
24
29
  };
30
+ /** How an answer is named in feedback: its place among the conversation's answers, from 1. */
31
+ export declare const answerIdOf: (answers: number) => string | undefined;
25
32
  /** What the feedback strip says, in a person's words. */
26
33
  export declare const FEEDBACK_WORDS: {
27
34
  readonly ask: "Was this answer useful?";
@@ -4,6 +4,8 @@
4
4
  */
5
5
  /** The longest comment kept, as the runtime holds it (`COMMENT_LIMIT`). */
6
6
  export const FEEDBACK_COMMENT_LIMIT = 2000;
7
+ /** How an answer is named in feedback: its place among the conversation's answers, from 1. */
8
+ export const answerIdOf = (answers) => answers > 0 ? `answer-${answers}` : undefined;
7
9
  /** What the feedback strip says, in a person's words. */
8
10
  export const FEEDBACK_WORDS = {
9
11
  ask: 'Was this answer useful?',
@@ -50,6 +52,7 @@ export async function sendFeedback(feedback, { agentBaseUrl, token, fetcher = fe
50
52
  session: feedback.session,
51
53
  liked: feedback.liked,
52
54
  comment: feedback.comment.trim(),
55
+ ...(feedback.message ? { message: feedback.message } : {}),
53
56
  }),
54
57
  });
55
58
  const body = (await response.json().catch(() => ({})));
@@ -0,0 +1,60 @@
1
+ /**
2
+ * An application's two Reactor plugins, named once (LOOP F-15).
3
+ *
4
+ * Whichever editor wrote it — a spec, the Canvas, an `app.py` — an
5
+ * application is a pair of plugins of one name, `loop-app-<id>`: the page's
6
+ * (`defineAppPlugin`) and the runtime's (`manifest_of` in
7
+ * `agent_runtimes.loop.apps.plugins`, which says the same name). Each
8
+ * declares the other: the page's `requiredBackendPlugins`, the runtime's
9
+ * `frontend_dependencies`; and both are delivered as one extension of that
10
+ * name, which is how Reactor's manager and graph show them as one
11
+ * application.
12
+ *
13
+ * The page follows what its runtime holds with Reactor's
14
+ * `useBackendPluginStream`, on `appPluginsBase` (`AppRuntimePlugins`).
15
+ *
16
+ * @module loop/apps/pluginPair
17
+ */
18
+ import type { SandboxSnapshot } from '../core';
19
+ import type { SandboxTarget } from '../plugins/agents/switchable';
20
+ /** The one name of an application's two plugins, the page's and the runtime's. */
21
+ export declare const appPluginName: (appId: string) => string;
22
+ /** What each tier of an application's pair says. */
23
+ export type AppPluginPair = {
24
+ /** The page's plugin, and the runtime plugin it cannot work without. */
25
+ page: {
26
+ name: string;
27
+ requiredBackendPlugins: string[];
28
+ };
29
+ /** The runtime's plugin, and the page plugin it cannot be used without. */
30
+ server: {
31
+ name: string;
32
+ frontendDependencies: string[];
33
+ };
34
+ /** The extension that delivers both: the application. */
35
+ extension: string;
36
+ };
37
+ /** The pair an application of this id is, on both tiers. */
38
+ export declare function appPluginPair(appId: string): AppPluginPair;
39
+ /**
40
+ * Where Reactor's `useBackendPluginStream` reads an application's plugins on
41
+ * its runtime: `<base>/plugins/state`, `<base>/events/stream` — about that
42
+ * application only, so that a runtime several share says nothing of the
43
+ * others.
44
+ */
45
+ export declare const appPluginsBase: (runtimeUrl: string, appId: string) => string;
46
+ /**
47
+ * The runtime an application's page follows, or `undefined` when it runs on
48
+ * none yet.
49
+ *
50
+ * - **In the browser alone**: none — the page is its own runtime.
51
+ * - **On Datalayer**: the runtime its agent was made on, once reported
52
+ * (`agent_base_url`) — at its address, embedded, on the visitors' runtime.
53
+ * - **Here** (`local`, `jupyter`): the workspace's server, once it runs —
54
+ * after the agent was made there with the application, never before.
55
+ */
56
+ export declare function appRuntimeOf({ target, snapshot, serverUrl, }: {
57
+ target: SandboxTarget | undefined;
58
+ snapshot: Pick<SandboxSnapshot, 'state' | 'agentBaseUrl'>;
59
+ serverUrl: string | undefined;
60
+ }): string | undefined;
@@ -0,0 +1,44 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /** The one name of an application's two plugins, the page's and the runtime's. */
6
+ export const appPluginName = (appId) => `loop-app-${appId}`;
7
+ /** The pair an application of this id is, on both tiers. */
8
+ export function appPluginPair(appId) {
9
+ const name = appPluginName(appId);
10
+ return {
11
+ page: { name, requiredBackendPlugins: [name] },
12
+ server: { name, frontendDependencies: [name] },
13
+ extension: name,
14
+ };
15
+ }
16
+ /**
17
+ * Where Reactor's `useBackendPluginStream` reads an application's plugins on
18
+ * its runtime: `<base>/plugins/state`, `<base>/events/stream` — about that
19
+ * application only, so that a runtime several share says nothing of the
20
+ * others.
21
+ */
22
+ export const appPluginsBase = (runtimeUrl, appId) => `${runtimeUrl.replace(/\/+$/, '')}/api/v1/apps/${encodeURIComponent(appId)}`;
23
+ /**
24
+ * The runtime an application's page follows, or `undefined` when it runs on
25
+ * none yet.
26
+ *
27
+ * - **In the browser alone**: none — the page is its own runtime.
28
+ * - **On Datalayer**: the runtime its agent was made on, once reported
29
+ * (`agent_base_url`) — at its address, embedded, on the visitors' runtime.
30
+ * - **Here** (`local`, `jupyter`): the workspace's server, once it runs —
31
+ * after the agent was made there with the application, never before.
32
+ */
33
+ export function appRuntimeOf({ target, snapshot, serverUrl, }) {
34
+ if (!target || target === 'browser') {
35
+ return undefined;
36
+ }
37
+ if (snapshot.agentBaseUrl) {
38
+ return snapshot.agentBaseUrl;
39
+ }
40
+ if (target === 'datalayer') {
41
+ return undefined;
42
+ }
43
+ return snapshot.state === 'running' && serverUrl ? serverUrl : undefined;
44
+ }