@datalayer/agent-runtimes 1.3.53 → 1.3.54

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.
@@ -0,0 +1,35 @@
1
+ /** Where a conversation without an account runs: the visitors' runtime, and what it is for. */
2
+ export type DatalayerVisitors = {
3
+ /** The visitors' runtime, as the chat addresses it. */
4
+ url: string;
5
+ /** The application the visitor's token is for: an example's id, or `at:<slug>`. */
6
+ app: string;
7
+ /** ai-inference, which mints the visitor's token. */
8
+ inferenceUrl: string;
9
+ };
10
+ /** A visitor's id: what ai-inference accepts (8 to 64 letters, digits or dashes). */
11
+ export declare function isVisitorId(value: string | null | undefined): value is string;
12
+ /** The id this tab keeps for its visitor, or `''` until ai-inference made one. */
13
+ export declare function keptVisitorId(): string;
14
+ /** A visitor's token, and when it is to be minted again. */
15
+ export type VisitorToken = {
16
+ token: string;
17
+ visitor: string;
18
+ /** Epoch milliseconds: the token's own lifetime from now. */
19
+ renewAt: number;
20
+ };
21
+ /** The body ai-inference is asked with. */
22
+ export declare function visitorTokenRequest(app: string, visitor: string): {
23
+ app: string;
24
+ visitor?: string;
25
+ };
26
+ /** Mint a visitor's token for an application; throws with ai-inference's sentence. */
27
+ export declare function fetchVisitorToken(inferenceUrl: string, app: string, visitor?: string, fetcher?: typeof fetch): Promise<VisitorToken>;
28
+ /**
29
+ * The visitor's token for an application while `visitors` is set: minted at
30
+ * once and again before it runs out. `error` says why none could be had.
31
+ */
32
+ export declare function useVisitorToken(visitors: DatalayerVisitors | undefined): {
33
+ token?: string;
34
+ error?: string;
35
+ };
@@ -0,0 +1,114 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * A visitor's token, for a conversation without an account on the visitors'
7
+ * runtime (LOOP R-30).
8
+ *
9
+ * ai-inference mints it (`POST /anonymous/token`) naming the visitor — the id
10
+ * this tab keeps for them, so that a new token is the same visitor, whose
11
+ * session, turns and rate it does not reset — and the one application it is
12
+ * for: an example's id, or `at:<slug>` for an application at its address.
13
+ * It lives minutes, so it is minted again before it runs out: what limits a
14
+ * visitor is on the servers (turns a day, a session's minutes, a rate and a
15
+ * ceiling for the day), not the life of a key.
16
+ *
17
+ * @module loop/apps/visitorToken
18
+ */
19
+ import { useEffect, useState } from 'react';
20
+ /** The id this tab keeps for its visitor. */
21
+ const VISITOR_KEY = 'datalayer-loop-visitor';
22
+ /** Minted again this long before the token runs out. */
23
+ const MARGIN_MS = 30_000;
24
+ /** A visitor's id: what ai-inference accepts (8 to 64 letters, digits or dashes). */
25
+ export function isVisitorId(value) {
26
+ return typeof value === 'string' && /^[A-Za-z0-9-]{8,64}$/.test(value);
27
+ }
28
+ /** The id this tab keeps for its visitor, or `''` until ai-inference made one. */
29
+ export function keptVisitorId() {
30
+ try {
31
+ const kept = window.sessionStorage.getItem(VISITOR_KEY);
32
+ return isVisitorId(kept) ? kept : '';
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
38
+ function keepVisitorId(visitor) {
39
+ try {
40
+ window.sessionStorage.setItem(VISITOR_KEY, visitor);
41
+ }
42
+ catch {
43
+ // A tab that keeps nothing is a new visitor at each token.
44
+ }
45
+ }
46
+ /** The body ai-inference is asked with. */
47
+ export function visitorTokenRequest(app, visitor) {
48
+ return isVisitorId(visitor) ? { app, visitor } : { app };
49
+ }
50
+ /** Mint a visitor's token for an application; throws with ai-inference's sentence. */
51
+ export async function fetchVisitorToken(inferenceUrl, app, visitor = keptVisitorId(), fetcher = fetch) {
52
+ const base = inferenceUrl.replace(/\/+$/, '');
53
+ const response = await fetcher(`${base}/api/ai-inference/v1/anonymous/token`, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify(visitorTokenRequest(app, visitor)),
57
+ });
58
+ const body = (await response.json().catch(() => ({})));
59
+ if (!response.ok || !body.token) {
60
+ throw new Error(body.detail ||
61
+ `A visitor's token was refused by ${base} (HTTP ${response.status}).`);
62
+ }
63
+ if (isVisitorId(body.visitor)) {
64
+ keepVisitorId(body.visitor);
65
+ }
66
+ const lifetimeMs = Math.max(60_000, (body.expires_in ?? 300) * 1000);
67
+ return {
68
+ token: body.token,
69
+ visitor: body.visitor ?? visitor,
70
+ renewAt: Date.now() + lifetimeMs - MARGIN_MS,
71
+ };
72
+ }
73
+ /**
74
+ * The visitor's token for an application while `visitors` is set: minted at
75
+ * once and again before it runs out. `error` says why none could be had.
76
+ */
77
+ export function useVisitorToken(visitors) {
78
+ const [state, setState] = useState({});
79
+ const app = visitors?.app;
80
+ const inferenceUrl = visitors?.inferenceUrl;
81
+ useEffect(() => {
82
+ if (!app || !inferenceUrl) {
83
+ setState({});
84
+ return;
85
+ }
86
+ let cancelled = false;
87
+ let timer;
88
+ const mint = () => {
89
+ fetchVisitorToken(inferenceUrl, app)
90
+ .then(minted => {
91
+ if (cancelled) {
92
+ return;
93
+ }
94
+ setState({ token: minted.token });
95
+ timer = setTimeout(mint, Math.max(5_000, minted.renewAt - Date.now()));
96
+ })
97
+ .catch((error) => {
98
+ if (!cancelled) {
99
+ setState({
100
+ error: error instanceof Error ? error.message : String(error),
101
+ });
102
+ }
103
+ });
104
+ };
105
+ mint();
106
+ return () => {
107
+ cancelled = true;
108
+ if (timer) {
109
+ clearTimeout(timer);
110
+ }
111
+ };
112
+ }, [app, inferenceUrl]);
113
+ return state;
114
+ }
@@ -76,6 +76,7 @@ export function LoopEmbed({ agentId = '', showHeader = false, chatHeaderActions,
76
76
  preset.showTokenUsage,
77
77
  // By its content: an application's spec written inline is a new object each render.
78
78
  JSON.stringify(preset.datalayerCreatePayload ?? null),
79
+ JSON.stringify(preset.datalayerVisitors ?? null),
79
80
  preset.defaultEditor,
80
81
  preset.showViewSelector,
81
82
  preset.hideChatHeader,
@@ -22,6 +22,9 @@ export function DatalayerAgentBridge() {
22
22
  const agentsConfig = reactor.getConfig(AGENTS_PLUGIN_NAME);
23
23
  const agentSpecId = agentsConfig?.datalayerAgentSpecId ?? DEFAULT_DATALAYER_AGENTSPEC;
24
24
  const createPayload = agentsConfig?.datalayerCreatePayload;
25
+ // A conversation without an account (LOOP R-30): the agent is already on
26
+ // the visitors' runtime, kept warm there; nothing is allocated or made.
27
+ const visitors = agentsConfig?.datalayerVisitors;
25
28
  const service = useOptionalSandboxService();
26
29
  const target = useSignalValue(service?.target ?? IDLE_SANDBOX_TARGET_SIGNAL);
27
30
  const onDatalayer = target === 'datalayer';
@@ -65,13 +68,22 @@ export function DatalayerAgentBridge() {
65
68
  agentSpecId,
66
69
  agentConfig,
67
70
  variant: 'cloud-pydanticai',
68
- autoStart: onDatalayer,
69
- autoCreateAgent: onDatalayer,
71
+ autoStart: onDatalayer && !visitors,
72
+ autoCreateAgent: onDatalayer && !visitors,
70
73
  });
71
74
  useEffect(() => {
72
75
  if (!service || !onDatalayer) {
73
76
  return;
74
77
  }
78
+ if (visitors) {
79
+ service.report({
80
+ variant: 'datalayer',
81
+ // No sandbox: a visitor's application runs no code.
82
+ sandbox_running: false,
83
+ agent_base_url: visitors.url,
84
+ });
85
+ return;
86
+ }
75
87
  if (error) {
76
88
  // The reason travels with the state. "connected-dead" and a column of
77
89
  // "unknown" is what a reader saw before, which names the symptom and
@@ -106,7 +118,7 @@ export function DatalayerAgentBridge() {
106
118
  // host's server for an agent that is not on it.
107
119
  agent_base_url: runtime.agentBaseUrl,
108
120
  });
109
- }, [service, onDatalayer, runtime, status, error]);
121
+ }, [service, onDatalayer, runtime, status, error, visitors]);
110
122
  return null;
111
123
  }
112
124
  export default DatalayerAgentBridge;
@@ -1,3 +1,21 @@
1
+ /**
2
+ * The Agents plugin: where the agent runs, and the sandbox that comes with it.
3
+ *
4
+ * It was the "code sandbox" plugin, and the rename follows what the choice
5
+ * came to mean. Picking a target used to say only where *code* ran; now it
6
+ * decides where the *agent* runs too — in this page with the Vercel AI SDK, or
7
+ * on a server with pydantic-ai — because the location is what a harness can
8
+ * be, whatever a spec asks for. One control, one answer.
9
+ *
10
+ * It owns the sandbox and publishes it as a service; the notebook and document
11
+ * plugins depend on this plugin rather than on a connection of their own.
12
+ *
13
+ * Which agentspec the session runs is a different question, and belongs to
14
+ * `@datalayer/loop-plugin-agentspecs`.
15
+ *
16
+ * @module loop/plugins/agents/plugin
17
+ */
18
+ import type { DatalayerVisitors } from '../../apps/visitorToken';
1
19
  import type { ReactorSlotComponent } from '@datalayer/reactor/react';
2
20
  import type { ServiceManagerSource } from './browserService';
3
21
  import { type TeamSelection } from './team';
@@ -79,6 +97,12 @@ export type AgentsConfig = {
79
97
  * decides its tool calls by its rules (LOOP R-05, R-03).
80
98
  */
81
99
  datalayerCreatePayload?: Record<string, unknown>;
100
+ /**
101
+ * A conversation without an account (LOOP R-30): the application's agent
102
+ * is already on the visitors' runtime, so nothing is allocated or created,
103
+ * and the chat speaks to it with a visitor's token for the application.
104
+ */
105
+ datalayerVisitors?: DatalayerVisitors;
82
106
  /**
83
107
  * The target is the host's decision, not the reader's: started there even
84
108
  * with the control hidden. For an application that runs where it is
@@ -2,23 +2,6 @@
2
2
  * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
- /**
6
- * The Agents plugin: where the agent runs, and the sandbox that comes with it.
7
- *
8
- * It was the "code sandbox" plugin, and the rename follows what the choice
9
- * came to mean. Picking a target used to say only where *code* ran; now it
10
- * decides where the *agent* runs too — in this page with the Vercel AI SDK, or
11
- * on a server with pydantic-ai — because the location is what a harness can
12
- * be, whatever a spec asks for. One control, one answer.
13
- *
14
- * It owns the sandbox and publishes it as a service; the notebook and document
15
- * plugins depend on this plugin rather than on a connection of their own.
16
- *
17
- * Which agentspec the session runs is a different question, and belongs to
18
- * `@datalayer/loop-plugin-agentspecs`.
19
- *
20
- * @module loop/plugins/agents/plugin
21
- */
22
5
  import { ServerIcon } from '@primer/octicons-react';
23
6
  import { contribution, definePlugin } from '@datalayer/reactor';
24
7
  import { LoopAgentBlueprint, LoopAgentGate, LoopCommand, LoopSlots, LoopViewType, } from '../../core';
@@ -36,6 +36,8 @@ import { CHAT_PLUGIN_NAME } from './index';
36
36
  import { INPUT_PROMPT_PLUGIN_NAME, } from '../input-prompt';
37
37
  import { agentRuntimeStore, useAgentRuntimeContextSnapshot, } from '../../../stores';
38
38
  import { useIAMStore } from '../../../state';
39
+ import { useVisitorToken } from '../../apps/visitorToken';
40
+ import { AGENTS_PLUGIN_NAME } from '../agents/plugin';
39
41
  import { useConfig } from '../../../hooks/useConfig';
40
42
  import { useSkills, useSkillActions } from '../../../hooks/useSkills';
41
43
  import { AI_MODEL_CATALOGUE, isChatModel } from '../../../specs/models';
@@ -1137,7 +1139,14 @@ export default function ChatView({ workspace }) {
1137
1139
  * chat says so with the person's token. The embed hands its own.
1138
1140
  */
1139
1141
  const runsApp = Boolean(blueprintTurn?.createPayload?.app_spec);
1140
- const iamToken = useIAMStore(state => state.token);
1142
+ const memberToken = useIAMStore(state => state.token);
1143
+ /*
1144
+ * Without an account (LOOP R-30) the application runs on the visitors'
1145
+ * runtime, which answers a visitor's token for it and nobody signed in.
1146
+ */
1147
+ const visitors = reactor.getConfig(AGENTS_PLUGIN_NAME)?.datalayerVisitors;
1148
+ const visitor = useVisitorToken(visitors);
1149
+ const iamToken = visitors ? visitor.token : memberToken;
1141
1150
  const protocol = useMemo(() => inPage
1142
1151
  ? browserProtocolConfig({
1143
1152
  agentId: member?.specId ?? agentId,
@@ -137,6 +137,8 @@ export type LoopPresetOptions = {
137
137
  datalayerAgentSpecId?: string;
138
138
  /** What the agent on that runtime is created with besides its spec — an `app_spec`. */
139
139
  datalayerCreatePayload?: Record<string, unknown>;
140
+ /** A conversation without an account, on the visitors' runtime (LOOP R-30). */
141
+ datalayerVisitors?: AgentsConfig['datalayerVisitors'];
140
142
  /** The target is the host's to fix, even with the agent control hidden. */
141
143
  targetFixed?: boolean;
142
144
  /** The theme the conversation wears, by name; the person's unless said. */
@@ -52,7 +52,7 @@ import { LoopPageLayoutPlugin } from './plugins/page-layout';
52
52
  * be installed or signed into, and the switches a demonstration wants are off.
53
53
  */
54
54
  export function loopPlugins(options = {}) {
55
- const { serverUrl, target = 'browser', defaultEditor: askedEditor, showViewSelector = true, hideChatHeader = false, chatHeaderButtons = false, promptPlacement, autoFocusPrompt = true, suggestionLabels = true, fullScreenTopOffset = 0, showAgentVariants = false, agentSummary = true, teamId, teamPicker = true, pageLayout = false, pageLayoutArrangement = 'page', pageLayoutTurnPanel = 'below', pageLayoutPrompt = 'docked', pageLayoutPromptAnchor = 'top', pageLayoutSize, pageLayoutTurnPanelFooter = 'full', localAgent, localAgentSpec, datalayerAgentSpecId, datalayerCreatePayload, targetFixed, themeVariant, themeOverrides, colorMode, presence, showTokenUsage, floatingPrompt = false, editorSelector = false, editors = true, conversation = true, graph = false, commandPalette = false, pluginsPanel = false, windowFrame = false, firstPromptHook, } = options;
55
+ const { serverUrl, target = 'browser', defaultEditor: askedEditor, showViewSelector = true, hideChatHeader = false, chatHeaderButtons = false, promptPlacement, autoFocusPrompt = true, suggestionLabels = true, fullScreenTopOffset = 0, showAgentVariants = false, agentSummary = true, teamId, teamPicker = true, pageLayout = false, pageLayoutArrangement = 'page', pageLayoutTurnPanel = 'below', pageLayoutPrompt = 'docked', pageLayoutPromptAnchor = 'top', pageLayoutSize, pageLayoutTurnPanelFooter = 'full', localAgent, localAgentSpec, datalayerAgentSpecId, datalayerCreatePayload, datalayerVisitors, targetFixed, themeVariant, themeOverrides, colorMode, presence, showTokenUsage, floatingPrompt = false, editorSelector = false, editors = true, conversation = true, graph = false, commandPalette = false, pluginsPanel = false, windowFrame = false, firstPromptHook, } = options;
56
56
  const defaultEditor = askedEditor ?? (editors ? 'notebook' : 'none');
57
57
  // A workspace without a conversation: the theme, the shell's points, and
58
58
  // the host's chrome; its view is a host's plugin's.
@@ -111,6 +111,7 @@ export function loopPlugins(options = {}) {
111
111
  target,
112
112
  datalayerAgentSpecId,
113
113
  datalayerCreatePayload,
114
+ datalayerVisitors,
114
115
  targetFixed,
115
116
  showAgentVariants,
116
117
  showAgentSummary: agentSummary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.3.53",
3
+ "version": "1.3.54",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",
@@ -147,7 +147,7 @@
147
147
  "@datalayer/agent-teams": "^1.0.0",
148
148
  "@datalayer/core": "^1.2.14",
149
149
  "@datalayer/decks": "^1.0.4",
150
- "@datalayer/icons-react": "^1.0.14",
150
+ "@datalayer/icons-react": "^1.0.16",
151
151
  "@datalayer/jupyter-lexical": "^2.0.18",
152
152
  "@datalayer/jupyter-react": "^2.0.18",
153
153
  "@datalayer/jupyter-server-nbmodel": "^0.2.8",