@lumenflow/surfaces 5.1.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/LICENSE +190 -0
- package/README.md +40 -0
- package/cli/__tests__/gates.test.ts +97 -0
- package/cli/__tests__/inspect.test.ts +184 -0
- package/cli/__tests__/task-lifecycle.test.ts +203 -0
- package/cli/gates.ts +46 -0
- package/cli/index.ts +6 -0
- package/cli/inspect.ts +138 -0
- package/cli/task-lifecycle.ts +46 -0
- package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
- package/http/__tests__/auth-boundary.test.ts +57 -0
- package/http/__tests__/channel-send-governance.test.ts +158 -0
- package/http/__tests__/event-stream.test.ts +340 -0
- package/http/__tests__/phone-device-tool-api.test.ts +177 -0
- package/http/__tests__/remote-exposure.test.ts +212 -0
- package/http/__tests__/run-agent.test.ts +447 -0
- package/http/__tests__/scope-enforcement.test.ts +349 -0
- package/http/__tests__/sidecar-entry.test.ts +158 -0
- package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
- package/http/__tests__/tool-api.test.ts +491 -0
- package/http/__tests__/tool-discovery.test.ts +384 -0
- package/http/ag-ui-adapter.ts +352 -0
- package/http/auth.ts +294 -0
- package/http/control-plane-event-subscriber.ts +233 -0
- package/http/event-stream.ts +216 -0
- package/http/index.ts +10 -0
- package/http/run-agent.ts +416 -0
- package/http/server.ts +329 -0
- package/http/sidecar-entry.ts +218 -0
- package/http/task-api.ts +307 -0
- package/http/tool-api.ts +373 -0
- package/http/tool-discovery.ts +159 -0
- package/mcp/__tests__/server.test.ts +554 -0
- package/mcp/index.ts +4 -0
- package/mcp/server.ts +250 -0
- package/package.json +51 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import {
|
|
6
|
+
KERNEL_EVENT_KINDS,
|
|
7
|
+
type KernelEvent,
|
|
8
|
+
type KernelRuntime,
|
|
9
|
+
type TaskSpec,
|
|
10
|
+
} from '@lumenflow/kernel';
|
|
11
|
+
import { AG_UI_EVENT_TYPES, mapKernelEventToAgUiEvent, type AgUiEvent } from './ag-ui-adapter.js';
|
|
12
|
+
import type { EventSubscriber } from './event-stream.js';
|
|
13
|
+
|
|
14
|
+
const HTTP_METHOD = {
|
|
15
|
+
POST: 'POST',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const HTTP_STATUS = {
|
|
19
|
+
OK: 200,
|
|
20
|
+
BAD_REQUEST: 400,
|
|
21
|
+
METHOD_NOT_ALLOWED: 405,
|
|
22
|
+
INTERNAL_SERVER_ERROR: 500,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
const HEADER = {
|
|
26
|
+
CACHE_CONTROL: 'cache-control',
|
|
27
|
+
CONNECTION: 'connection',
|
|
28
|
+
CONTENT_TYPE: 'content-type',
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
const HEADER_VALUE = {
|
|
32
|
+
CACHE_CONTROL: 'no-cache',
|
|
33
|
+
CONNECTION: 'keep-alive',
|
|
34
|
+
EVENT_STREAM: 'text/event-stream; charset=utf-8',
|
|
35
|
+
JSON: 'application/json; charset=utf-8',
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const JSON_RESPONSE_KEY_ERROR = 'error';
|
|
39
|
+
const JSON_RESPONSE_KEY_MESSAGE = 'message';
|
|
40
|
+
const JSON_LINE_SEPARATOR = '\n';
|
|
41
|
+
const UTF8_ENCODING = 'utf8';
|
|
42
|
+
const JSON_BODY_EMPTY = '';
|
|
43
|
+
|
|
44
|
+
const RUN_AGENT_DEFAULTS = {
|
|
45
|
+
LANE_ID: 'ag-ui',
|
|
46
|
+
DOMAIN: 'ag-ui',
|
|
47
|
+
RISK: 'low' as const,
|
|
48
|
+
TYPE: 'runtime' as const,
|
|
49
|
+
PRIORITY: 'P2' as const,
|
|
50
|
+
TITLE_PREFIX: 'AG-UI RunAgent: ',
|
|
51
|
+
BY_PREFIX: 'ag-ui-client',
|
|
52
|
+
SESSION_PREFIX: 'ag-ui-session-',
|
|
53
|
+
ACCEPTANCE_PREFIX: 'Process AG-UI request: ',
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
interface RunAgentMessage {
|
|
57
|
+
id: string;
|
|
58
|
+
role: string;
|
|
59
|
+
content: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RunAgentTool {
|
|
63
|
+
name: string;
|
|
64
|
+
description?: string;
|
|
65
|
+
parameters?: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface RunAgentContext {
|
|
69
|
+
name: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
value: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RunAgentInput {
|
|
75
|
+
threadId: string;
|
|
76
|
+
runId: string;
|
|
77
|
+
messages: RunAgentMessage[];
|
|
78
|
+
tools?: RunAgentTool[];
|
|
79
|
+
context?: RunAgentContext[];
|
|
80
|
+
state?: Record<string, unknown>;
|
|
81
|
+
forwardedProps?: Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* AG-UI RunAgent event extends the base AG-UI event with CopilotKit-compatible
|
|
86
|
+
* threadId and runId fields at the event level. These are separate from kernel
|
|
87
|
+
* task_id/run_id and map to the AG-UI client's thread/run identifiers.
|
|
88
|
+
*/
|
|
89
|
+
export interface RunAgentEvent extends AgUiEvent {
|
|
90
|
+
threadId: string;
|
|
91
|
+
runId: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface RunAgentRouter {
|
|
95
|
+
handleRequest(
|
|
96
|
+
request: IncomingMessage,
|
|
97
|
+
response: ServerResponse<IncomingMessage>,
|
|
98
|
+
): Promise<boolean>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface RunAgentConfig {
|
|
102
|
+
workspaceId: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class RunAgentValidationError extends Error {
|
|
106
|
+
readonly statusCode: number;
|
|
107
|
+
|
|
108
|
+
constructor(message: string, statusCode: number = HTTP_STATUS.BAD_REQUEST) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.statusCode = statusCode;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type JsonRecord = Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
function isJsonRecord(value: unknown): value is JsonRecord {
|
|
117
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function readRequestBody(request: IncomingMessage): Promise<string> {
|
|
121
|
+
let body = JSON_BODY_EMPTY;
|
|
122
|
+
for await (const chunk of request) {
|
|
123
|
+
body += Buffer.isBuffer(chunk) ? chunk.toString(UTF8_ENCODING) : String(chunk);
|
|
124
|
+
}
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readJsonRequestBody(request: IncomingMessage): Promise<unknown> {
|
|
129
|
+
const rawBody = await readRequestBody(request);
|
|
130
|
+
if (rawBody.trim().length === 0) {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(rawBody);
|
|
136
|
+
} catch {
|
|
137
|
+
throw new RunAgentValidationError('Request body must be valid JSON.');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateRunAgentInput(payload: unknown): RunAgentInput {
|
|
142
|
+
if (!isJsonRecord(payload)) {
|
|
143
|
+
throw new RunAgentValidationError('Request body must be a JSON object.');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { threadId, runId, messages, tools, context, state, forwardedProps } = payload;
|
|
147
|
+
|
|
148
|
+
if (typeof threadId !== 'string' || threadId.trim().length === 0) {
|
|
149
|
+
throw new RunAgentValidationError('threadId is required and must be a non-empty string.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (typeof runId !== 'string' || runId.trim().length === 0) {
|
|
153
|
+
throw new RunAgentValidationError('runId is required and must be a non-empty string.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!Array.isArray(messages)) {
|
|
157
|
+
throw new RunAgentValidationError('messages is required and must be an array.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const validatedMessages: RunAgentMessage[] = [];
|
|
161
|
+
for (const msg of messages) {
|
|
162
|
+
if (!isJsonRecord(msg)) {
|
|
163
|
+
throw new RunAgentValidationError('Each message must be a JSON object.');
|
|
164
|
+
}
|
|
165
|
+
validatedMessages.push({
|
|
166
|
+
id: typeof msg.id === 'string' ? msg.id : '',
|
|
167
|
+
role: typeof msg.role === 'string' ? msg.role : 'user',
|
|
168
|
+
content: typeof msg.content === 'string' ? msg.content : '',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const validatedTools: RunAgentTool[] = [];
|
|
173
|
+
if (tools !== undefined) {
|
|
174
|
+
if (!Array.isArray(tools)) {
|
|
175
|
+
throw new RunAgentValidationError('tools must be an array when provided.');
|
|
176
|
+
}
|
|
177
|
+
for (const tool of tools) {
|
|
178
|
+
if (!isJsonRecord(tool)) {
|
|
179
|
+
throw new RunAgentValidationError('Each tool must be a JSON object.');
|
|
180
|
+
}
|
|
181
|
+
validatedTools.push({
|
|
182
|
+
name: typeof tool.name === 'string' ? tool.name : '',
|
|
183
|
+
description: typeof tool.description === 'string' ? tool.description : undefined,
|
|
184
|
+
parameters: isJsonRecord(tool.parameters) ? tool.parameters : undefined,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const validatedContext: RunAgentContext[] = [];
|
|
190
|
+
if (context !== undefined) {
|
|
191
|
+
if (!Array.isArray(context)) {
|
|
192
|
+
throw new RunAgentValidationError('context must be an array when provided.');
|
|
193
|
+
}
|
|
194
|
+
for (const ctx of context) {
|
|
195
|
+
if (!isJsonRecord(ctx)) {
|
|
196
|
+
throw new RunAgentValidationError('Each context entry must be a JSON object.');
|
|
197
|
+
}
|
|
198
|
+
validatedContext.push({
|
|
199
|
+
name: typeof ctx.name === 'string' ? ctx.name : '',
|
|
200
|
+
description: typeof ctx.description === 'string' ? ctx.description : undefined,
|
|
201
|
+
value: ctx.value,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
threadId,
|
|
208
|
+
runId,
|
|
209
|
+
messages: validatedMessages,
|
|
210
|
+
tools: validatedTools.length > 0 ? validatedTools : undefined,
|
|
211
|
+
context: validatedContext.length > 0 ? validatedContext : undefined,
|
|
212
|
+
state: isJsonRecord(state) ? state : undefined,
|
|
213
|
+
forwardedProps: isJsonRecord(forwardedProps) ? forwardedProps : undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildTaskTitle(input: RunAgentInput): string {
|
|
218
|
+
const lastMessage = input.messages[input.messages.length - 1];
|
|
219
|
+
const snippet = lastMessage?.content.slice(0, 80) ?? input.runId;
|
|
220
|
+
return `${RUN_AGENT_DEFAULTS.TITLE_PREFIX}${snippet}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildTaskDescription(input: RunAgentInput): string {
|
|
224
|
+
return input.messages.map((msg) => `[${msg.role}] ${msg.content}`).join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildDeclaredScopes(_input: RunAgentInput): TaskSpec['declared_scopes'] {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildAcceptance(input: RunAgentInput): string[] {
|
|
232
|
+
const lastMessage = input.messages[input.messages.length - 1];
|
|
233
|
+
const snippet = lastMessage?.content.slice(0, 120) ?? input.runId;
|
|
234
|
+
return [`${RUN_AGENT_DEFAULTS.ACCEPTANCE_PREFIX}${snippet}`];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildTaskSpec(input: RunAgentInput, taskId: string, config: RunAgentConfig): TaskSpec {
|
|
238
|
+
return {
|
|
239
|
+
id: taskId,
|
|
240
|
+
workspace_id: config.workspaceId,
|
|
241
|
+
lane_id: RUN_AGENT_DEFAULTS.LANE_ID,
|
|
242
|
+
domain: RUN_AGENT_DEFAULTS.DOMAIN,
|
|
243
|
+
title: buildTaskTitle(input),
|
|
244
|
+
description: buildTaskDescription(input),
|
|
245
|
+
acceptance: buildAcceptance(input),
|
|
246
|
+
declared_scopes: buildDeclaredScopes(input),
|
|
247
|
+
risk: RUN_AGENT_DEFAULTS.RISK,
|
|
248
|
+
type: RUN_AGENT_DEFAULTS.TYPE,
|
|
249
|
+
priority: RUN_AGENT_DEFAULTS.PRIORITY,
|
|
250
|
+
created: new Date().toISOString().split('T')[0] ?? '',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function generateTaskId(input: RunAgentInput): string {
|
|
255
|
+
return `ag-ui-${input.threadId}-${Date.now()}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function writeJsonError(
|
|
259
|
+
response: ServerResponse<IncomingMessage>,
|
|
260
|
+
statusCode: number,
|
|
261
|
+
message: string,
|
|
262
|
+
): void {
|
|
263
|
+
response.statusCode = statusCode;
|
|
264
|
+
response.setHeader(HEADER.CONTENT_TYPE, HEADER_VALUE.JSON);
|
|
265
|
+
response.end(
|
|
266
|
+
JSON.stringify({
|
|
267
|
+
[JSON_RESPONSE_KEY_ERROR]: {
|
|
268
|
+
[JSON_RESPONSE_KEY_MESSAGE]: message,
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function writeEventStreamHeaders(response: ServerResponse<IncomingMessage>): void {
|
|
275
|
+
response.statusCode = HTTP_STATUS.OK;
|
|
276
|
+
response.setHeader(HEADER.CONTENT_TYPE, HEADER_VALUE.EVENT_STREAM);
|
|
277
|
+
response.setHeader(HEADER.CACHE_CONTROL, HEADER_VALUE.CACHE_CONTROL);
|
|
278
|
+
response.setHeader(HEADER.CONNECTION, HEADER_VALUE.CONNECTION);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function writeRunAgentEvent(response: ServerResponse<IncomingMessage>, event: RunAgentEvent): void {
|
|
282
|
+
const payload = `${JSON.stringify(event)}${JSON_LINE_SEPARATOR}`;
|
|
283
|
+
response.write(payload);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function createRunAgentEvent(
|
|
287
|
+
type: string,
|
|
288
|
+
input: RunAgentInput,
|
|
289
|
+
taskId: string,
|
|
290
|
+
kernelRunId: string | undefined,
|
|
291
|
+
extraPayload: Record<string, unknown> = {},
|
|
292
|
+
): RunAgentEvent {
|
|
293
|
+
return {
|
|
294
|
+
type,
|
|
295
|
+
timestamp: new Date().toISOString(),
|
|
296
|
+
threadId: input.threadId,
|
|
297
|
+
runId: input.runId,
|
|
298
|
+
task_id: taskId,
|
|
299
|
+
run_id: kernelRunId,
|
|
300
|
+
payload: extraPayload,
|
|
301
|
+
metadata: {
|
|
302
|
+
source: 'ag_ui_run_agent',
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isTaskCompletedEvent(event: KernelEvent): boolean {
|
|
308
|
+
return event.kind === KERNEL_EVENT_KINDS.TASK_COMPLETED;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function createRunAgentRouter(
|
|
312
|
+
runtime: KernelRuntime,
|
|
313
|
+
eventSubscriber?: EventSubscriber,
|
|
314
|
+
config?: RunAgentConfig,
|
|
315
|
+
): RunAgentRouter {
|
|
316
|
+
return {
|
|
317
|
+
async handleRequest(
|
|
318
|
+
request: IncomingMessage,
|
|
319
|
+
response: ServerResponse<IncomingMessage>,
|
|
320
|
+
): Promise<boolean> {
|
|
321
|
+
const method = request.method ?? '';
|
|
322
|
+
|
|
323
|
+
if (method !== HTTP_METHOD.POST) {
|
|
324
|
+
writeJsonError(response, HTTP_STATUS.METHOD_NOT_ALLOWED, `Unsupported method: ${method}`);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let input: RunAgentInput;
|
|
329
|
+
try {
|
|
330
|
+
const body = await readJsonRequestBody(request);
|
|
331
|
+
input = validateRunAgentInput(body);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (error instanceof RunAgentValidationError) {
|
|
334
|
+
writeJsonError(response, error.statusCode, error.message);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
writeJsonError(response, HTTP_STATUS.BAD_REQUEST, 'Invalid request body.');
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
writeEventStreamHeaders(response);
|
|
343
|
+
|
|
344
|
+
const resolvedConfig: RunAgentConfig = config ?? {
|
|
345
|
+
workspaceId: 'ag-ui',
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const taskId = generateTaskId(input);
|
|
349
|
+
const taskSpec = buildTaskSpec(input, taskId, resolvedConfig);
|
|
350
|
+
|
|
351
|
+
const createResult = await runtime.createTask(taskSpec);
|
|
352
|
+
const createdTaskId = createResult.task.id;
|
|
353
|
+
|
|
354
|
+
const claimResult = await runtime.claimTask({
|
|
355
|
+
task_id: createdTaskId,
|
|
356
|
+
by: `${RUN_AGENT_DEFAULTS.BY_PREFIX}`,
|
|
357
|
+
session_id: `${RUN_AGENT_DEFAULTS.SESSION_PREFIX}${input.threadId}`,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const runId = claimResult.run?.run_id;
|
|
361
|
+
|
|
362
|
+
const runStartedEvent = createRunAgentEvent(
|
|
363
|
+
AG_UI_EVENT_TYPES.RUN_STARTED,
|
|
364
|
+
input,
|
|
365
|
+
createdTaskId,
|
|
366
|
+
runId,
|
|
367
|
+
{
|
|
368
|
+
messages: input.messages,
|
|
369
|
+
tools: input.tools,
|
|
370
|
+
context: input.context,
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
writeRunAgentEvent(response, runStartedEvent);
|
|
374
|
+
|
|
375
|
+
if (eventSubscriber) {
|
|
376
|
+
eventSubscriber.subscribe({ taskId: createdTaskId }, (event: KernelEvent) => {
|
|
377
|
+
const agUiEvent = mapKernelEventToAgUiEvent(event);
|
|
378
|
+
const runAgentEvent: RunAgentEvent = {
|
|
379
|
+
...agUiEvent,
|
|
380
|
+
threadId: input.threadId,
|
|
381
|
+
runId: input.runId,
|
|
382
|
+
};
|
|
383
|
+
writeRunAgentEvent(response, runAgentEvent);
|
|
384
|
+
|
|
385
|
+
if (isTaskCompletedEvent(event)) {
|
|
386
|
+
const runCompletedEvent = createRunAgentEvent(
|
|
387
|
+
AG_UI_EVENT_TYPES.RUN_COMPLETED,
|
|
388
|
+
input,
|
|
389
|
+
createdTaskId,
|
|
390
|
+
runId,
|
|
391
|
+
);
|
|
392
|
+
writeRunAgentEvent(response, runCompletedEvent);
|
|
393
|
+
response.end();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
} else {
|
|
397
|
+
// Fallback: no event subscriber available, end immediately
|
|
398
|
+
const runCompletedEvent = createRunAgentEvent(
|
|
399
|
+
AG_UI_EVENT_TYPES.RUN_COMPLETED,
|
|
400
|
+
input,
|
|
401
|
+
createdTaskId,
|
|
402
|
+
runId,
|
|
403
|
+
);
|
|
404
|
+
writeRunAgentEvent(response, runCompletedEvent);
|
|
405
|
+
response.end();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return true;
|
|
409
|
+
} catch (error) {
|
|
410
|
+
const message = error instanceof Error ? error.message : 'RunAgent execution failed.';
|
|
411
|
+
writeJsonError(response, HTTP_STATUS.INTERNAL_SERVER_ERROR, message);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|