@theia/ai-chat 1.71.0-next.8 → 1.71.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.
- package/lib/browser/agent-delegation-tool.d.ts +4 -10
- package/lib/browser/agent-delegation-tool.d.ts.map +1 -1
- package/lib/browser/agent-delegation-tool.js +31 -22
- package/lib/browser/agent-delegation-tool.js.map +1 -1
- package/lib/browser/agent-delegation-tool.spec.js +3 -2
- package/lib/browser/agent-delegation-tool.spec.js.map +1 -1
- package/lib/browser/chat-tool-preference-bindings.d.ts +3 -2
- package/lib/browser/chat-tool-preference-bindings.d.ts.map +1 -1
- package/lib/browser/chat-tool-preference-bindings.js +9 -8
- package/lib/browser/chat-tool-preference-bindings.js.map +1 -1
- package/lib/browser/chat-tool-preference-bindings.spec.js +72 -21
- package/lib/browser/chat-tool-preference-bindings.spec.js.map +1 -1
- package/lib/browser/chat-tool-request-service.d.ts.map +1 -1
- package/lib/browser/chat-tool-request-service.js +3 -1
- package/lib/browser/chat-tool-request-service.js.map +1 -1
- package/lib/common/chat-agents.d.ts +5 -2
- package/lib/common/chat-agents.d.ts.map +1 -1
- package/lib/common/chat-agents.js +54 -1
- package/lib/common/chat-agents.js.map +1 -1
- package/lib/common/chat-agents.spec.d.ts +2 -0
- package/lib/common/chat-agents.spec.d.ts.map +1 -0
- package/lib/common/chat-agents.spec.js +100 -0
- package/lib/common/chat-agents.spec.js.map +1 -0
- package/lib/common/chat-model-serialization.d.ts +2 -0
- package/lib/common/chat-model-serialization.d.ts.map +1 -1
- package/lib/common/chat-model-serialization.js.map +1 -1
- package/lib/common/chat-model.d.ts +59 -11
- package/lib/common/chat-model.d.ts.map +1 -1
- package/lib/common/chat-model.js +82 -2
- package/lib/common/chat-model.js.map +1 -1
- package/lib/common/chat-request-parser.spec.js +5 -2
- package/lib/common/chat-request-parser.spec.js.map +1 -1
- package/lib/common/chat-response-model.spec.d.ts +2 -0
- package/lib/common/chat-response-model.spec.d.ts.map +1 -0
- package/lib/common/chat-response-model.spec.js +43 -0
- package/lib/common/chat-response-model.spec.js.map +1 -0
- package/package.json +11 -11
- package/src/browser/agent-delegation-tool.spec.ts +4 -2
- package/src/browser/agent-delegation-tool.ts +40 -29
- package/src/browser/chat-tool-preference-bindings.spec.ts +83 -23
- package/src/browser/chat-tool-preference-bindings.ts +17 -8
- package/src/browser/chat-tool-request-service.ts +3 -2
- package/src/common/chat-agents.spec.ts +124 -0
- package/src/common/chat-agents.ts +59 -2
- package/src/common/chat-model-serialization.ts +2 -0
- package/src/common/chat-model.ts +136 -12
- package/src/common/chat-request-parser.spec.ts +6 -2
- package/src/common/chat-response-model.spec.ts +47 -0
|
@@ -15,18 +15,20 @@
|
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
17
|
import { AGENT_DELEGATION_FUNCTION_ID, ToolInvocationContext, ToolProvider, ToolRequest } from '@theia/ai-core';
|
|
18
|
+
import { Disposable } from '@theia/core';
|
|
18
19
|
import { inject, injectable } from '@theia/core/shared/inversify';
|
|
19
20
|
import {
|
|
20
21
|
assertChatContext,
|
|
21
22
|
ChatAgentService,
|
|
22
23
|
ChatAgentServiceFactory,
|
|
24
|
+
ChatChangeEvent,
|
|
23
25
|
ChatRequest,
|
|
24
26
|
ChatService,
|
|
25
27
|
ChatServiceFactory,
|
|
26
28
|
ChatToolContext,
|
|
27
29
|
MutableChatModel,
|
|
28
30
|
MutableChatRequestModel,
|
|
29
|
-
|
|
31
|
+
MutableChatResponseModel,
|
|
30
32
|
ChatRequestInvocation,
|
|
31
33
|
} from '../common';
|
|
32
34
|
import { TASK_CONTEXT_VARIABLE } from './task-context-variable';
|
|
@@ -114,6 +116,7 @@ export class AgentDelegationTool implements ToolProvider {
|
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
let newSession;
|
|
119
|
+
let childModelDisposable: Disposable | undefined;
|
|
117
120
|
try {
|
|
118
121
|
// FIXME: this creates a new conversation visible in the UI (Panel), which we don't want
|
|
119
122
|
// It is not possible to start a session without specifying a location (default=Panel)
|
|
@@ -146,8 +149,8 @@ export class AgentDelegationTool implements ToolProvider {
|
|
|
146
149
|
chatService.setActiveSession(currentActiveSession.id, { focus: false });
|
|
147
150
|
}
|
|
148
151
|
|
|
149
|
-
// Setup
|
|
150
|
-
this.
|
|
152
|
+
// Setup bubbling of child session events to parent session
|
|
153
|
+
childModelDisposable = this.setupChildSessionBubbling(newSession.model as MutableChatModel, ctx.request.session, ctx.response);
|
|
151
154
|
} catch (sessionError) {
|
|
152
155
|
const errorMsg = `Failed to create chat session for agent '${agentId}': ${sessionError instanceof Error ? sessionError.message : sessionError}`;
|
|
153
156
|
console.error(errorMsg, sessionError);
|
|
@@ -209,7 +212,8 @@ export class AgentDelegationTool implements ToolProvider {
|
|
|
209
212
|
const result = await response.responseCompleted;
|
|
210
213
|
const stringResult = result.response.asString();
|
|
211
214
|
|
|
212
|
-
// Clean up the session
|
|
215
|
+
// Clean up the session and parent-child link after completion
|
|
216
|
+
childModelDisposable?.dispose();
|
|
213
217
|
const chatService = this.getChatService();
|
|
214
218
|
chatService.deleteSession(newSession.id).catch(error => {
|
|
215
219
|
console.error('Failed to delete delegated session', error);
|
|
@@ -242,33 +246,40 @@ export class AgentDelegationTool implements ToolProvider {
|
|
|
242
246
|
}
|
|
243
247
|
|
|
244
248
|
/**
|
|
245
|
-
* Sets up
|
|
246
|
-
*
|
|
247
|
-
*
|
|
249
|
+
* Sets up all event bubbling from a delegated child session to the parent session:
|
|
250
|
+
* - Interaction forwarding: child interactionNeeded events are forwarded to the parent response
|
|
251
|
+
* - ChangeSet bubbling: child changeset changes are forwarded to the parent model
|
|
248
252
|
*/
|
|
249
|
-
private
|
|
250
|
-
|
|
251
|
-
parentModel: MutableChatModel
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
private setupChildSessionBubbling(
|
|
254
|
+
childModel: MutableChatModel,
|
|
255
|
+
parentModel: MutableChatModel,
|
|
256
|
+
parentResponse: MutableChatResponseModel
|
|
257
|
+
): Disposable {
|
|
258
|
+
|
|
259
|
+
// Forward interactionNeeded events to the parent response model
|
|
260
|
+
// so the UI (which subscribes to response.onInteractionNeeded) can display them.
|
|
261
|
+
// Also watch for each forwarded interaction's resolution to trigger cleanup.
|
|
262
|
+
const eventForwarding = childModel.onDidChange(event => {
|
|
263
|
+
if (ChatChangeEvent.isInteractionNeededEvent(event)) {
|
|
264
|
+
parentResponse.fireInteractionNeeded(event.contentPart);
|
|
265
|
+
event.contentPart.whenResolved.then(() => parentResponse.notifyChanged());
|
|
266
|
+
}
|
|
256
267
|
});
|
|
257
|
-
}
|
|
258
268
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
269
|
+
// Bubble ChangeSet changes to the parent model
|
|
270
|
+
const changeSetForwarding = childModel.changeSet.onDidChange(() => {
|
|
271
|
+
const delegatedElements = childModel.changeSet.getElements();
|
|
272
|
+
if (delegatedElements.length > 0) {
|
|
273
|
+
parentModel.changeSet.setTitle(childModel.changeSet.title);
|
|
274
|
+
parentModel.changeSet.addElements(...delegatedElements);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
dispose: () => {
|
|
280
|
+
eventForwarding.dispose();
|
|
281
|
+
changeSetForwarding.dispose();
|
|
282
|
+
}
|
|
283
|
+
};
|
|
273
284
|
}
|
|
274
285
|
}
|
|
@@ -16,15 +16,24 @@
|
|
|
16
16
|
|
|
17
17
|
import { expect } from 'chai';
|
|
18
18
|
import * as sinon from 'sinon';
|
|
19
|
+
import { Container } from '@theia/core/shared/inversify';
|
|
19
20
|
import { ToolConfirmationManager } from './chat-tool-preference-bindings';
|
|
20
|
-
import { ToolConfirmationMode
|
|
21
|
+
import { ToolConfirmationMode } from '../common/chat-tool-preferences';
|
|
21
22
|
import { ToolRequest } from '@theia/ai-core';
|
|
22
23
|
import { PreferenceService } from '@theia/core/lib/common/preferences';
|
|
24
|
+
import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader';
|
|
23
25
|
|
|
24
26
|
describe('ToolConfirmationManager', () => {
|
|
25
27
|
let manager: ToolConfirmationManager;
|
|
26
28
|
let preferenceServiceMock: sinon.SinonStubbedInstance<PreferenceService>;
|
|
29
|
+
let trustAwareReaderMock: sinon.SinonStubbedInstance<TrustAwarePreferenceReader>;
|
|
27
30
|
let storedPreferences: { [toolId: string]: ToolConfirmationMode };
|
|
31
|
+
let trusted: boolean;
|
|
32
|
+
let inspectResult: {
|
|
33
|
+
defaultValue?: { [toolId: string]: ToolConfirmationMode };
|
|
34
|
+
globalValue?: { [toolId: string]: ToolConfirmationMode };
|
|
35
|
+
workspaceValue?: { [toolId: string]: ToolConfirmationMode };
|
|
36
|
+
} | undefined;
|
|
28
37
|
|
|
29
38
|
const createToolRequest = (id: string, confirmAlwaysAllow?: boolean | string): ToolRequest => ({
|
|
30
39
|
id,
|
|
@@ -34,26 +43,35 @@ describe('ToolConfirmationManager', () => {
|
|
|
34
43
|
confirmAlwaysAllow
|
|
35
44
|
});
|
|
36
45
|
|
|
37
|
-
const getPreferencesMock = (): ChatToolPreferences => ({
|
|
38
|
-
get [TOOL_CONFIRMATION_PREFERENCE](): { [toolId: string]: ToolConfirmationMode } {
|
|
39
|
-
return storedPreferences;
|
|
40
|
-
}
|
|
41
|
-
}) as unknown as ChatToolPreferences;
|
|
42
|
-
|
|
43
46
|
beforeEach(() => {
|
|
44
47
|
storedPreferences = {};
|
|
48
|
+
trusted = true;
|
|
49
|
+
inspectResult = undefined;
|
|
45
50
|
|
|
46
51
|
preferenceServiceMock = {
|
|
47
52
|
updateValue: sinon.stub().callsFake((_key: string, value: { [toolId: string]: ToolConfirmationMode }) => {
|
|
48
53
|
storedPreferences = value;
|
|
49
54
|
return Promise.resolve();
|
|
50
55
|
}),
|
|
51
|
-
inspect: sinon.stub().
|
|
56
|
+
inspect: sinon.stub().callsFake(() => inspectResult)
|
|
52
57
|
} as unknown as sinon.SinonStubbedInstance<PreferenceService>;
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
trustAwareReaderMock = {
|
|
60
|
+
get: sinon.stub().callsFake(<T>(_name: string, fallback?: T): T | undefined => {
|
|
61
|
+
if (trusted) {
|
|
62
|
+
return (storedPreferences as unknown as T) ?? fallback;
|
|
63
|
+
}
|
|
64
|
+
const insp = inspectResult;
|
|
65
|
+
const value = insp?.globalValue ?? insp?.defaultValue;
|
|
66
|
+
return ((value as unknown as T) ?? fallback);
|
|
67
|
+
})
|
|
68
|
+
} as unknown as sinon.SinonStubbedInstance<TrustAwarePreferenceReader>;
|
|
69
|
+
|
|
70
|
+
const container = new Container();
|
|
71
|
+
container.bind(ToolConfirmationManager).toSelf().inSingletonScope();
|
|
72
|
+
container.bind(PreferenceService).toConstantValue(preferenceServiceMock as unknown as PreferenceService);
|
|
73
|
+
container.bind(TrustAwarePreferenceReader).toConstantValue(trustAwareReaderMock as unknown as TrustAwarePreferenceReader);
|
|
74
|
+
manager = container.get(ToolConfirmationManager);
|
|
57
75
|
});
|
|
58
76
|
|
|
59
77
|
describe('getConfirmationMode', () => {
|
|
@@ -95,6 +113,48 @@ describe('ToolConfirmationManager', () => {
|
|
|
95
113
|
});
|
|
96
114
|
});
|
|
97
115
|
|
|
116
|
+
describe('workspace trust', () => {
|
|
117
|
+
it('ignores workspace {"*": "always_allow"} when workspace is untrusted', () => {
|
|
118
|
+
trusted = false;
|
|
119
|
+
// Simulate the effective (workspace-merged) preference containing always_allow,
|
|
120
|
+
// while the user/global scope is not set and the schema default prescribes
|
|
121
|
+
// CONFIRM. If the workspace override were honoured the mode would be
|
|
122
|
+
// ALWAYS_ALLOW; because trust filters the workspace scope out, the default
|
|
123
|
+
// (CONFIRM) wins, which demonstrably proves the override was dropped.
|
|
124
|
+
storedPreferences['*'] = ToolConfirmationMode.ALWAYS_ALLOW;
|
|
125
|
+
inspectResult = {
|
|
126
|
+
defaultValue: { '*': ToolConfirmationMode.CONFIRM },
|
|
127
|
+
workspaceValue: { '*': ToolConfirmationMode.ALWAYS_ALLOW }
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const mode = manager.getConfirmationMode('someTool', 'chat-1');
|
|
131
|
+
expect(mode).to.equal(ToolConfirmationMode.CONFIRM);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('blocks confirmAlwaysAllow bypass via workspace {"*": "always_allow"} when untrusted', () => {
|
|
135
|
+
trusted = false;
|
|
136
|
+
storedPreferences['*'] = ToolConfirmationMode.ALWAYS_ALLOW;
|
|
137
|
+
inspectResult = {
|
|
138
|
+
defaultValue: {},
|
|
139
|
+
workspaceValue: { '*': ToolConfirmationMode.ALWAYS_ALLOW }
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const toolRequest = createToolRequest('dangerousTool', true);
|
|
143
|
+
const mode = manager.getConfirmationMode('dangerousTool', 'chat-1', toolRequest);
|
|
144
|
+
// Without the workspace value the map is empty, so the default for a
|
|
145
|
+
// confirmAlwaysAllow tool (CONFIRM) is used.
|
|
146
|
+
expect(mode).to.equal(ToolConfirmationMode.CONFIRM);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('honours workspace {"*": "always_allow"} when workspace is trusted', () => {
|
|
150
|
+
trusted = true;
|
|
151
|
+
storedPreferences['*'] = ToolConfirmationMode.ALWAYS_ALLOW;
|
|
152
|
+
|
|
153
|
+
const mode = manager.getConfirmationMode('regularTool', 'chat-1');
|
|
154
|
+
expect(mode).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
98
158
|
describe('setConfirmationMode', () => {
|
|
99
159
|
it('should persist ALWAYS_ALLOW for regular tools when different from default', () => {
|
|
100
160
|
storedPreferences['*'] = ToolConfirmationMode.CONFIRM;
|
|
@@ -116,34 +176,34 @@ describe('ToolConfirmationManager', () => {
|
|
|
116
176
|
});
|
|
117
177
|
|
|
118
178
|
it('should not persist when mode matches the global preference default', () => {
|
|
119
|
-
|
|
179
|
+
inspectResult = {
|
|
120
180
|
defaultValue: { '*': ToolConfirmationMode.CONFIRM }
|
|
121
|
-
}
|
|
181
|
+
};
|
|
122
182
|
manager.setConfirmationMode('regularTool', ToolConfirmationMode.CONFIRM);
|
|
123
183
|
expect(preferenceServiceMock.updateValue.called).to.be.false;
|
|
124
184
|
});
|
|
125
185
|
|
|
126
186
|
it('should persist when mode differs from the global preference default', () => {
|
|
127
|
-
|
|
187
|
+
inspectResult = {
|
|
128
188
|
defaultValue: { '*': ToolConfirmationMode.CONFIRM }
|
|
129
|
-
}
|
|
189
|
+
};
|
|
130
190
|
manager.setConfirmationMode('regularTool', ToolConfirmationMode.DISABLED);
|
|
131
191
|
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
|
132
192
|
expect(storedPreferences['regularTool']).to.equal(ToolConfirmationMode.DISABLED);
|
|
133
193
|
});
|
|
134
194
|
|
|
135
195
|
it('should not persist when mode matches the tool-specific preference default', () => {
|
|
136
|
-
|
|
196
|
+
inspectResult = {
|
|
137
197
|
defaultValue: { 'myTool': ToolConfirmationMode.DISABLED }
|
|
138
|
-
}
|
|
198
|
+
};
|
|
139
199
|
manager.setConfirmationMode('myTool', ToolConfirmationMode.DISABLED);
|
|
140
200
|
expect(preferenceServiceMock.updateValue.called).to.be.false;
|
|
141
201
|
});
|
|
142
202
|
|
|
143
203
|
it('should remove entry when mode matches the tool-specific preference default and entry exists', () => {
|
|
144
|
-
|
|
204
|
+
inspectResult = {
|
|
145
205
|
defaultValue: { 'myTool': ToolConfirmationMode.DISABLED }
|
|
146
|
-
}
|
|
206
|
+
};
|
|
147
207
|
storedPreferences['myTool'] = ToolConfirmationMode.ALWAYS_ALLOW;
|
|
148
208
|
manager.setConfirmationMode('myTool', ToolConfirmationMode.DISABLED);
|
|
149
209
|
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
|
@@ -151,18 +211,18 @@ describe('ToolConfirmationManager', () => {
|
|
|
151
211
|
});
|
|
152
212
|
|
|
153
213
|
it('should not persist CONFIRM for confirmAlwaysAllow tool when global preference default is CONFIRM', () => {
|
|
154
|
-
|
|
214
|
+
inspectResult = {
|
|
155
215
|
defaultValue: { '*': ToolConfirmationMode.CONFIRM }
|
|
156
|
-
}
|
|
216
|
+
};
|
|
157
217
|
const toolRequest = createToolRequest('dangerousTool', true);
|
|
158
218
|
manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.CONFIRM, toolRequest);
|
|
159
219
|
expect(preferenceServiceMock.updateValue.called).to.be.false;
|
|
160
220
|
});
|
|
161
221
|
|
|
162
222
|
it('should persist ALWAYS_ALLOW for confirmAlwaysAllow tool when global preference default is CONFIRM', () => {
|
|
163
|
-
|
|
223
|
+
inspectResult = {
|
|
164
224
|
defaultValue: { '*': ToolConfirmationMode.CONFIRM }
|
|
165
|
-
}
|
|
225
|
+
};
|
|
166
226
|
const toolRequest = createToolRequest('dangerousTool', true);
|
|
167
227
|
manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.ALWAYS_ALLOW, toolRequest);
|
|
168
228
|
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
|
@@ -18,20 +18,21 @@ import { injectable, inject } from '@theia/core/shared/inversify';
|
|
|
18
18
|
import {
|
|
19
19
|
PreferenceService,
|
|
20
20
|
} from '@theia/core/lib/common/preferences';
|
|
21
|
-
import { ToolConfirmationMode, TOOL_CONFIRMATION_PREFERENCE
|
|
21
|
+
import { ToolConfirmationMode, TOOL_CONFIRMATION_PREFERENCE } from '../common/chat-tool-preferences';
|
|
22
22
|
import { ToolRequest } from '@theia/ai-core';
|
|
23
|
+
import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Utility class to manage tool confirmation settings
|
|
26
27
|
*/
|
|
27
28
|
@injectable()
|
|
28
29
|
export class ToolConfirmationManager {
|
|
29
|
-
@inject(ChatToolPreferences)
|
|
30
|
-
protected readonly preferences: ChatToolPreferences;
|
|
31
|
-
|
|
32
30
|
@inject(PreferenceService)
|
|
33
31
|
protected readonly preferenceService: PreferenceService;
|
|
34
32
|
|
|
33
|
+
@inject(TrustAwarePreferenceReader)
|
|
34
|
+
protected readonly trustAwareReader: TrustAwarePreferenceReader;
|
|
35
|
+
|
|
35
36
|
// In-memory session overrides (not persisted), per chat
|
|
36
37
|
protected sessionOverrides: Map<string, Map<string, ToolConfirmationMode>> = new Map();
|
|
37
38
|
|
|
@@ -51,7 +52,9 @@ export class ToolConfirmationManager {
|
|
|
51
52
|
if (chatMap && chatMap.has(toolId)) {
|
|
52
53
|
return chatMap.get(toolId)!;
|
|
53
54
|
}
|
|
54
|
-
const toolConfirmation = this.
|
|
55
|
+
const toolConfirmation = this.trustAwareReader.get<Record<string, ToolConfirmationMode>>(
|
|
56
|
+
TOOL_CONFIRMATION_PREFERENCE, {}
|
|
57
|
+
) ?? {};
|
|
55
58
|
if (toolConfirmation[toolId]) {
|
|
56
59
|
return toolConfirmation[toolId];
|
|
57
60
|
}
|
|
@@ -80,7 +83,9 @@ export class ToolConfirmationManager {
|
|
|
80
83
|
const defaultPref = this.preferenceService.inspect(TOOL_CONFIRMATION_PREFERENCE)?.defaultValue as {
|
|
81
84
|
[toolId: string]: ToolConfirmationMode;
|
|
82
85
|
} || {};
|
|
83
|
-
const current = this.
|
|
86
|
+
const current = this.trustAwareReader.get<Record<string, ToolConfirmationMode>>(
|
|
87
|
+
TOOL_CONFIRMATION_PREFERENCE, {}
|
|
88
|
+
) ?? {};
|
|
84
89
|
let starMode = current['*'];
|
|
85
90
|
if (starMode === undefined) {
|
|
86
91
|
starMode = defaultPref['*'] ?? ToolConfirmationMode.ALWAYS_ALLOW;
|
|
@@ -127,11 +132,15 @@ export class ToolConfirmationManager {
|
|
|
127
132
|
* Get all tool confirmation settings
|
|
128
133
|
*/
|
|
129
134
|
getAllConfirmationSettings(): { [toolId: string]: ToolConfirmationMode } {
|
|
130
|
-
return this.
|
|
135
|
+
return this.trustAwareReader.get<Record<string, ToolConfirmationMode>>(
|
|
136
|
+
TOOL_CONFIRMATION_PREFERENCE, {}
|
|
137
|
+
) ?? {};
|
|
131
138
|
}
|
|
132
139
|
|
|
133
140
|
resetAllConfirmationModeSettings(): void {
|
|
134
|
-
const current = this.
|
|
141
|
+
const current = this.trustAwareReader.get<Record<string, ToolConfirmationMode>>(
|
|
142
|
+
TOOL_CONFIRMATION_PREFERENCE, {}
|
|
143
|
+
) ?? {};
|
|
135
144
|
if ('*' in current) {
|
|
136
145
|
this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, { '*': current['*'] });
|
|
137
146
|
} else {
|
|
@@ -39,12 +39,12 @@ export class FrontendChatToolRequestService extends ChatToolRequestService {
|
|
|
39
39
|
protected readonly preferences: ChatToolPreferences;
|
|
40
40
|
|
|
41
41
|
protected override toChatToolRequest(toolRequest: ToolRequest, request: MutableChatRequestModel): ToolRequest {
|
|
42
|
-
const confirmationMode = this.confirmationManager.getConfirmationMode(toolRequest.id, request.session.id, toolRequest);
|
|
43
|
-
|
|
44
42
|
return {
|
|
45
43
|
...toolRequest,
|
|
46
44
|
handler: async (arg_string: string, ctx?: ToolInvocationContext) => {
|
|
47
45
|
const toolCallId = ctx?.toolCallId;
|
|
46
|
+
const sessionId = request.session.rootSessionId ?? request.session.id;
|
|
47
|
+
const confirmationMode = this.confirmationManager.getConfirmationMode(toolRequest.id, sessionId, toolRequest);
|
|
48
48
|
|
|
49
49
|
switch (confirmationMode) {
|
|
50
50
|
case ToolConfirmationMode.DISABLED:
|
|
@@ -84,6 +84,7 @@ export class FrontendChatToolRequestService extends ChatToolRequestService {
|
|
|
84
84
|
: this.preferences[TOOL_CONFIRMATION_TIMEOUT_PREFERENCE];
|
|
85
85
|
toolCallContent.confirmationTimeout = timeoutSeconds;
|
|
86
86
|
toolCallContent.requestUserConfirmation();
|
|
87
|
+
request.response.fireInteractionNeeded(toolCallContent);
|
|
87
88
|
confirmed = await raceConfirmationWithTimeout(toolCallContent, timeoutSeconds);
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -0,0 +1,124 @@
|
|
|
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 'reflect-metadata';
|
|
18
|
+
|
|
19
|
+
import { expect } from 'chai';
|
|
20
|
+
import { LanguageModelMessage, LanguageModelRequirement } from '@theia/ai-core';
|
|
21
|
+
import { AbstractChatAgent, ChatAgentLocation } from './chat-agents';
|
|
22
|
+
import {
|
|
23
|
+
MutableChatModel,
|
|
24
|
+
MutableChatRequestModel,
|
|
25
|
+
ChatModel,
|
|
26
|
+
TextChatResponseContentImpl,
|
|
27
|
+
ThinkingChatResponseContentImpl,
|
|
28
|
+
} from './chat-model';
|
|
29
|
+
import { ParsedChatRequest, ParsedChatRequestTextPart } from './parsed-chat-request';
|
|
30
|
+
|
|
31
|
+
class TestChatAgent extends AbstractChatAgent {
|
|
32
|
+
readonly id = 'test-agent';
|
|
33
|
+
readonly name = 'Test Agent';
|
|
34
|
+
readonly languageModelRequirements: LanguageModelRequirement[] = [];
|
|
35
|
+
protected readonly defaultLanguageModelPurpose = 'chat';
|
|
36
|
+
|
|
37
|
+
protected addContentsToResponse(): Promise<void> {
|
|
38
|
+
return Promise.resolve();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public async exposeGetMessages(model: ChatModel, includeResponseInProgress = false): Promise<LanguageModelMessage[]> {
|
|
42
|
+
return this.getMessages(model, includeResponseInProgress);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createParsedRequest(text: string): ParsedChatRequest {
|
|
47
|
+
return {
|
|
48
|
+
request: { text },
|
|
49
|
+
parts: [
|
|
50
|
+
new ParsedChatRequestTextPart({ start: 0, endExclusive: text.length }, text)
|
|
51
|
+
],
|
|
52
|
+
toolRequests: new Map(),
|
|
53
|
+
variables: []
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('AbstractChatAgent.getMessages', () => {
|
|
58
|
+
|
|
59
|
+
let agent: TestChatAgent;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
agent = new TestChatAgent();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function addThinkingResponse(request: MutableChatRequestModel, content: string, signature: string): void {
|
|
66
|
+
request.response.response.addContent(new ThinkingChatResponseContentImpl(content, signature));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function addTextResponse(request: MutableChatRequestModel, text: string): void {
|
|
70
|
+
request.response.response.addContent(new TextChatResponseContentImpl(text));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it('filters out incomplete thinking blocks (empty signature) from a cancelled stream', async () => {
|
|
74
|
+
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
|
75
|
+
const request = model.addRequest(createParsedRequest('Hello'));
|
|
76
|
+
|
|
77
|
+
addThinkingResponse(request, 'Some thinking that was cancelled', '');
|
|
78
|
+
request.response.cancel();
|
|
79
|
+
|
|
80
|
+
const messages = await agent.exposeGetMessages(model);
|
|
81
|
+
|
|
82
|
+
expect(messages.filter(LanguageModelMessage.isThinkingMessage)).to.have.lengthOf(0);
|
|
83
|
+
// The user text message should still be included
|
|
84
|
+
const userTextMessages = messages
|
|
85
|
+
.filter(LanguageModelMessage.isTextMessage)
|
|
86
|
+
.filter(m => m.actor === 'user');
|
|
87
|
+
expect(userTextMessages).to.have.lengthOf(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('keeps thinking blocks with a valid signature', async () => {
|
|
91
|
+
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
|
92
|
+
const request = model.addRequest(createParsedRequest('Hello'));
|
|
93
|
+
|
|
94
|
+
addThinkingResponse(request, 'Complete thought', 'sig-abc');
|
|
95
|
+
addTextResponse(request, 'Hi there');
|
|
96
|
+
request.response.complete();
|
|
97
|
+
|
|
98
|
+
const messages = await agent.exposeGetMessages(model);
|
|
99
|
+
|
|
100
|
+
const thinkingMessages = messages.filter(LanguageModelMessage.isThinkingMessage);
|
|
101
|
+
expect(thinkingMessages).to.have.lengthOf(1);
|
|
102
|
+
expect(thinkingMessages[0].thinking).to.equal('Complete thought');
|
|
103
|
+
expect(thinkingMessages[0].signature).to.equal('sig-abc');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('filters incomplete thinking but preserves following text content from the same response', async () => {
|
|
107
|
+
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
|
108
|
+
const request = model.addRequest(createParsedRequest('Hello'));
|
|
109
|
+
|
|
110
|
+
addThinkingResponse(request, 'Cancelled thought', '');
|
|
111
|
+
addTextResponse(request, 'Partial reply before cancel');
|
|
112
|
+
request.response.cancel();
|
|
113
|
+
|
|
114
|
+
const messages = await agent.exposeGetMessages(model);
|
|
115
|
+
|
|
116
|
+
expect(messages.filter(LanguageModelMessage.isThinkingMessage)).to.have.lengthOf(0);
|
|
117
|
+
|
|
118
|
+
const aiTextMessages = messages
|
|
119
|
+
.filter(LanguageModelMessage.isTextMessage)
|
|
120
|
+
.filter(m => m.actor === 'ai');
|
|
121
|
+
expect(aiTextMessages).to.have.lengthOf(1);
|
|
122
|
+
expect(aiTextMessages[0].text).to.equal('Partial reply before cancel');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
isToolCallResponsePart,
|
|
34
34
|
isUsageResponsePart,
|
|
35
35
|
LanguageModel,
|
|
36
|
+
TokenUsageService,
|
|
37
|
+
UsageResponsePart,
|
|
36
38
|
LanguageModelMessage,
|
|
37
39
|
LanguageModelRequirement,
|
|
38
40
|
LanguageModelResponse,
|
|
@@ -47,13 +49,14 @@ import {
|
|
|
47
49
|
} from '@theia/ai-core';
|
|
48
50
|
import {
|
|
49
51
|
Agent,
|
|
52
|
+
isLanguageModelParsedResponse,
|
|
50
53
|
isLanguageModelStreamResponse,
|
|
51
54
|
isLanguageModelTextResponse,
|
|
52
55
|
LanguageModelRegistry,
|
|
53
56
|
LanguageModelStreamResponsePart
|
|
54
57
|
} from '@theia/ai-core/lib/common';
|
|
55
58
|
import { ContributionProvider, ILogger, isArray, nls } from '@theia/core';
|
|
56
|
-
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
|
|
59
|
+
import { inject, injectable, named, optional, postConstruct } from '@theia/core/shared/inversify';
|
|
57
60
|
import { ChatAgentService } from './chat-agent-service';
|
|
58
61
|
import {
|
|
59
62
|
ChatModel,
|
|
@@ -68,6 +71,8 @@ import {
|
|
|
68
71
|
ToolCallArgumentsDeltaContent,
|
|
69
72
|
ErrorChatResponseContent,
|
|
70
73
|
InformationalChatResponseContent,
|
|
74
|
+
ResponseTokenUsage,
|
|
75
|
+
ThinkingChatResponseContent,
|
|
71
76
|
} from './chat-model';
|
|
72
77
|
import { ChatToolRequestService } from './chat-tool-request-service';
|
|
73
78
|
import { parseContents } from './parse-contents';
|
|
@@ -179,6 +184,8 @@ export abstract class AbstractChatAgent implements ChatAgent {
|
|
|
179
184
|
@inject(DefaultResponseContentFactory)
|
|
180
185
|
protected defaultContentFactory: DefaultResponseContentFactory;
|
|
181
186
|
|
|
187
|
+
@inject(TokenUsageService) @optional() protected tokenUsageService: TokenUsageService | undefined;
|
|
188
|
+
|
|
182
189
|
readonly abstract id: string;
|
|
183
190
|
readonly abstract name: string;
|
|
184
191
|
readonly abstract languageModelRequirements: LanguageModelRequirement[];
|
|
@@ -259,6 +266,7 @@ export abstract class AbstractChatAgent implements ChatAgent {
|
|
|
259
266
|
);
|
|
260
267
|
|
|
261
268
|
await this.addContentsToResponse(languageModelResponse, request);
|
|
269
|
+
await this.recordTokenUsageFromResponse(request, languageModel);
|
|
262
270
|
await this.onResponseComplete(request);
|
|
263
271
|
|
|
264
272
|
} catch (e) {
|
|
@@ -418,6 +426,12 @@ export abstract class AbstractChatAgent implements ChatAgent {
|
|
|
418
426
|
if (ErrorChatResponseContent.is(c) || InformationalChatResponseContent.is(c)) {
|
|
419
427
|
return false;
|
|
420
428
|
}
|
|
429
|
+
// skip incomplete thinking blocks (e.g. from a cancelled stream where the
|
|
430
|
+
// signature_delta never arrived). Some LLMs (e.g. Anthropic) reject thinking
|
|
431
|
+
// blocks without a signature.
|
|
432
|
+
if (ThinkingChatResponseContent.is(c) && !c.signature) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
421
435
|
// content even has an own converter, definitely include it
|
|
422
436
|
if (ChatResponseContent.hasToLanguageModelMessage(c)) {
|
|
423
437
|
return true;
|
|
@@ -498,7 +512,7 @@ export abstract class AbstractChatAgent implements ChatAgent {
|
|
|
498
512
|
messages,
|
|
499
513
|
tools,
|
|
500
514
|
settings,
|
|
501
|
-
|
|
515
|
+
reasoning: commonSettings?.reasoning,
|
|
502
516
|
agentId: this.id,
|
|
503
517
|
sessionId: request.session.id,
|
|
504
518
|
requestId: request.id,
|
|
@@ -526,6 +540,34 @@ export abstract class AbstractChatAgent implements ChatAgent {
|
|
|
526
540
|
return request.response.complete();
|
|
527
541
|
}
|
|
528
542
|
|
|
543
|
+
protected mapUsageResponsePart(usage: UsageResponsePart): ResponseTokenUsage {
|
|
544
|
+
return {
|
|
545
|
+
inputTokens: usage.input_tokens,
|
|
546
|
+
outputTokens: usage.output_tokens,
|
|
547
|
+
cacheCreationInputTokens: usage.cache_creation_input_tokens,
|
|
548
|
+
cacheReadInputTokens: usage.cache_read_input_tokens,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
protected async recordTokenUsageFromResponse(request: MutableChatRequestModel, languageModel: LanguageModel): Promise<void> {
|
|
553
|
+
if (!this.tokenUsageService) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const entries = request.response.tokenUsageEntries;
|
|
557
|
+
if (entries.length === 0) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
for (const entry of entries) {
|
|
561
|
+
await this.tokenUsageService.recordTokenUsage(languageModel.id, {
|
|
562
|
+
inputTokens: entry.inputTokens,
|
|
563
|
+
outputTokens: entry.outputTokens,
|
|
564
|
+
cachedInputTokens: entry.cacheCreationInputTokens,
|
|
565
|
+
readCachedInputTokens: entry.cacheReadInputTokens,
|
|
566
|
+
requestId: request.id,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
529
571
|
protected abstract addContentsToResponse(languageModelResponse: LanguageModelResponse, request: MutableChatRequestModel): Promise<void>;
|
|
530
572
|
}
|
|
531
573
|
|
|
@@ -537,6 +579,9 @@ export abstract class AbstractTextToModelParsingChatAgent<T> extends AbstractCha
|
|
|
537
579
|
const parsedCommand = await this.parseTextResponse(responseAsText);
|
|
538
580
|
const content = this.createResponseContent(parsedCommand, request);
|
|
539
581
|
request.response.response.addContent(content);
|
|
582
|
+
if ('usage' in languageModelResponse && languageModelResponse.usage) {
|
|
583
|
+
request.response.setTokenUsage(this.mapUsageResponsePart(languageModelResponse.usage));
|
|
584
|
+
}
|
|
540
585
|
}
|
|
541
586
|
|
|
542
587
|
protected abstract parseTextResponse(text: string): Promise<T>;
|
|
@@ -581,6 +626,17 @@ export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent {
|
|
|
581
626
|
if (isLanguageModelTextResponse(languageModelResponse)) {
|
|
582
627
|
const contents = this.parseContents(languageModelResponse.text, request);
|
|
583
628
|
request.response.response.addContents(contents);
|
|
629
|
+
if (languageModelResponse.usage) {
|
|
630
|
+
request.response.setTokenUsage(this.mapUsageResponsePart(languageModelResponse.usage));
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (isLanguageModelParsedResponse(languageModelResponse)) {
|
|
635
|
+
const contents = this.parseContents(languageModelResponse.content, request);
|
|
636
|
+
request.response.response.addContents(contents);
|
|
637
|
+
if (languageModelResponse.usage) {
|
|
638
|
+
request.response.setTokenUsage(this.mapUsageResponsePart(languageModelResponse.usage));
|
|
639
|
+
}
|
|
584
640
|
return;
|
|
585
641
|
}
|
|
586
642
|
if (isLanguageModelStreamResponse(languageModelResponse)) {
|
|
@@ -655,6 +711,7 @@ export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent {
|
|
|
655
711
|
return new ThinkingChatResponseContentImpl(token.thought, token.signature);
|
|
656
712
|
}
|
|
657
713
|
if (isUsageResponsePart(token)) {
|
|
714
|
+
request.response.setTokenUsage(this.mapUsageResponsePart(token));
|
|
658
715
|
return [];
|
|
659
716
|
}
|
|
660
717
|
return this.defaultContentFactory.create('', request);
|