@theia/ai-core 1.77.0-next.4 → 1.77.0-next.47
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.
- package/lib/browser/frontend-language-model-service.d.ts.map +1 -1
- package/lib/browser/frontend-language-model-service.js +0 -8
- package/lib/browser/frontend-language-model-service.js.map +1 -1
- package/lib/browser/reasoning-labels.d.ts +4 -0
- package/lib/browser/reasoning-labels.d.ts.map +1 -0
- package/lib/browser/reasoning-labels.js +41 -0
- package/lib/browser/reasoning-labels.js.map +1 -0
- package/lib/browser/reasoning-labels.spec.d.ts +2 -0
- package/lib/browser/reasoning-labels.spec.d.ts.map +1 -0
- package/lib/browser/reasoning-labels.spec.js +28 -0
- package/lib/browser/reasoning-labels.spec.js.map +1 -0
- package/lib/common/ai-core-preferences.d.ts.map +1 -1
- package/lib/common/ai-core-preferences.js +6 -3
- package/lib/common/ai-core-preferences.js.map +1 -1
- package/lib/common/index.d.ts +1 -0
- package/lib/common/index.d.ts.map +1 -1
- package/lib/common/index.js +1 -0
- package/lib/common/index.js.map +1 -1
- package/lib/common/language-model-service.d.ts.map +1 -1
- package/lib/common/language-model-service.js +7 -0
- package/lib/common/language-model-service.js.map +1 -1
- package/lib/common/language-model-service.spec.js +21 -0
- package/lib/common/language-model-service.spec.js.map +1 -1
- package/lib/common/language-model.d.ts +10 -3
- package/lib/common/language-model.d.ts.map +1 -1
- package/lib/common/language-model.js +17 -4
- package/lib/common/language-model.js.map +1 -1
- package/lib/common/language-model.spec.js +18 -0
- package/lib/common/language-model.spec.js.map +1 -1
- package/lib/common/tool-call-execution.d.ts +89 -0
- package/lib/common/tool-call-execution.d.ts.map +1 -0
- package/lib/common/tool-call-execution.js +67 -0
- package/lib/common/tool-call-execution.js.map +1 -0
- package/lib/common/tool-call-execution.spec.d.ts +2 -0
- package/lib/common/tool-call-execution.spec.d.ts.map +1 -0
- package/lib/common/tool-call-execution.spec.js +114 -0
- package/lib/common/tool-call-execution.spec.js.map +1 -0
- package/lib/common/tool-request-parameters.spec.d.ts +2 -0
- package/lib/common/tool-request-parameters.spec.d.ts.map +1 -0
- package/lib/common/tool-request-parameters.spec.js +62 -0
- package/lib/common/tool-request-parameters.spec.js.map +1 -0
- package/lib/node/ai-core-backend-module.d.ts.map +1 -1
- package/lib/node/ai-core-backend-module.js +2 -0
- package/lib/node/ai-core-backend-module.js.map +1 -1
- package/package.json +9 -9
- package/src/browser/frontend-language-model-service.ts +1 -10
- package/src/browser/reasoning-labels.spec.ts +28 -0
- package/src/browser/reasoning-labels.ts +41 -0
- package/src/common/ai-core-preferences.ts +6 -3
- package/src/common/index.ts +1 -0
- package/src/common/language-model-service.spec.ts +23 -1
- package/src/common/language-model-service.ts +11 -1
- package/src/common/language-model.spec.ts +21 -0
- package/src/common/language-model.ts +26 -6
- package/src/common/tool-call-execution.spec.ts +162 -0
- package/src/common/tool-call-execution.ts +161 -0
- package/src/common/tool-request-parameters.spec.ts +81 -0
- package/src/node/ai-core-backend-module.ts +5 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource GmbH.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { nls } from '@theia/core/lib/common/nls';
|
|
18
|
+
import { ReasoningLevel } from '../common/language-model';
|
|
19
|
+
|
|
20
|
+
export function reasoningLevelLabel(level: ReasoningLevel): string {
|
|
21
|
+
switch (level) {
|
|
22
|
+
case 'off': return nls.localizeByDefault('Off');
|
|
23
|
+
case 'none': return nls.localizeByDefault('None');
|
|
24
|
+
case 'minimal': return nls.localizeByDefault('Minimal');
|
|
25
|
+
case 'low': return nls.localizeByDefault('Low');
|
|
26
|
+
case 'medium': return nls.localizeByDefault('Medium');
|
|
27
|
+
case 'high': return nls.localizeByDefault('High');
|
|
28
|
+
case 'xhigh': return nls.localizeByDefault('Extra High');
|
|
29
|
+
case 'max': return nls.localizeByDefault('Max');
|
|
30
|
+
case 'auto': return nls.localizeByDefault('Auto');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function reasoningLevelDescription(level: ReasoningLevel): string | undefined {
|
|
35
|
+
switch (level) {
|
|
36
|
+
case 'off': return nls.localize('theia/ai/core/reasoning/offDescription',
|
|
37
|
+
'Omits explicit reasoning effort, allowing provider defaults or raw request settings to apply; on Ollama, disables thinking.');
|
|
38
|
+
case 'none': return nls.localize('theia/ai/core/reasoning/noneDescription', 'Explicitly requests no reasoning.');
|
|
39
|
+
default: return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -240,11 +240,14 @@ export const aiCorePreferenceSchema: PreferenceSchema = {
|
|
|
240
240
|
+ ' `#ai-features.modelSettings.requestSettings#` for the same fields.\n\n'
|
|
241
241
|
+ 'Each entry consists of:\n'
|
|
242
242
|
+ '- `scope`: Defines when the setting applies (`modelId`, `providerId`, `agentId`).\n'
|
|
243
|
-
+ '- `reasoning.level`: One of `off`, `minimal`, `low`, `medium`, `high`, `auto`.\n
|
|
243
|
+
+ '- `reasoning.level`: One of `off`, `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `auto`.\n'
|
|
244
|
+
+ 'Supported levels depend on the model. `none` explicitly requests no reasoning; `off` disables thinking'
|
|
245
|
+
+ ' on Ollama. For other providers, `off` omits explicit reasoning effort, allowing provider defaults'
|
|
246
|
+
+ ' or raw request settings to apply. `auto` uses the provider default.\n\n'
|
|
244
247
|
+ 'Precedence at runtime (highest first): session override via the selector → this preference →'
|
|
245
248
|
+ ' the model\'s declared default. Whichever the selector displays is what gets sent. To override'
|
|
246
249
|
+ ' a provider\'s reasoning field manually via `#ai-features.modelSettings.requestSettings#`, set'
|
|
247
|
-
+ ' `reasoning.level` to `off` here so the level-based translation is disabled.\n\n'
|
|
250
|
+
+ ' `reasoning.level` to `off` here so the level-based translation is disabled (except on Ollama).\n\n'
|
|
248
251
|
+ 'Entries are matched based on scope specificity (agent: 100, model: 10, provider: 1 points).\n\n'
|
|
249
252
|
+ 'Example:\n'
|
|
250
253
|
+ '```json\n'
|
|
@@ -272,7 +275,7 @@ export const aiCorePreferenceSchema: PreferenceSchema = {
|
|
|
272
275
|
properties: {
|
|
273
276
|
level: {
|
|
274
277
|
type: 'string',
|
|
275
|
-
enum: ['off', 'minimal', 'low', 'medium', 'high', 'auto'],
|
|
278
|
+
enum: ['off', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'auto'],
|
|
276
279
|
default: 'auto'
|
|
277
280
|
}
|
|
278
281
|
},
|
package/src/common/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export * from './language-model-delegate';
|
|
|
22
22
|
export * from './language-model-util';
|
|
23
23
|
export * from './language-model';
|
|
24
24
|
export * from './language-model-alias';
|
|
25
|
+
export * from './tool-call-execution';
|
|
25
26
|
export * from './model-discovery-status';
|
|
26
27
|
export * from './model-discovery-util';
|
|
27
28
|
export * from './prompt-service';
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { expect } from 'chai';
|
|
18
18
|
import { LanguageModelServiceImpl } from './language-model-service';
|
|
19
|
-
import { LanguageModel, LanguageModelMessage, UserRequest } from './language-model';
|
|
19
|
+
import { LanguageModel, LanguageModelMessage, ReasoningLevel, ReasoningSupport, UserRequest } from './language-model';
|
|
20
20
|
|
|
21
21
|
describe('LanguageModelServiceImpl message filtering', () => {
|
|
22
22
|
|
|
@@ -33,6 +33,28 @@ describe('LanguageModelServiceImpl message filtering', () => {
|
|
|
33
33
|
return { model, captured: () => capturedMessages };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
for (const [support, requested, expected] of [
|
|
37
|
+
[{ supportedLevels: ['off', 'minimal', 'low', 'auto'] }, 'none', 'off'],
|
|
38
|
+
[{ supportedLevels: ['off', 'low', 'medium', 'high', 'auto'] }, 'max', 'high'],
|
|
39
|
+
[{ supportedLevels: ['none', 'low'] }, 'minimal', 'low'],
|
|
40
|
+
[undefined, 'max', 'max']
|
|
41
|
+
] satisfies [ReasoningSupport | undefined, ReasoningLevel, ReasoningLevel][]) {
|
|
42
|
+
it(`clamps ${requested} to ${expected} in the common request path with support ${JSON.stringify(support)}`, async () => {
|
|
43
|
+
const service = new LanguageModelServiceImpl();
|
|
44
|
+
const reasoning = { level: requested };
|
|
45
|
+
const model: LanguageModel = {
|
|
46
|
+
id: 'test', name: 'test', vendor: 'test', version: '1', status: { status: 'ready' }, reasoningSupport: support,
|
|
47
|
+
async request(request): Promise<{ text: string }> {
|
|
48
|
+
expect(request.reasoning?.level).to.equal(expected);
|
|
49
|
+
return { text: '' };
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
await service.sendRequest(model, { messages: [], sessionId: 'session', requestId: 'req', reasoning });
|
|
53
|
+
expect(reasoning.level).to.equal(requested);
|
|
54
|
+
expect(service.sessions[0].exchanges[0].requests[0].request.reasoning?.level).to.equal(expected);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
function baseMessages(): LanguageModelMessage[] {
|
|
37
59
|
return [
|
|
38
60
|
{ actor: 'user', type: 'text', text: 'hi' },
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
17
|
import { inject } from '@theia/core/shared/inversify';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
isLanguageModelStreamResponse, LanguageModel, LanguageModelRegistry, LanguageModelResponse, LanguageModelStreamResponsePart, ReasoningSupport, UserRequest
|
|
20
|
+
} from './language-model';
|
|
19
21
|
import { LanguageModelExchangeRequest, LanguageModelSession } from './language-model-interaction-model';
|
|
20
22
|
import { Emitter } from '@theia/core';
|
|
21
23
|
|
|
@@ -72,6 +74,14 @@ export class LanguageModelServiceImpl implements LanguageModelService {
|
|
|
72
74
|
languageModel: LanguageModel,
|
|
73
75
|
languageModelRequest: UserRequest
|
|
74
76
|
): Promise<LanguageModelResponse> {
|
|
77
|
+
// Clamp persisted selections and direct requests to the model's declared reasoning levels.
|
|
78
|
+
if (languageModelRequest.reasoning && languageModel.reasoningSupport) {
|
|
79
|
+
const level = ReasoningSupport.clampLevel(languageModel.reasoningSupport, languageModelRequest.reasoning.level);
|
|
80
|
+
if (level !== languageModelRequest.reasoning.level) {
|
|
81
|
+
languageModelRequest.reasoning = { ...languageModelRequest.reasoning, level };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
// Filter messages based on client settings
|
|
76
86
|
languageModelRequest.messages = languageModelRequest.messages.filter(message => {
|
|
77
87
|
if (message.type === 'thinking' && languageModelRequest.clientSettings?.keepThinking === false) {
|
|
@@ -367,8 +367,29 @@ describe('ReasoningSupport.clampLevel', () => {
|
|
|
367
367
|
expect(ReasoningSupport.clampLevel(oSeries, 'auto')).to.equal('auto');
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
+
it('passes through native none, xhigh, and max when supported', () => {
|
|
371
|
+
const support: ReasoningSupport = { supportedLevels: ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'auto'] };
|
|
372
|
+
for (const level of support.supportedLevels) {
|
|
373
|
+
expect(ReasoningSupport.clampLevel(support, level)).to.equal(level);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('clamps extended efforts to older models and max to max-only models', () => {
|
|
378
|
+
expect(ReasoningSupport.clampLevel(oSeries, 'xhigh')).to.equal('high');
|
|
379
|
+
expect(ReasoningSupport.clampLevel(oSeries, 'max')).to.equal('high');
|
|
380
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: ['low', 'medium', 'high', 'max'] }, 'xhigh')).to.equal('max');
|
|
381
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: ['low', 'medium', 'high', 'xhigh'] }, 'max')).to.equal('xhigh');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('clamps none to the nearest supported effort', () => {
|
|
385
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: ['low', 'medium', 'high'] }, 'none')).to.equal('low');
|
|
386
|
+
expect(ReasoningSupport.clampLevel(oSeries, 'none')).to.equal('off');
|
|
387
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: ['off', 'minimal', 'low', 'auto'] }, 'none')).to.equal('off');
|
|
388
|
+
});
|
|
389
|
+
|
|
370
390
|
it('raises an unsupported minimal to low rather than lowering it to off', () => {
|
|
371
391
|
expect(ReasoningSupport.clampLevel(oSeries, 'minimal')).to.equal('low');
|
|
392
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: ['none', 'low'] }, 'minimal')).to.equal('low');
|
|
372
393
|
});
|
|
373
394
|
|
|
374
395
|
it('lowers an unsupported level when nothing higher is supported', () => {
|
|
@@ -20,7 +20,7 @@ import { inject, injectable, named, postConstruct } from '@theia/core/shared/inv
|
|
|
20
20
|
export type MessageActor = 'user' | 'ai' | 'system';
|
|
21
21
|
|
|
22
22
|
/** Provider-agnostic reasoning level; each provider maps this to its native API. */
|
|
23
|
-
export type ReasoningLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'auto';
|
|
23
|
+
export type ReasoningLevel = 'off' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
|
|
24
24
|
|
|
25
25
|
export interface ReasoningSettings {
|
|
26
26
|
level: ReasoningLevel;
|
|
@@ -43,11 +43,12 @@ export interface ReasoningSupport {
|
|
|
43
43
|
}
|
|
44
44
|
export namespace ReasoningSupport {
|
|
45
45
|
/** Levels ordered by increasing effort; `'auto'` sits outside the scale. */
|
|
46
|
-
const EFFORT_SCALE: ReadonlyArray<ReasoningLevel> = ['off', 'minimal', 'low', 'medium', 'high'];
|
|
46
|
+
const EFFORT_SCALE: ReadonlyArray<ReasoningLevel> = ['off', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Returns `level` when `support` lists it, otherwise the nearest supported level on the effort scale,
|
|
50
|
-
* preferring the higher neighbour (an unsupported `minimal` becomes `low`, not `off`)
|
|
50
|
+
* preferring the higher neighbour (an unsupported `minimal` becomes `low`, not `off`), except that
|
|
51
|
+
* unsupported `'none'` prefers `'off'` to avoid enabling reasoning. An unsupported
|
|
51
52
|
* `'auto'` resolves to `defaultLevel`, then to the first supported level. Returns `level` unchanged
|
|
52
53
|
* when nothing suitable is supported.
|
|
53
54
|
*/
|
|
@@ -56,6 +57,9 @@ export namespace ReasoningSupport {
|
|
|
56
57
|
if (supported.includes(level)) {
|
|
57
58
|
return level;
|
|
58
59
|
}
|
|
60
|
+
if (level === 'none' && supported.includes('off')) {
|
|
61
|
+
return 'off';
|
|
62
|
+
}
|
|
59
63
|
if (level === 'auto') {
|
|
60
64
|
return support.defaultLevel && supported.includes(support.defaultLevel) ? support.defaultLevel : supported[0] ?? level;
|
|
61
65
|
}
|
|
@@ -188,8 +192,15 @@ export interface AutoActionResult {
|
|
|
188
192
|
reason?: string;
|
|
189
193
|
}
|
|
190
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The primitive JSON Schema types allowed in a {@link ToolRequestParameterProperty}.
|
|
197
|
+
* Follows JSON Schema 2020-12 / MCP: a property may declare a single type or a
|
|
198
|
+
* union of types (e.g. `type: ["string", "null"]`).
|
|
199
|
+
*/
|
|
200
|
+
export type ToolRequestParameterType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
|
|
201
|
+
|
|
191
202
|
export interface ToolRequestParameterProperty {
|
|
192
|
-
type?:
|
|
203
|
+
type?: ToolRequestParameterType | ToolRequestParameterType[];
|
|
193
204
|
anyOf?: ToolRequestParameterProperty[];
|
|
194
205
|
[key: string]: unknown;
|
|
195
206
|
}
|
|
@@ -326,8 +337,17 @@ export namespace ToolRequest {
|
|
|
326
337
|
}
|
|
327
338
|
}
|
|
328
339
|
}
|
|
329
|
-
if ('type' in record
|
|
330
|
-
|
|
340
|
+
if ('type' in record) {
|
|
341
|
+
if (typeof record.type !== 'string' && !Array.isArray(record.type)) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (Array.isArray(record.type)) {
|
|
345
|
+
for (const typeItem of record.type) {
|
|
346
|
+
if (typeof typeItem !== 'string') {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
331
351
|
}
|
|
332
352
|
|
|
333
353
|
// No further checks required for additional properties.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource GmbH and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { expect } from 'chai';
|
|
18
|
+
import { CancellationToken, CancellationTokenSource, ILogger } from '@theia/core';
|
|
19
|
+
import { Container } from '@theia/core/shared/inversify';
|
|
20
|
+
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
|
|
21
|
+
import { Deferred } from '@theia/core/lib/common/promise-util';
|
|
22
|
+
import {
|
|
23
|
+
ToolRequest,
|
|
24
|
+
ToolInvocationContext,
|
|
25
|
+
hasToolCallError,
|
|
26
|
+
isToolNotAvailableError,
|
|
27
|
+
isToolCallContent
|
|
28
|
+
} from './language-model';
|
|
29
|
+
import { ToolCallExecutor, ToolCallExecutorImpl, ToolCallOutcome } from './tool-call-execution';
|
|
30
|
+
|
|
31
|
+
/** Builds a minimal {@link ToolRequest} whose handler delegates to `handler`. */
|
|
32
|
+
function tool(name: string, handler: ToolRequest['handler']): ToolRequest {
|
|
33
|
+
return { id: name, name, parameters: { type: 'object', properties: {} }, handler };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('ToolCallExecutor', () => {
|
|
37
|
+
let executor: ToolCallExecutor;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
const container = new Container();
|
|
41
|
+
container.bind(ILogger).to(MockLogger);
|
|
42
|
+
container.bind(ToolCallExecutorImpl).toSelf();
|
|
43
|
+
executor = container.get(ToolCallExecutorImpl);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('executes the tool calls of a turn concurrently (not sequentially)', async () => {
|
|
47
|
+
// `a` only completes once `b` has started. A sequential implementation that runs
|
|
48
|
+
// `a` before `b` would deadlock here, so this is the core regression test for #17533.
|
|
49
|
+
const bStarted = new Deferred<void>();
|
|
50
|
+
const tools = [
|
|
51
|
+
tool('a', async () => {
|
|
52
|
+
await bStarted.promise;
|
|
53
|
+
return 'a-done';
|
|
54
|
+
}),
|
|
55
|
+
tool('b', async () => {
|
|
56
|
+
bStarted.resolve();
|
|
57
|
+
return 'b-done';
|
|
58
|
+
})
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const results = await executor.executeToolCalls(
|
|
62
|
+
[{ id: '1', name: 'a', arguments: '{}' }, { id: '2', name: 'b', arguments: '{}' }],
|
|
63
|
+
tools
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(results.map(r => r.result)).to.deep.equal(['a-done', 'b-done']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('preserves input order even when calls complete out of order', async () => {
|
|
70
|
+
const aResolves = new Deferred<void>();
|
|
71
|
+
const tools = [
|
|
72
|
+
tool('slow', async () => {
|
|
73
|
+
await aResolves.promise;
|
|
74
|
+
return 'slow-done';
|
|
75
|
+
}),
|
|
76
|
+
tool('fast', async () => {
|
|
77
|
+
// Let `fast` finish first, then release `slow`.
|
|
78
|
+
aResolves.resolve();
|
|
79
|
+
return 'fast-done';
|
|
80
|
+
})
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const results = await executor.executeToolCalls(
|
|
84
|
+
[{ id: 'a', name: 'slow', arguments: '{}' }, { id: 'b', name: 'fast', arguments: '{}' }],
|
|
85
|
+
tools
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(results.map(r => r.id)).to.deep.equal(['a', 'b']);
|
|
89
|
+
expect(results.map(r => r.result)).to.deep.equal(['slow-done', 'fast-done']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('reports a tool-not-available error when no tool matches the name', async () => {
|
|
93
|
+
const results = await executor.executeToolCalls(
|
|
94
|
+
[{ id: '1', name: 'missing', arguments: '{}' }],
|
|
95
|
+
[tool('present', async () => 'ok')]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(results).to.have.lengthOf(1);
|
|
99
|
+
expect(results[0].notFound).to.equal(true);
|
|
100
|
+
expect(results[0].error).to.equal(undefined);
|
|
101
|
+
expect(isToolCallContent(results[0].result)).to.equal(true);
|
|
102
|
+
expect(isToolCallContent(results[0].result) && results[0].result.content.some(isToolNotAvailableError)).to.equal(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('isolates a throwing handler: it does not reject, exposes the error, and siblings still resolve', async () => {
|
|
106
|
+
const boom = new Error('handler boom');
|
|
107
|
+
const results = await executor.executeToolCalls(
|
|
108
|
+
[{ id: '1', name: 'throws', arguments: '{}' }, { id: '2', name: 'ok', arguments: '{}' }],
|
|
109
|
+
[
|
|
110
|
+
tool('throws', async () => { throw boom; }),
|
|
111
|
+
tool('ok', async () => 'ok-result')
|
|
112
|
+
]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const thrown = results.find(r => r.id === '1')!;
|
|
116
|
+
const succeeded = results.find(r => r.id === '2')!;
|
|
117
|
+
expect(thrown.error).to.equal(boom);
|
|
118
|
+
expect(hasToolCallError(thrown.result)).to.equal(true);
|
|
119
|
+
expect(thrown.notFound).to.equal(false);
|
|
120
|
+
expect(succeeded.result).to.equal('ok-result');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('invokes onResult exactly once per tool call', async () => {
|
|
124
|
+
const seen: string[] = [];
|
|
125
|
+
await executor.executeToolCalls(
|
|
126
|
+
[{ id: '1', name: 'a', arguments: '{}' }, { id: '2', name: 'b', arguments: '{}' }],
|
|
127
|
+
[tool('a', async () => 'a'), tool('b', async () => 'b')],
|
|
128
|
+
{ onResult: (r: ToolCallOutcome) => seen.push(r.id) }
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(seen.slice().sort()).to.deep.equal(['1', '2']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('forwards the cancellation token into each ToolInvocationContext', async () => {
|
|
135
|
+
const source = new CancellationTokenSource();
|
|
136
|
+
let received: CancellationToken | undefined;
|
|
137
|
+
await executor.executeToolCalls(
|
|
138
|
+
[{ id: '1', name: 'a', arguments: '{}' }],
|
|
139
|
+
[tool('a', async (_args, ctx) => { received = ToolInvocationContext.getCancellationToken(ctx); return 'a'; })],
|
|
140
|
+
{ cancellationToken: source.token }
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(received).to.equal(source.token);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('does not let a throwing onResult callback reject the execution or affect the results', async () => {
|
|
147
|
+
const results = await executor.executeToolCalls(
|
|
148
|
+
[{ id: '1', name: 'a', arguments: '{}' }, { id: '2', name: 'b', arguments: '{}' }],
|
|
149
|
+
[tool('a', async () => 'a-done'), tool('b', async () => 'b-done')],
|
|
150
|
+
{ onResult: () => { throw new Error('reporting boom'); } }
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(results.map(r => r.result)).to.deep.equal(['a-done', 'b-done']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns an empty array and never calls onResult for empty input', async () => {
|
|
157
|
+
let called = false;
|
|
158
|
+
const results = await executor.executeToolCalls([], [tool('a', async () => 'a')], { onResult: () => { called = true; } });
|
|
159
|
+
expect(results).to.deep.equal([]);
|
|
160
|
+
expect(called).to.equal(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource GmbH and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { CancellationToken, ILogger } from '@theia/core';
|
|
18
|
+
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
|
19
|
+
import {
|
|
20
|
+
ToolRequest,
|
|
21
|
+
ToolCallResult,
|
|
22
|
+
ToolInvocationContext,
|
|
23
|
+
createToolCallError
|
|
24
|
+
} from './language-model';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A single tool call collected from one model response/turn, in a provider-neutral shape.
|
|
28
|
+
* Each language model normalizes its own representation into this shape before execution.
|
|
29
|
+
*/
|
|
30
|
+
export interface ToolInvocation {
|
|
31
|
+
/** The tool call ID assigned by the model; forwarded into the {@link ToolInvocationContext}. */
|
|
32
|
+
readonly id: string;
|
|
33
|
+
/** The name used to match against {@link ToolRequest.name}. */
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/** The raw JSON argument string passed verbatim to {@link ToolRequest.handler}. */
|
|
36
|
+
readonly arguments: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A tool reduced to what executing a call against it requires: the name that a {@link ToolInvocation}
|
|
41
|
+
* is matched against and the handler to invoke. A language model that has already converted the
|
|
42
|
+
* request's tools into its provider's own representation can recover just these two from that
|
|
43
|
+
* representation instead of reconstructing a whole {@link ToolRequest}.
|
|
44
|
+
*/
|
|
45
|
+
export type ExecutableTool = Pick<ToolRequest, 'name' | 'handler'>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The normalized outcome of executing one tool call. Returned in the same order as the
|
|
49
|
+
* input array, regardless of the order in which the calls actually completed.
|
|
50
|
+
*/
|
|
51
|
+
export interface ToolCallOutcome {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly arguments: string;
|
|
55
|
+
/**
|
|
56
|
+
* The handler result, or a {@link createToolCallError} value if the tool was not found
|
|
57
|
+
* or the handler threw. A thrown handler yields an error result here, never a rejection.
|
|
58
|
+
*/
|
|
59
|
+
readonly result: ToolCallResult;
|
|
60
|
+
/** The original error if the handler threw; `undefined` for success and for tool-not-found. */
|
|
61
|
+
readonly error?: Error;
|
|
62
|
+
/** `true` when no {@link ToolRequest} matched {@link ToolInvocation.name}. */
|
|
63
|
+
readonly notFound: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ToolCallExecutionOptions {
|
|
67
|
+
/**
|
|
68
|
+
* Optional per-call hook invoked exactly once per tool call as soon as that call settles
|
|
69
|
+
* (success, throw, or not-found). Use it for per-call side effects such as streaming a UI
|
|
70
|
+
* event or appending a provider message.
|
|
71
|
+
*
|
|
72
|
+
* **Note:** because calls run concurrently, this hook fires in completion order, not input
|
|
73
|
+
* order. Side effects that must be ordered should instead be performed by the caller over
|
|
74
|
+
* the returned (input-ordered) array of execution results.
|
|
75
|
+
*/
|
|
76
|
+
readonly onResult?: (result: ToolCallOutcome) => void;
|
|
77
|
+
/** Optional cancellation token forwarded into each {@link ToolInvocationContext}. */
|
|
78
|
+
readonly cancellationToken?: CancellationToken;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Executes the tool calls of a single language model response/turn.
|
|
83
|
+
*
|
|
84
|
+
* Concurrency contract: tool calls emitted within a single model response/turn are executed
|
|
85
|
+
* concurrently. All matched handlers are started before any of them is awaited.
|
|
86
|
+
* A tool handler must therefore not assume that it runs in isolation or in array
|
|
87
|
+
* order, and must be safe to overlap with sibling calls from the same turn. Models serialize
|
|
88
|
+
* dependent calls across turns (by withholding a dependent call until it has seen the prior
|
|
89
|
+
* result), so "issued together in one turn" is the model's signal that the calls are independent.
|
|
90
|
+
*
|
|
91
|
+
* Error handling is uniform across all language model providers:
|
|
92
|
+
* - If no {@link ToolRequest} matches a call's name, its result is a
|
|
93
|
+
* {@link createToolCallError} with kind `'tool-not-available'` and `notFound` is `true`.
|
|
94
|
+
* - If a handler throws/rejects, the rejection is caught, logged, and converted to a
|
|
95
|
+
* {@link createToolCallError}; the original error is exposed on `error`.
|
|
96
|
+
* - The overall tool execution promise never rejects but returns a result that may
|
|
97
|
+
* include errors from failed tool calls
|
|
98
|
+
*/
|
|
99
|
+
export interface ToolCallExecutor {
|
|
100
|
+
/**
|
|
101
|
+
* Executes all `toolCalls` concurrently and returns their outcomes in input order.
|
|
102
|
+
*
|
|
103
|
+
* @param toolCalls the tool calls collected for this turn (id + name + raw args)
|
|
104
|
+
* @param tools the tools available for this request (typically `request.tools`), reduced to the
|
|
105
|
+
* {@link ExecutableTool} shape
|
|
106
|
+
* @param options optional per-call hook and cancellation token
|
|
107
|
+
*/
|
|
108
|
+
executeToolCalls(
|
|
109
|
+
toolCalls: readonly ToolInvocation[],
|
|
110
|
+
tools: readonly ExecutableTool[] | undefined,
|
|
111
|
+
options?: ToolCallExecutionOptions
|
|
112
|
+
): Promise<ToolCallOutcome[]>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const ToolCallExecutor = Symbol('ToolCallExecutor');
|
|
116
|
+
|
|
117
|
+
@injectable()
|
|
118
|
+
export class ToolCallExecutorImpl implements ToolCallExecutor {
|
|
119
|
+
|
|
120
|
+
@inject(ILogger) @named('ai-core:ToolCallExecutorImpl')
|
|
121
|
+
protected readonly logger: ILogger;
|
|
122
|
+
|
|
123
|
+
async executeToolCalls(
|
|
124
|
+
toolCalls: readonly ToolInvocation[],
|
|
125
|
+
tools: readonly ExecutableTool[] | undefined,
|
|
126
|
+
options: ToolCallExecutionOptions = {}
|
|
127
|
+
): Promise<ToolCallOutcome[]> {
|
|
128
|
+
return Promise.all(toolCalls.map(toolCall => this.executeToolCall(toolCall, tools, options)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
protected async executeToolCall(
|
|
132
|
+
toolCall: ToolInvocation,
|
|
133
|
+
tools: readonly ExecutableTool[] | undefined,
|
|
134
|
+
options: ToolCallExecutionOptions
|
|
135
|
+
): Promise<ToolCallOutcome> {
|
|
136
|
+
const { id, name, arguments: args } = toolCall;
|
|
137
|
+
const tool = tools?.find(candidate => candidate.name === name);
|
|
138
|
+
let outcome: ToolCallOutcome;
|
|
139
|
+
if (!tool) {
|
|
140
|
+
outcome = {
|
|
141
|
+
id, name, arguments: args, notFound: true,
|
|
142
|
+
result: createToolCallError(`Tool '${name}' not found in the available tools for this request.`, 'tool-not-available')
|
|
143
|
+
};
|
|
144
|
+
} else {
|
|
145
|
+
try {
|
|
146
|
+
const result = await tool.handler(args, ToolInvocationContext.create(id, options.cancellationToken));
|
|
147
|
+
outcome = { id, name, arguments: args, result, notFound: false };
|
|
148
|
+
} catch (e) {
|
|
149
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
150
|
+
this.logger.error(`Error executing tool ${name}:`, e);
|
|
151
|
+
outcome = { id, name, arguments: args, notFound: false, error, result: createToolCallError(error.message || 'Tool execution failed') };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
options.onResult?.(outcome);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.logger.error('Uncaught error in tool-call onResult call-back.', error);
|
|
158
|
+
}
|
|
159
|
+
return outcome;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 Beijing Kaikong Technology Co., Ltd. and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { expect } from 'chai';
|
|
18
|
+
import { ToolRequest } from './language-model';
|
|
19
|
+
|
|
20
|
+
describe('ToolRequest.isToolRequestParameters', () => {
|
|
21
|
+
it('accepts a property with a plain string type', () => {
|
|
22
|
+
expect(
|
|
23
|
+
ToolRequest.isToolRequestParameters({
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: { name: { type: 'string' } }
|
|
26
|
+
})
|
|
27
|
+
).to.equal(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('accepts a property whose type is a union array (JSON Schema 2020-12)', () => {
|
|
31
|
+
// Mirrors the shape produced by MCP tools with `type: ["string", "null"]`.
|
|
32
|
+
expect(
|
|
33
|
+
ToolRequest.isToolRequestParameters({
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: { opt: { type: ['string', 'null'] } }
|
|
36
|
+
})
|
|
37
|
+
).to.equal(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('accepts a property whose type array has more than two elements', () => {
|
|
41
|
+
expect(
|
|
42
|
+
ToolRequest.isToolRequestParameters({
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: { mixed: { type: ['string', 'number', 'null'] } }
|
|
45
|
+
})
|
|
46
|
+
).to.equal(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects a property whose type array contains a non-string element', () => {
|
|
50
|
+
expect(
|
|
51
|
+
ToolRequest.isToolRequestParameters({
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: { bad: { type: ['string', 42] } }
|
|
54
|
+
})
|
|
55
|
+
).to.equal(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects a property whose type is not a string or an array of strings', () => {
|
|
59
|
+
expect(
|
|
60
|
+
ToolRequest.isToolRequestParameters({
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: { bad: { type: 42 } }
|
|
63
|
+
})
|
|
64
|
+
).to.equal(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects a property without a type or anyOf', () => {
|
|
68
|
+
expect(
|
|
69
|
+
ToolRequest.isToolRequestParameters({
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: { bad: {} }
|
|
72
|
+
})
|
|
73
|
+
).to.equal(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects a top-level object without a properties object', () => {
|
|
77
|
+
expect(
|
|
78
|
+
ToolRequest.isToolRequestParameters({ type: 'object' })
|
|
79
|
+
).to.equal(false);
|
|
80
|
+
});
|
|
81
|
+
});
|