Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { CancellationToken } from '../../../../base/common/cancellation.js';
7
import { Codicon } from '../../../../base/common/codicons.js';
8
import { Emitter, Event } from '../../../../base/common/event.js';
9
import { Disposable } from '../../../../base/common/lifecycle.js';
10
import { ResourceMap } from '../../../../base/common/map.js';
11
import { extname } from '../../../../base/common/path.js';
12
import { basename, joinPath } from '../../../../base/common/resources.js';
13
import { SKILL_FILENAME } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js';
14
import { PromptFileParser } from '../../../../workbench/contrib/chat/common/promptSyntax/promptFileParser.js';
15
import { ThemeIcon } from '../../../../base/common/themables.js';
16
import { URI } from '../../../../base/common/uri.js';
17
import { localize } from '../../../../nls.js';
18
import { AgentHostConfigKey, getAgentHostConfiguredCustomizations } from '../../../../platform/agentHost/common/agentHostCustomizationConfig.js';
19
import { agentHostUri } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js';
20
import { IFileService } from '../../../../platform/files/common/files.js';
21
import { ILogService } from '../../../../platform/log/common/log.js';
22
import { AGENT_HOST_SCHEME, fromAgentHostUri, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js';
23
import type { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js';
24
import { ActionType } from '../../../../platform/agentHost/common/state/sessionActions.js';
25
import { type AgentInfo, type CustomizationRef, type RootState, type SessionCustomization, CustomizationStatus } from '../../../../platform/agentHost/common/state/sessionState.js';
26
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
27
import { INotificationService } from '../../../../platform/notification/common/notification.js';
28
import { AICustomizationManagementSection, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
29
import { type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemAction, type ICustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js';
30
import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';
31
import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
32
import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js';
33
import { AgentCustomizationSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js';
34
import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../workbench/services/agentHost/common/agentHostFileSystemService.js';
35
36
export { AgentCustomizationSyncProvider as RemoteAgentSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js';
37
38
const REMOTE_HOST_GROUP = 'remote-host';
39
const REMOTE_CLIENT_GROUP = 'remote-client';
40
41
/**
42
* Returns `true` for the synthetic "VS Code Synced Data" bundle plugin,
43
* which is an implementation detail of the customization sync pipeline
44
* and should not be surfaced as a standalone item in the UI.
45
*/
46
function isSyntheticBundle(customization: CustomizationRef): boolean {
47
try {
48
return URI.parse(customization.uri).scheme === SYNCED_CUSTOMIZATION_SCHEME;
49
} catch {
50
return false;
51
}
52
}
53
54
/**
55
* Maps a plugin sub-directory name to the {@link PromptsType}
56
* its files represent. Returns `undefined` for unknown directories.
57
*/
58
function promptsTypeForPluginDir(dir: string): PromptsType | undefined {
59
switch (dir) {
60
case 'rules': return PromptsType.instructions;
61
case 'commands': return PromptsType.prompt;
62
case 'agents': return PromptsType.agent;
63
case 'skills': return PromptsType.skill;
64
default: return undefined;
65
}
66
}
67
68
/**
69
* Strips conventional prompt file extensions so we can show `foo`
70
* for `foo.prompt.md`, `foo.instructions.md`, etc.
71
*/
72
function stripPromptFileExtensions(filename: string): string {
73
const ext = extname(filename);
74
if (!ext) {
75
return filename;
76
}
77
const stem = filename.slice(0, -ext.length);
78
const dotInStem = stem.lastIndexOf('.');
79
return dotInStem > 0 ? stem.slice(0, dotInStem) : stem;
80
}
81
82
interface IExpandedPlugin {
83
readonly nonce: string | undefined;
84
readonly children: readonly ICustomizationItem[];
85
}
86
87
/**
88
* Maps a {@link CustomizationStatus} enum value to the string literal
89
* expected by {@link ICustomizationItem.status}.
90
*/
91
function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined {
92
switch (status) {
93
case CustomizationStatus.Loading: return 'loading';
94
case CustomizationStatus.Loaded: return 'loaded';
95
case CustomizationStatus.Degraded: return 'degraded';
96
case CustomizationStatus.Error: return 'error';
97
default: return undefined;
98
}
99
}
100
101
function customizationKey(customization: CustomizationRef): string {
102
return customization.uri;
103
}
104
105
function customizationItemKey(customization: CustomizationRef, clientId: string | undefined): string {
106
return clientId !== undefined
107
? `${customizationKey(customization)}::${clientId}`
108
: customizationKey(customization);
109
}
110
111
/**
112
* Owns the client-side UI commands for configuring plugins on a remote
113
* agent host. The actual source of truth lives in the host's root config.
114
*/
115
export class RemoteAgentPluginController extends Disposable {
116
readonly pluginActions: readonly ICustomizationItemAction[];
117
118
constructor(
119
private readonly _hostLabel: string,
120
private readonly _connectionAuthority: string,
121
private readonly _connection: IAgentConnection,
122
@IFileDialogService private readonly _fileDialogService: IFileDialogService,
123
@INotificationService private readonly _notificationService: INotificationService,
124
@IAICustomizationWorkspaceService _workspaceService: IAICustomizationWorkspaceService,
125
) {
126
super();
127
128
this.pluginActions = [
129
{
130
id: 'remoteAgentHost.addPlugin',
131
label: localize('remoteAgentHost.addPlugin', "Add Remote Plugin"),
132
tooltip: localize('remoteAgentHost.addPluginTooltip', "Add a plugin folder that already exists on this remote agent host."),
133
icon: Codicon.add,
134
run: () => this.addConfiguredPlugin(),
135
},
136
];
137
}
138
139
async removeConfiguredPlugin(customizationToRemove: CustomizationRef): Promise<void> {
140
const updated = this.getConfiguredCustomizations().filter(customization => customizationKey(customization) !== customizationKey(customizationToRemove));
141
this.dispatchCustomizations(updated);
142
}
143
144
private getConfiguredCustomizations(): readonly CustomizationRef[] {
145
const rootState = this._connection.rootState.value;
146
if (!rootState || rootState instanceof Error) {
147
return [];
148
}
149
150
return getAgentHostConfiguredCustomizations(rootState.config?.values);
151
}
152
153
private dispatchCustomizations(customizations: readonly CustomizationRef[]): void {
154
this._connection.dispatch({
155
type: ActionType.RootConfigChanged,
156
config: {
157
[AgentHostConfigKey.Customizations]: [...customizations],
158
},
159
});
160
}
161
162
private async pickRemotePluginFolder(title: string): Promise<URI | undefined> {
163
try {
164
const selected = await this._fileDialogService.showOpenDialog({
165
canSelectFiles: false,
166
canSelectFolders: true,
167
canSelectMany: false,
168
title,
169
availableFileSystems: [AGENT_HOST_SCHEME],
170
defaultUri: agentHostUri(this._connectionAuthority, '/'),
171
});
172
return selected?.[0];
173
} catch {
174
return undefined;
175
}
176
}
177
178
private async addConfiguredPlugin(): Promise<void> {
179
const selected = await this.pickRemotePluginFolder(localize('remoteAgentHost.selectPluginFolder', "Select Plugin Folder on {0}", this._hostLabel));
180
if (!selected) {
181
return;
182
}
183
184
const original = fromAgentHostUri(selected);
185
const newCustomization: CustomizationRef = {
186
uri: original.toString(),
187
displayName: basename(original) || original.path,
188
};
189
190
const current = this.getConfiguredCustomizations();
191
const nextKey = customizationKey(newCustomization);
192
if (current.some(customization => customizationKey(customization) === nextKey)) {
193
this._notificationService.info(localize(
194
'remoteAgentHost.pluginAlreadyConfigured',
195
"'{0}' is already configured on {1}.",
196
newCustomization.displayName,
197
this._hostLabel,
198
));
199
return;
200
}
201
202
this.dispatchCustomizations([...current, newCustomization]);
203
}
204
}
205
206
/**
207
* Provider that exposes a remote agent's configured plugins as
208
* {@link ICustomizationItem} entries for the plugin management widget.
209
*
210
* Each plugin is also **expanded** into its individual customization
211
* files (agents, skills, instructions, prompts) by reading the plugin
212
* directory through the agent-host filesystem provider. The expanded
213
* children appear in per-type sections (Skills, Agents, etc.) while
214
* the parent plugin item appears in the Plugins section.
215
*/
216
export class RemoteAgentCustomizationItemProvider extends Disposable implements ICustomizationItemProvider {
217
private readonly _onDidChange = this._register(new Emitter<void>());
218
readonly onDidChange: Event<void> = this._onDidChange.event;
219
220
private _agentCustomizations: readonly CustomizationRef[];
221
private _sessionCustomizations: readonly SessionCustomization[] | undefined;
222
223
/** Cache: pluginUri → last expansion (keyed by nonce so we re-fetch on content change). */
224
private readonly _expansionCache = new ResourceMap<IExpandedPlugin>();
225
226
constructor(
227
private readonly _agentInfo: AgentInfo,
228
private readonly _connection: IAgentConnection,
229
private readonly _connectionAuthority: string,
230
private readonly _controller: RemoteAgentPluginController,
231
private readonly _fileService: IFileService,
232
private readonly _logService: ILogService,
233
) {
234
super();
235
this._agentCustomizations = this._readRootCustomizations(this._connection.rootState.value) ?? _agentInfo.customizations ?? [];
236
237
this._register(this._connection.rootState.onDidChange(rootState => {
238
const next = this._readRootCustomizations(rootState) ?? this._readAgentCustomizations(rootState) ?? this._agentCustomizations;
239
if (next !== this._agentCustomizations) {
240
this._agentCustomizations = next;
241
this._onDidChange.fire();
242
}
243
}));
244
245
this._register(this._connection.onDidAction(envelope => {
246
if (envelope.action.type === ActionType.SessionCustomizationsChanged) {
247
const customizations = (envelope.action as { customizations?: SessionCustomization[] }).customizations;
248
if (customizations && customizations !== this._sessionCustomizations) {
249
this._sessionCustomizations = customizations;
250
this._onDidChange.fire();
251
}
252
}
253
}));
254
}
255
256
private _readRootCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined {
257
if (!rootState || rootState instanceof Error || !rootState.config) {
258
return undefined;
259
}
260
261
return getAgentHostConfiguredCustomizations(rootState.config?.values);
262
}
263
264
private _readAgentCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined {
265
if (!rootState || rootState instanceof Error) {
266
return undefined;
267
}
268
269
return rootState.agents.find(agent => agent.provider === this._agentInfo.provider)?.customizations;
270
}
271
272
private toRemoteUri(customization: CustomizationRef): URI {
273
const original = URI.parse(customization.uri);
274
// The synthetic synced-customization bundle lives in the client's
275
// in-memory filesystem. Don't wrap it as an agent-host:// URI —
276
// the server doesn't have this scheme registered, so wrapping it
277
// would make expansion (and any direct read) fail.
278
if (original.scheme === SYNCED_CUSTOMIZATION_SCHEME) {
279
return original;
280
}
281
return toAgentHostUri(original, this._connectionAuthority);
282
}
283
284
private toBadge(customization: CustomizationRef, clientId: string | undefined): { badge?: string; badgeTooltip?: string; groupKey?: string } {
285
if (clientId !== undefined) {
286
return {
287
groupKey: REMOTE_CLIENT_GROUP,
288
};
289
}
290
291
return {
292
groupKey: REMOTE_HOST_GROUP,
293
};
294
}
295
296
private toItem(customization: CustomizationRef, sessionCustomization: SessionCustomization | undefined): ICustomizationItem {
297
const clientId = sessionCustomization?.clientId;
298
const badge = this.toBadge(customization, clientId);
299
const actions = clientId !== undefined
300
? undefined
301
: <const>[{
302
id: 'remoteAgentHost.removeConfiguredPlugin',
303
label: localize('remoteAgentHost.removeConfiguredPlugin', "Remove from Remote Host"),
304
icon: Codicon.trash,
305
run: () => this._controller.removeConfiguredPlugin(customization),
306
}];
307
308
return {
309
itemKey: customizationItemKey(customization, clientId),
310
uri: this.toRemoteUri(customization),
311
type: 'plugin',
312
name: customization.displayName,
313
description: customization.description,
314
storage: PromptsStorage.plugin,
315
status: toStatusString(sessionCustomization?.status),
316
statusMessage: sessionCustomization?.statusMessage,
317
enabled: sessionCustomization?.enabled ?? true,
318
badge: badge.badge,
319
badgeTooltip: badge.badgeTooltip,
320
groupKey: badge.groupKey,
321
extensionId: undefined,
322
pluginUri: undefined,
323
userInvocable: undefined,
324
actions,
325
};
326
}
327
328
async provideChatSessionCustomizations(token: CancellationToken): Promise<ICustomizationItem[]> {
329
const items = new Map<string, ICustomizationItem>();
330
331
// Build parent plugin items keyed by customization ref
332
type PluginMeta = { item: ICustomizationItem; nonce: string | undefined; status: ReturnType<typeof toStatusString>; statusMessage: string | undefined; enabled: boolean | undefined; childGroupKey?: string };
333
const plugins: PluginMeta[] = [];
334
335
for (const customization of this._agentCustomizations) {
336
const item = this.toItem(customization, undefined);
337
items.set(customizationItemKey(customization, undefined), item);
338
plugins.push({ item, nonce: customization.nonce, status: undefined, statusMessage: undefined, enabled: undefined, childGroupKey: REMOTE_HOST_GROUP });
339
}
340
341
for (const sessionCustomization of this._sessionCustomizations ?? []) {
342
const isBundleItem = isSyntheticBundle(sessionCustomization.customization);
343
const isClientSynced = sessionCustomization.clientId !== undefined;
344
345
// Always show session customizations as distinct plugin entries —
346
// client-synced items appear in the "Local" group, host-owned in
347
// the "Remote" group. The synthetic bundle is an implementation
348
// detail and is not shown as a standalone entry, but is still
349
// expanded below so individual user files appear in per-type tabs.
350
if (!isBundleItem) {
351
const item = this.toItem(sessionCustomization.customization, sessionCustomization);
352
items.set(
353
customizationItemKey(sessionCustomization.customization, sessionCustomization.clientId),
354
item,
355
);
356
}
357
358
// Always expand plugin contents so individual files are visible.
359
const childGroupKey = isClientSynced ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP;
360
plugins.push({
361
item: isBundleItem
362
? { uri: this.toRemoteUri(sessionCustomization.customization), type: 'plugin', name: '', storage: PromptsStorage.plugin, groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }
363
: this.toItem(sessionCustomization.customization, sessionCustomization),
364
nonce: sessionCustomization.customization.nonce,
365
status: toStatusString(sessionCustomization.status),
366
statusMessage: sessionCustomization.statusMessage,
367
enabled: sessionCustomization.enabled,
368
childGroupKey,
369
});
370
}
371
372
// Expand each plugin directory in parallel to discover individual
373
// skills, agents, instructions, and prompts inside.
374
const expansions = await Promise.all(plugins.map(p => this._expandPluginContents(p.item.uri, p.nonce, p.childGroupKey ?? REMOTE_HOST_GROUP, token)));
375
if (token.isCancellationRequested) {
376
return [];
377
}
378
379
for (let i = 0; i < plugins.length; i++) {
380
const p = plugins[i];
381
for (const child of expansions[i]) {
382
// Children inherit the parent plugin's status/enabled state.
383
items.set(`${p.item.itemKey ?? p.item.uri.toString()}::${child.type}::${child.name}`, {
384
...child,
385
status: p.status,
386
statusMessage: p.statusMessage,
387
enabled: p.enabled,
388
});
389
}
390
}
391
392
return [...items.values()];
393
}
394
395
/**
396
* Reads a plugin's directory contents through the agent-host
397
* filesystem provider and returns one {@link ICustomizationItem} per
398
* supported file (agents/skills/instructions/prompts).
399
*
400
* Cached by `(uri, nonce)`; a different nonce invalidates the entry.
401
*/
402
private async _expandPluginContents(pluginUri: URI, nonce: string | undefined, groupKey: string, token: CancellationToken): Promise<readonly ICustomizationItem[]> {
403
const cached = this._expansionCache.get(pluginUri);
404
if (cached && cached.nonce === nonce) {
405
return cached.children;
406
}
407
408
// pluginUri is already an agent-host:// URI (from toRemoteUri),
409
// so use it directly as the filesystem root.
410
const fsRoot = pluginUri;
411
const children: ICustomizationItem[] = [];
412
try {
413
if (!await this._fileService.canHandleResource(fsRoot)) {
414
return [];
415
}
416
if (token.isCancellationRequested) {
417
return [];
418
}
419
420
const dirNames = ['agents', 'skills', 'commands', 'rules'] as const;
421
const subdirs = dirNames.map(name => ({ name, resource: URI.joinPath(fsRoot, name) }));
422
const stats = await this._fileService.resolveAll(subdirs.map(s => ({ resource: s.resource })));
423
424
if (token.isCancellationRequested) {
425
return [];
426
}
427
428
for (let i = 0; i < subdirs.length; i++) {
429
const stat = stats[i];
430
if (!stat.success || !stat.stat?.isDirectory || !stat.stat.children) {
431
continue;
432
}
433
const promptType = promptsTypeForPluginDir(subdirs[i].name);
434
if (!promptType) {
435
continue;
436
}
437
children.push(...await this._collectFromTypeDir(stat.stat.children, promptType, groupKey, token));
438
}
439
children.sort((a, b) => `${a.type}:${a.name}`.localeCompare(`${b.type}:${b.name}`));
440
} catch (err) {
441
this._logService.trace(`[RemoteAgentCustomizationItemProvider] Failed to expand plugin ${pluginUri.toString()}: ${err}`);
442
return [];
443
}
444
445
this._expansionCache.set(pluginUri, { nonce, children });
446
return children;
447
}
448
449
/**
450
* Emits one {@link ICustomizationItem} per child of a per-type
451
* sub-folder. Skills are conventionally folders containing
452
* `SKILL.md`, and synced bundles may preserve per-skill
453
* subdirectories; flat skill files can still appear for legacy
454
* bundles, so both layouts are accepted.
455
*
456
* For skills, the `SKILL.md` frontmatter is read so that the item's
457
* description (and a frontmatter-supplied name, when present) can be
458
* surfaced — without it the UI would only show the folder name with
459
* no description.
460
*/
461
private async _collectFromTypeDir(entries: readonly { name: string; resource: URI; isDirectory: boolean }[], promptType: PromptsType, groupKey: string, token: CancellationToken): Promise<ICustomizationItem[]> {
462
type Entry = { name: string; resource: URI; isDirectory: boolean };
463
const eligible: Entry[] = [];
464
for (const child of entries) {
465
// Skip dotfiles (e.g. .DS_Store)
466
if (child.name.startsWith('.')) {
467
continue;
468
}
469
if (promptType !== PromptsType.skill && child.isDirectory) {
470
continue;
471
}
472
eligible.push(child);
473
}
474
475
const skillMetadata = promptType === PromptsType.skill
476
? await Promise.all(eligible.map(child => this._readSkillMetadata(child, token)))
477
: undefined;
478
if (token.isCancellationRequested) {
479
return [];
480
}
481
482
const items: ICustomizationItem[] = [];
483
for (let i = 0; i < eligible.length; i++) {
484
const child = eligible[i];
485
let displayName: string;
486
let description: string | undefined;
487
let uri = child.resource;
488
if (promptType === PromptsType.skill) {
489
const meta = skillMetadata![i];
490
// For folder-style skills the canonical resource for the skill
491
// is its `SKILL.md`; downstream code (slash-command resolution,
492
// chat input decorations) calls `parseNew(item.uri)` and would
493
// otherwise try to read the directory as a file. If we couldn't
494
// read `SKILL.md`, skip the entry rather than emit a URI that
495
// will fail to parse downstream.
496
if (child.isDirectory) {
497
if (!meta) {
498
continue;
499
}
500
uri = joinPath(child.resource, SKILL_FILENAME);
501
}
502
const fallbackName = child.isDirectory ? child.name : stripPromptFileExtensions(child.name);
503
displayName = meta?.name ?? fallbackName;
504
description = meta?.description;
505
} else {
506
displayName = stripPromptFileExtensions(child.name);
507
}
508
items.push({
509
uri,
510
type: promptType,
511
name: displayName,
512
description,
513
storage: PromptsStorage.plugin,
514
groupKey,
515
extensionId: undefined,
516
pluginUri: undefined,
517
userInvocable: true
518
});
519
}
520
return items;
521
}
522
523
/**
524
* Reads `SKILL.md` for a skill entry and returns its frontmatter
525
* `name` / `description`. Returns `undefined` when the file cannot
526
* be read or parsed — the caller falls back to the folder name and
527
* leaves the description empty.
528
*/
529
private async _readSkillMetadata(entry: { name: string; resource: URI; isDirectory: boolean }, token: CancellationToken): Promise<{ name: string | undefined; description: string | undefined } | undefined> {
530
const skillFileUri = entry.isDirectory ? joinPath(entry.resource, SKILL_FILENAME) : entry.resource;
531
try {
532
const content = await this._fileService.readFile(skillFileUri);
533
if (token.isCancellationRequested) {
534
return undefined;
535
}
536
const parsed = new PromptFileParser().parse(skillFileUri, content.value.toString());
537
return { name: parsed.header?.name, description: parsed.header?.description };
538
} catch (err) {
539
this._logService.trace(`[RemoteAgentCustomizationItemProvider] Failed to read skill metadata ${skillFileUri.toString()}: ${err}`);
540
return undefined;
541
}
542
}
543
}
544
545
/**
546
* Creates a {@link IHarnessDescriptor} for a remote agent discovered via
547
* the agent host protocol.
548
*/
549
export function createRemoteAgentHarnessDescriptor(
550
harnessId: string,
551
displayName: string,
552
controller: RemoteAgentPluginController,
553
itemProvider: RemoteAgentCustomizationItemProvider,
554
syncProvider: AgentCustomizationSyncProvider,
555
): IHarnessDescriptor {
556
const allSources = [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, BUILTIN_STORAGE];
557
const filter: IStorageSourceFilter = { sources: allSources };
558
559
return {
560
id: harnessId,
561
label: displayName,
562
icon: ThemeIcon.fromId(Codicon.remote.id),
563
hiddenSections: [
564
AICustomizationManagementSection.Models,
565
AICustomizationManagementSection.McpServers,
566
],
567
hideGenerateButton: true,
568
getStorageSourceFilter(_type: PromptsType): IStorageSourceFilter {
569
return filter;
570
},
571
itemProvider,
572
syncProvider,
573
pluginActions: controller.pluginActions,
574
};
575
}
576
577