@jupyterlite/ai 0.13.0 → 0.14.0

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.
@@ -13,7 +13,7 @@ import MoreVert from '@mui/icons-material/MoreVert';
13
13
  import Settings from '@mui/icons-material/Settings';
14
14
  import { Alert, Box, Button, Card, CardContent, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, FormControlLabel, IconButton, InputLabel, List, ListItem, ListItemText, Menu, MenuItem, Select, Switch, Tab, Tabs, TextField, ThemeProvider, Tooltip, Typography, createTheme } from '@mui/material';
15
15
  import React, { useEffect, useMemo, useState } from 'react';
16
- import { SECRETS_NAMESPACE, SECRETS_REPLACEMENT } from '../tokens';
16
+ import { SECRETS_REPLACEMENT } from '../tokens';
17
17
  import { ProviderConfigDialog } from './provider-config-dialog';
18
18
  /**
19
19
  * Create a theme that uses IThemeManager to detect theme
@@ -41,21 +41,20 @@ export class AISettingsWidget extends ReactWidget {
41
41
  */
42
42
  constructor(options) {
43
43
  super();
44
- Private.setToken(options.token);
45
44
  this._settingsModel = options.settingsModel;
46
45
  this._agentManagerFactory = options.agentManagerFactory;
47
46
  this._themeManager = options.themeManager;
48
47
  this._providerRegistry = options.providerRegistry;
49
- this._secretsManager = options.secretsManager;
48
+ this._secretsAccess = options.secretsAccess;
50
49
  this._trans = options.trans;
51
50
  this.id = 'jupyterlite-ai-settings';
52
51
  this.title.label = this._trans.__('AI Settings');
53
52
  this.title.caption = this._trans.__('Configure AI providers and behavior');
54
53
  this.title.closable = true;
55
54
  // Disable the secrets manager if the token is empty.
56
- if (!options.token) {
55
+ if (!options.secretsAccess.isAvailable) {
57
56
  this._settingsModel.updateConfig({ useSecretsManager: false });
58
- this._secretsManager = undefined;
57
+ this._secretsAccess = undefined;
59
58
  }
60
59
  }
61
60
  /**
@@ -63,13 +62,13 @@ export class AISettingsWidget extends ReactWidget {
63
62
  * @returns A React element containing the AI settings interface
64
63
  */
65
64
  render() {
66
- return (React.createElement(AISettingsComponent, { model: this._settingsModel, agentManagerFactory: this._agentManagerFactory, themeManager: this._themeManager, providerRegistry: this._providerRegistry, secretsManager: this._secretsManager, trans: this._trans }));
65
+ return (React.createElement(AISettingsComponent, { model: this._settingsModel, agentManagerFactory: this._agentManagerFactory, themeManager: this._themeManager, providerRegistry: this._providerRegistry, secretsAccess: this._secretsAccess, trans: this._trans }));
67
66
  }
68
67
  _settingsModel;
69
68
  _agentManagerFactory;
70
69
  _themeManager;
71
70
  _providerRegistry;
72
- _secretsManager;
71
+ _secretsAccess;
73
72
  _trans;
74
73
  }
75
74
  /**
@@ -77,7 +76,7 @@ export class AISettingsWidget extends ReactWidget {
77
76
  * @param props - Component props containing models and theme manager
78
77
  * @returns A React component for AI settings configuration
79
78
  */
80
- const AISettingsComponent = ({ model, agentManagerFactory, themeManager, providerRegistry, secretsManager, trans }) => {
79
+ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, providerRegistry, secretsAccess, trans }) => {
81
80
  if (!model) {
82
81
  return React.createElement("div", null, trans.__('Settings model not available'));
83
82
  }
@@ -174,23 +173,10 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
174
173
  void promptDebouncer.invoke();
175
174
  };
176
175
  const getSecretFromManager = async (provider, fieldName) => {
177
- const token = Private.getToken();
178
- if (!token) {
179
- return;
180
- }
181
- const secret = await secretsManager?.get(token, SECRETS_NAMESPACE, `${provider}:${fieldName}`);
182
- return secret?.value;
176
+ return secretsAccess?.get(`${provider}:${fieldName}`);
183
177
  };
184
178
  const setSecretToManager = async (provider, fieldName, value) => {
185
- const token = Private.getToken();
186
- if (!token) {
187
- return;
188
- }
189
- await secretsManager?.set(token, SECRETS_NAMESPACE, `${provider}:${fieldName}`, {
190
- namespace: SECRETS_NAMESPACE,
191
- id: `${provider}:${fieldName}`,
192
- value
193
- });
179
+ await secretsAccess?.set(`${provider}:${fieldName}`, value);
194
180
  };
195
181
  /**
196
182
  * Attach a secrets field to the secrets manager.
@@ -199,14 +185,10 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
199
185
  * @param fieldName - the name of the field.
200
186
  */
201
187
  const handleSecretField = async (input, provider, fieldName) => {
202
- if (!(model.config.useSecretsManager && secretsManager)) {
188
+ if (!(model.config.useSecretsManager && secretsAccess?.isAvailable)) {
203
189
  return;
204
190
  }
205
- const token = Private.getToken();
206
- if (!token) {
207
- return;
208
- }
209
- await secretsManager?.attach(token, SECRETS_NAMESPACE, `${provider}:${fieldName}`, input);
191
+ await secretsAccess.attach(`${provider}:${fieldName}`, input);
210
192
  };
211
193
  /**
212
194
  * Handle adding a new AI provider
@@ -214,7 +196,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
214
196
  */
215
197
  const handleAddProvider = async (providerConfig) => {
216
198
  if (model.config.useSecretsManager &&
217
- secretsManager &&
199
+ secretsAccess?.isAvailable &&
218
200
  providerConfig.apiKey) {
219
201
  providerConfig.apiKey = SECRETS_REPLACEMENT;
220
202
  }
@@ -227,7 +209,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
227
209
  const handleEditProvider = async (providerConfig) => {
228
210
  if (editingProvider) {
229
211
  if (model.config.useSecretsManager &&
230
- secretsManager &&
212
+ secretsAccess?.isAvailable &&
231
213
  providerConfig.apiKey) {
232
214
  providerConfig.apiKey = SECRETS_REPLACEMENT;
233
215
  }
@@ -249,7 +231,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
249
231
  */
250
232
  const openEditDialog = async (provider) => {
251
233
  // Retrieve the API key from the secrets manager if necessary.
252
- if (model.config.useSecretsManager && secretsManager) {
234
+ if (model.config.useSecretsManager && secretsAccess?.isAvailable) {
253
235
  provider.apiKey =
254
236
  (await getSecretFromManager(provider.provider, 'apiKey')) ?? '';
255
237
  }
@@ -477,7 +459,7 @@ const AISettingsComponent = ({ model, agentManagerFactory, themeManager, provide
477
459
  React.createElement(IconButton, { onClick: e => handleMenuClick(e, provider.id), size: "small" },
478
460
  React.createElement(MoreVert, null)))));
479
461
  }))))),
480
- secretsManager !== undefined && (React.createElement(FormControlLabel, { control: React.createElement(Switch, { checked: config.useSecretsManager, onChange: e => handleConfigUpdate({
462
+ secretsAccess?.isAvailable && (React.createElement(FormControlLabel, { control: React.createElement(Switch, { checked: config.useSecretsManager, onChange: e => handleConfigUpdate({
481
463
  useSecretsManager: e.target.checked
482
464
  }), color: "primary", sx: { alignSelf: 'flex-start' } }), label: React.createElement("div", null,
483
465
  React.createElement("span", null, trans.__('Use the secrets manager to manage API keys')),
@@ -761,18 +743,3 @@ const MCPServerDialog = ({ open, onClose, onSave, initialConfig, mode, trans })
761
743
  React.createElement(Button, { onClick: onClose }, trans.__('Cancel')),
762
744
  React.createElement(Button, { onClick: handleSave, variant: "contained", disabled: !canSave }, mode === 'add' ? trans.__('Add') : trans.__('Save')))));
763
745
  };
764
- var Private;
765
- (function (Private) {
766
- /**
767
- * The token to use with the secrets manager, setter and getter.
768
- */
769
- let secretsToken;
770
- function setToken(value) {
771
- secretsToken = value;
772
- }
773
- Private.setToken = setToken;
774
- function getToken() {
775
- return secretsToken;
776
- }
777
- Private.getToken = getToken;
778
- })(Private || (Private = {}));
@@ -68,7 +68,6 @@ function sanitizeCustomSettingsForProvider(customSettings, capabilities) {
68
68
  return result;
69
69
  }
70
70
  export const ProviderConfigDialog = ({ open, onClose, onSave, initialConfig, mode, providerRegistry, handleSecretField, trans }) => {
71
- const apiKeyRef = React.useRef();
72
71
  const [name, setName] = React.useState(initialConfig?.name || '');
73
72
  const [provider, setProvider] = React.useState(initialConfig?.provider || 'anthropic');
74
73
  const [model, setModel] = React.useState(initialConfig?.model || '');
@@ -130,13 +129,11 @@ export const ProviderConfigDialog = ({ open, onClose, onSave, initialConfig, mod
130
129
  setModel(selectedProvider.models[0]);
131
130
  }
132
131
  }, [provider, selectedProvider, model]);
133
- React.useEffect(() => {
134
- // Attach the API key field to the secrets manager, to automatically save the value
135
- // when it is updated.
136
- if (open && apiKeyRef.current) {
137
- handleSecretField(apiKeyRef.current, provider, 'apiKey');
132
+ const handleRef = React.useCallback((node) => {
133
+ if (open && node) {
134
+ handleSecretField(node, provider, 'apiKey');
138
135
  }
139
- }, [open, provider, handleSecretField]);
136
+ }, [provider, handleSecretField, open]);
140
137
  const updateCustomSetting = React.useCallback((section, key, value) => {
141
138
  setCustomSettings(prev => {
142
139
  const next = { ...prev };
@@ -275,7 +272,7 @@ export const ProviderConfigDialog = ({ open, onClose, onSave, initialConfig, mod
275
272
  ? trans.__('Code-specialized')
276
273
  : trans.__('General purpose'))))))))),
277
274
  selectedProvider &&
278
- selectedProvider?.apiKeyRequirement !== 'none' && (React.createElement(TextField, { fullWidth: true, inputRef: apiKeyRef, label: selectedProvider?.apiKeyRequirement === 'required'
275
+ selectedProvider?.apiKeyRequirement !== 'none' && (React.createElement(TextField, { fullWidth: true, inputRef: handleRef, label: selectedProvider?.apiKeyRequirement === 'required'
279
276
  ? trans.__('API Key')
280
277
  : trans.__('API Key (Optional)'), type: showApiKey ? 'text' : 'password', value: apiKey, onChange: e => setApiKey(e.target.value), placeholder: trans.__('Enter your API key...'), required: selectedProvider?.apiKeyRequirement === 'required', InputProps: {
281
278
  endAdornment: (React.createElement(InputAdornment, { position: "end" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyterlite/ai",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "AI code completions and chat for JupyterLite",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -56,12 +56,12 @@
56
56
  "docs:build": "sed -e 's/\\[@/[/g' -e 's/@/\\@/g' CHANGELOG.md > docs/_changelog_content.md && jupyter book build --html"
57
57
  },
58
58
  "dependencies": {
59
- "@ai-sdk/anthropic": "^3.0.40",
60
- "@ai-sdk/google": "^3.0.23",
61
- "@ai-sdk/mcp": "^1.0.19",
62
- "@ai-sdk/mistral": "^3.0.19",
63
- "@ai-sdk/openai": "^3.0.26",
64
- "@ai-sdk/openai-compatible": "^2.0.28",
59
+ "@ai-sdk/anthropic": "^3.0.53",
60
+ "@ai-sdk/google": "^3.0.36",
61
+ "@ai-sdk/mcp": "^1.0.23",
62
+ "@ai-sdk/mistral": "^3.0.22",
63
+ "@ai-sdk/openai": "^3.0.39",
64
+ "@ai-sdk/openai-compatible": "^2.0.33",
65
65
  "@jupyter/chat": "^0.20.0",
66
66
  "@jupyterlab/application": "^4.0.0",
67
67
  "@jupyterlab/apputils": "^4.5.6",
@@ -86,7 +86,7 @@
86
86
  "@lumino/widgets": "^2.7.1",
87
87
  "@mui/icons-material": "^7",
88
88
  "@mui/material": "^7",
89
- "ai": "^6.0.78",
89
+ "ai": "^6.0.108",
90
90
  "jupyter-secrets-manager": "^0.5.0",
91
91
  "yaml": "^2.8.1",
92
92
  "zod": "^4.3.6"
package/src/agent.ts CHANGED
@@ -111,6 +111,20 @@ export class AgentManagerFactory {
111
111
  secretsManager: this._secretsManager
112
112
  });
113
113
  this._agentManagers.push(agentManager);
114
+
115
+ // New chats can be created before MCP setup finishes.
116
+ // Reinitialize them with connected MCP tools once it does.
117
+ this._initQueue
118
+ .then(() => this.getMCPTools())
119
+ .then(mcpTools => {
120
+ if (Object.keys(mcpTools).length > 0) {
121
+ agentManager.initializeAgent(mcpTools);
122
+ }
123
+ })
124
+ .catch(error =>
125
+ console.warn('Failed to pass MCP tools to new agent:', error)
126
+ );
127
+
114
128
  return agentManager;
115
129
  }
116
130
 
@@ -657,6 +671,9 @@ export class AgentManager {
657
671
  data: { error: error as Error }
658
672
  });
659
673
  }
674
+ // After an error (including AbortError), sanitize the history
675
+ // to remove any trailing assistant messages without tool results
676
+ this._sanitizeHistory();
660
677
  } finally {
661
678
  this._controller = null;
662
679
  }
@@ -1191,6 +1208,96 @@ WEB RETRIEVAL POLICY:
1191
1208
  return `Supported MIME types in this session: ${safeMimeTypes.join(', ')}`;
1192
1209
  }
1193
1210
 
1211
+ /**
1212
+ * Sanitizes history to ensure it's in a valid state in case of abort or error.
1213
+ */
1214
+ private _sanitizeHistory(): void {
1215
+ if (this._history.length === 0) {
1216
+ return;
1217
+ }
1218
+
1219
+ const newHistory: ModelMessage[] = [];
1220
+ for (let i = 0; i < this._history.length; i++) {
1221
+ const msg = this._history[i];
1222
+
1223
+ if (msg.role === 'assistant') {
1224
+ const toolCallIds = this._getToolCallIds(msg);
1225
+ if (toolCallIds.length > 0) {
1226
+ // Find if there's a following tool message with results for these calls
1227
+ const nextMsg = this._history[i + 1];
1228
+ if (
1229
+ nextMsg &&
1230
+ nextMsg.role === 'tool' &&
1231
+ this._matchesAllToolCalls(nextMsg, toolCallIds)
1232
+ ) {
1233
+ newHistory.push(msg);
1234
+ } else {
1235
+ // Message has unmatched tool calls drop it and everything after it
1236
+ break;
1237
+ }
1238
+ } else {
1239
+ newHistory.push(msg);
1240
+ }
1241
+ } else if (msg.role === 'tool') {
1242
+ // Tool messages are valid if they were preceded by a valid assistant message
1243
+ newHistory.push(msg);
1244
+ } else {
1245
+ newHistory.push(msg);
1246
+ }
1247
+ }
1248
+
1249
+ this._history = newHistory;
1250
+ }
1251
+
1252
+ /**
1253
+ * Extracts tool call IDs from a message
1254
+ */
1255
+ private _getToolCallIds(message: ModelMessage): string[] {
1256
+ const ids: string[] = [];
1257
+
1258
+ // Check content array for tool-call parts
1259
+ if (Array.isArray(message.content)) {
1260
+ for (const part of message.content) {
1261
+ if (
1262
+ typeof part === 'object' &&
1263
+ part !== null &&
1264
+ 'type' in part &&
1265
+ part.type === 'tool-call'
1266
+ ) {
1267
+ ids.push(part.toolCallId);
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ return ids;
1273
+ }
1274
+
1275
+ /**
1276
+ * Checks if a tool message contains results for all specified tool call IDs
1277
+ */
1278
+ private _matchesAllToolCalls(
1279
+ message: ModelMessage,
1280
+ callIds: string[]
1281
+ ): boolean {
1282
+ if (message.role !== 'tool' || !Array.isArray(message.content)) {
1283
+ return false;
1284
+ }
1285
+
1286
+ const resultIds = new Set<string>();
1287
+ for (const part of message.content) {
1288
+ if (
1289
+ typeof part === 'object' &&
1290
+ part !== null &&
1291
+ 'type' in part &&
1292
+ part.type === 'tool-result'
1293
+ ) {
1294
+ resultIds.add(part.toolCallId);
1295
+ }
1296
+ }
1297
+
1298
+ return callIds.every(id => resultIds.has(id));
1299
+ }
1300
+
1194
1301
  // Private attributes
1195
1302
  private _settingsModel: AISettingsModel;
1196
1303
  private _toolRegistry?: IToolRegistry;