@adminforth/agent 1.34.2 → 1.36.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/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type {
2
2
  AdminUser,
3
3
  AdminForthResource,
4
+ HttpExtra,
4
5
  IAdminForth,
5
- IHttpServer
6
+ IHttpServer,
7
+ TextToSpeechInput,
6
8
  } from "adminforth";
7
9
 
8
10
  import { AdminForthPlugin, logger, Filters, Sorts } from "adminforth";
@@ -33,16 +35,36 @@ import {
33
35
  } from "./agent/systemPrompt.js";
34
36
  import { ALWAYS_AVAILABLE_API_TOOL_NAMES } from "./agent/tools/index.js";
35
37
  import type { ToolCallEvent } from "./agent/toolCallEvents.js";
38
+ import type { CurrentPageContext } from "./agent/tools/getUserLocation.js";
36
39
 
37
40
  type CurrentPageRequestBody = {
38
41
  currentPage?: CurrentPageContext;
39
42
  };
40
43
 
41
- type CurrentPageContext = {
42
- path: string;
43
- fullPath: string;
44
- title: string;
45
- url: string;
44
+ type SpeechResponseRequestBody = CurrentPageRequestBody & {
45
+ audioBase64: string;
46
+ filename: string;
47
+ mimeType: string;
48
+ prompt?: string;
49
+ sessionId?: string | null;
50
+ mode?: string | null;
51
+ timeZone?: string;
52
+ tts?: Omit<TextToSpeechInput, "text">;
53
+ };
54
+
55
+ type AgentTurnRunInput = {
56
+ prompt: string;
57
+ sessionId: string;
58
+ turnId: string;
59
+ modeName?: string | null;
60
+ userTimeZone: string;
61
+ currentPage?: CurrentPageContext;
62
+ adminUser: AdminUser;
63
+ httpExtra: HttpExtra;
64
+ sequenceDebugCollector: ReturnType<typeof createSequenceDebugCollector>;
65
+ emitReasoningDelta?: (delta: string) => void;
66
+ emitTextDelta?: (delta: string) => void;
67
+ emitToolCallEvent?: (event: ToolCallEvent) => void;
46
68
  };
47
69
 
48
70
  function isAggregateErrorLike(
@@ -73,6 +95,24 @@ function formatAgentError(error: unknown) {
73
95
  return String(error);
74
96
  }
75
97
 
98
+ function formatAgentResponseError(error: unknown): string {
99
+ if (isAggregateErrorLike(error)) {
100
+ const nestedErrors = error.errors.map(formatAgentResponseError);
101
+
102
+ if (nestedErrors.length) {
103
+ return nestedErrors.join("\n");
104
+ }
105
+
106
+ return error.message || "Agent response failed";
107
+ }
108
+
109
+ if (error instanceof Error) {
110
+ return error.toString();
111
+ }
112
+
113
+ return String(error);
114
+ }
115
+
76
116
  function formatAdminUserPrompt(adminUser: AdminUser, usernameField: string) {
77
117
  const dbUser = adminUser.dbUser as Record<string, unknown>;
78
118
  const adminUserContext = {
@@ -87,18 +127,6 @@ function formatAdminUserPrompt(adminUser: AdminUser, usernameField: string) {
87
127
  ].join("\n");
88
128
  }
89
129
 
90
- function formatCurrentPagePrompt(currentPage: CurrentPageContext | undefined) {
91
- if (!currentPage) {
92
- return null;
93
- }
94
-
95
- return [
96
- "Current user page context for the latest message:",
97
- JSON.stringify(currentPage, null, 2),
98
- "When the user says here, this page, current page, or opened page, treat it as this page.",
99
- ].join("\n");
100
- }
101
-
102
130
  function assertRequiredApiTool(
103
131
  apiBasedTools: Record<string, ApiBasedTool>,
104
132
  toolName: string,
@@ -245,6 +273,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
245
273
  modes: this.options.modes.map((mode) => ({ name: mode.name })),
246
274
  defaultModeName: this.options.modes[0].name,
247
275
  stickByDefault: this.options.stickByDefault ?? false,
276
+ hasAudioAdapter: Boolean(this.options.audioAdapter),
248
277
  }
249
278
  });
250
279
  if (!this.pluginOptions.sessionResource) {
@@ -253,6 +282,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
253
282
  }
254
283
 
255
284
  validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
285
+ this.options.audioAdapter?.validate();
256
286
  this.agentSystemPromptPromise = buildAgentSystemPrompt(
257
287
  adminforth,
258
288
  this.getInternalAgentResourceIds(),
@@ -264,6 +294,101 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
264
294
  return `single`;
265
295
  }
266
296
 
297
+ private async runAgentTurn(input: AgentTurnRunInput) {
298
+ let fullResponse = "";
299
+ const maxTokens = this.options.maxTokens ?? 10000;
300
+ const selectedMode =
301
+ this.options.modes.find((mode) => mode.name === input.modeName) ??
302
+ this.options.modes[0];
303
+ const { model, summaryModel, modelMiddleware } =
304
+ await this.getModeModels(selectedMode, maxTokens);
305
+ const userLanguage = await detectUserLanguage(selectedMode.completionAdapter, input.prompt)
306
+ .catch((error) => {
307
+ logger.warn(`Failed to detect user language: ${error instanceof Error ? error.message : String(error)}`);
308
+ return null;
309
+ });
310
+ const systemPrompt = [
311
+ await this.agentSystemPromptPromise,
312
+ formatAdminUserPrompt(input.adminUser, this.adminforth.config.auth.usernameField),
313
+ formatLanguagePrompt(userLanguage),
314
+ ].join("\n\n");
315
+ const apiBasedTools = buildApiBasedTools(
316
+ this.adminforth,
317
+ this.getInternalAgentResourceIds(),
318
+ );
319
+ for (const toolName of ALWAYS_AVAILABLE_API_TOOL_NAMES) {
320
+ assertRequiredApiTool(apiBasedTools, toolName);
321
+ }
322
+ assertRequiredApiTool(apiBasedTools, "update_record");
323
+ this.apiBasedTools = apiBasedTools;
324
+ const stream = await callAgent({
325
+ name: `adminforth-agent-${this.pluginInstanceId}`,
326
+ model,
327
+ summaryModel,
328
+ modelMiddleware,
329
+ checkpointer: this.getCheckpointer(),
330
+ messages: [
331
+ new SystemMessage(systemPrompt),
332
+ new HumanMessage(input.prompt),
333
+ ],
334
+ adminUser: input.adminUser,
335
+ adminforth: this.adminforth,
336
+ apiBasedTools,
337
+ customComponentsDir: this.adminforth.config.customization.customComponentsDir,
338
+ sessionId: input.sessionId,
339
+ turnId: input.turnId,
340
+ currentPage: input.currentPage,
341
+ httpExtra: input.httpExtra,
342
+ userTimeZone: input.userTimeZone,
343
+ emitToolCallEvent: (event) => {
344
+ input.sequenceDebugCollector.handleToolCallEvent(event);
345
+ input.emitToolCallEvent?.(event);
346
+ },
347
+ sequenceDebugSink: input.sequenceDebugCollector,
348
+ });
349
+
350
+ for await (const rawChunk of stream as AsyncIterable<[any, any]>) {
351
+ const [token, metadata] = rawChunk;
352
+
353
+ const nodeName =
354
+ typeof metadata?.langgraph_node === "string"
355
+ ? metadata.langgraph_node
356
+ : "";
357
+
358
+ if (nodeName && !["model", "model_request"].includes(nodeName)) {
359
+ continue;
360
+ }
361
+
362
+ const blocks = Array.isArray(token?.contentBlocks)
363
+ ? token.contentBlocks
364
+ : Array.isArray(token?.content)
365
+ ? token.content
366
+ : [];
367
+ const reasoningDelta = blocks
368
+ .filter((b: any) => b?.type === "reasoning")
369
+ .map((b: any) => String(b.reasoning ?? ""))
370
+ .join("");
371
+
372
+ const textDelta = blocks
373
+ .filter((b: any) => b?.type === "text")
374
+ .map((b: any) => String(b.text ?? ""))
375
+ .join("");
376
+
377
+ if (reasoningDelta) {
378
+ input.emitReasoningDelta?.(reasoningDelta);
379
+ }
380
+
381
+ if (textDelta) {
382
+ fullResponse += textDelta;
383
+ input.emitTextDelta?.(textDelta);
384
+ }
385
+ }
386
+
387
+ return {
388
+ text: fullResponse,
389
+ };
390
+ }
391
+
267
392
  setupEndpoints(server: IHttpServer) {
268
393
  server.endpoint({
269
394
  method: 'POST',
@@ -295,7 +420,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
295
420
  server.endpoint({
296
421
  method: 'POST',
297
422
  path: `/agent/response`,
298
- handler: async ({ body, query, headers, cookies, adminUser, response, requestUrl, _raw_express_res }) => {
423
+ handler: async ({ body, query, headers, cookies, adminUser, response, requestUrl, _raw_express_res, abortSignal }) => {
299
424
  const res = _raw_express_res;
300
425
  const messageId = randomUUID();
301
426
  const prompt = body.message;
@@ -327,8 +452,6 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
327
452
  endActiveBlock();
328
453
  }
329
454
 
330
- sequenceDebugCollector.handleToolCallEvent(event);
331
-
332
455
  send({
333
456
  type: "data-tool-call",
334
457
  data: event,
@@ -389,47 +512,14 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
389
512
  messageId,
390
513
  });
391
514
 
392
- const maxTokens = this.options.maxTokens ?? 10000;
393
- const selectedMode = this.options.modes.find((mode) => mode.name === body.mode) ?? this.options.modes[0];
394
- const { model, summaryModel, modelMiddleware } =
395
- await this.getModeModels(selectedMode, maxTokens);
396
- const userLanguage = await detectUserLanguage(selectedMode.completionAdapter, prompt)
397
- .catch((error) => {
398
- logger.warn(`Failed to detect user language: ${error instanceof Error ? error.message : String(error)}`);
399
- return null;
400
- });
401
- const systemPrompt = [
402
- await this.agentSystemPromptPromise,
403
- formatAdminUserPrompt(adminUser, this.adminforth.config.auth.usernameField),
404
- formatLanguagePrompt(userLanguage),
405
- ].join("\n\n");
406
- const apiBasedTools = buildApiBasedTools(
407
- this.adminforth,
408
- this.getInternalAgentResourceIds(),
409
- );
410
- for (const toolName of ALWAYS_AVAILABLE_API_TOOL_NAMES) {
411
- assertRequiredApiTool(apiBasedTools, toolName);
412
- }
413
- assertRequiredApiTool(apiBasedTools, "update_record");
414
- this.apiBasedTools = apiBasedTools;
415
- const currentPagePrompt = formatCurrentPagePrompt(currentPage);
416
- const stream = await callAgent({
417
- name: `adminforth-agent-${this.pluginInstanceId}`,
418
- model,
419
- summaryModel,
420
- modelMiddleware,
421
- checkpointer: this.getCheckpointer(),
422
- messages: [
423
- new SystemMessage(systemPrompt),
424
- ...(currentPagePrompt ? [new SystemMessage(currentPagePrompt)] : []),
425
- new HumanMessage(prompt),
426
- ],
427
- adminUser,
428
- adminforth: this.adminforth,
429
- apiBasedTools,
430
- customComponentsDir: this.adminforth.config.customization.customComponentsDir,
515
+ const agentResponse = await this.runAgentTurn({
516
+ prompt,
431
517
  sessionId,
432
518
  turnId,
519
+ modeName: body.mode,
520
+ userTimeZone,
521
+ currentPage,
522
+ adminUser,
433
523
  httpExtra: {
434
524
  body,
435
525
  query,
@@ -438,48 +528,17 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
438
528
  requestUrl,
439
529
  response,
440
530
  },
441
- userTimeZone,
531
+ sequenceDebugCollector,
442
532
  emitToolCallEvent,
443
- sequenceDebugSink: sequenceDebugCollector,
444
- });
445
-
446
- for await (const rawChunk of stream as AsyncIterable<[any, any]>) {
447
- const [token, metadata] = rawChunk;
448
-
449
- const nodeName =
450
- typeof metadata?.langgraph_node === "string"
451
- ? metadata.langgraph_node
452
- : "";
453
-
454
- if (nodeName && !["model", "model_request"].includes(nodeName)) {
455
- continue;
456
- }
457
-
458
- const blocks = Array.isArray(token?.contentBlocks)
459
- ? token.contentBlocks
460
- : Array.isArray(token?.content)
461
- ? token.content
462
- : [];
463
- const reasoningDelta = blocks
464
- .filter((b: any) => b?.type === "reasoning")
465
- .map((b: any) => String(b.reasoning ?? ""))
466
- .join("");
467
-
468
- const textDelta = blocks
469
- .filter((b: any) => b?.type === "text")
470
- .map((b: any) => String(b.text ?? ""))
471
- .join("");
472
-
473
- if (reasoningDelta) {
533
+ emitReasoningDelta: (reasoningDelta) => {
474
534
  const reasoningId = startBlock('reasoning');
475
- send({
476
- type: 'reasoning-delta',
477
- id: reasoningId,
478
- delta: reasoningDelta,
479
- });
480
- }
481
-
482
- if (textDelta) {
535
+ send({
536
+ type: 'reasoning-delta',
537
+ id: reasoningId,
538
+ delta: reasoningDelta,
539
+ });
540
+ },
541
+ emitTextDelta: (textDelta) => {
483
542
  const textId = startBlock('text');
484
543
  fullResponse += textDelta;
485
544
  send({
@@ -487,16 +546,18 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
487
546
  id: textId,
488
547
  delta: textDelta,
489
548
  });
490
- }
491
- }
549
+ },
550
+ });
551
+ fullResponse = agentResponse.text;
492
552
  } catch (error) {
493
553
  logger.error(`Agent response streaming failed:\n${formatAgentError(error)}`);
494
554
  sequenceDebugCollector.flush();
555
+ fullResponse = formatAgentResponseError(error);
495
556
  const textId = startBlock('text');
496
557
  send({
497
558
  type: 'text-delta',
498
559
  id: textId,
499
- delta: 'Agent response failed. Check server logs for details.',
560
+ delta: fullResponse,
500
561
  });
501
562
  }
502
563
  sequenceDebugCollector.flush();
@@ -513,6 +574,125 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
513
574
  return null;
514
575
  }
515
576
  });
577
+ server.endpoint({
578
+ method: 'POST',
579
+ path: `/agent/speech-response`,
580
+ handler: async ({ body, query, headers, cookies, adminUser, response, requestUrl }) => {
581
+ const audioAdapter = this.options.audioAdapter;
582
+ if (!audioAdapter) {
583
+ response.setStatus(400, undefined);
584
+ return {
585
+ error: "Audio adapter is not configured for AdminForth Agent",
586
+ };
587
+ }
588
+
589
+ const speechBody = body as SpeechResponseRequestBody;
590
+ let transcription;
591
+
592
+ try {
593
+ transcription = await audioAdapter.transcribe({
594
+ buffer: Buffer.from(speechBody.audioBase64, "base64"),
595
+ filename: speechBody.filename,
596
+ mimeType: speechBody.mimeType,
597
+ language: "auto",
598
+ prompt: speechBody.prompt,
599
+ });
600
+ } catch (error) {
601
+ logger.error(`Agent speech transcription failed:\n${formatAgentError(error)}`);
602
+ response.setStatus(500, undefined);
603
+ return {
604
+ error: "Speech transcription failed. Check server logs for details.",
605
+ };
606
+ }
607
+
608
+ const prompt = transcription.text;
609
+ if (!prompt) {
610
+ response.setStatus(400, undefined);
611
+ return {
612
+ error: "Speech transcription is empty",
613
+ };
614
+ }
615
+
616
+ const sessionId = speechBody.sessionId || adminUser?.pk || adminUser?.username || 'default';
617
+ const turnId = await this.createNewTurn(sessionId, prompt);
618
+ await this.updateSessionDate(sessionId);
619
+ const sequenceDebugCollector = createSequenceDebugCollector();
620
+ let fullResponse = "";
621
+
622
+ try {
623
+ const agentResponse = await this.runAgentTurn({
624
+ prompt,
625
+ sessionId,
626
+ turnId,
627
+ modeName: speechBody.mode,
628
+ userTimeZone: speechBody.timeZone ?? 'UTC',
629
+ currentPage: speechBody.currentPage,
630
+ adminUser,
631
+ httpExtra: {
632
+ body,
633
+ query,
634
+ headers,
635
+ cookies,
636
+ requestUrl,
637
+ response,
638
+ },
639
+ sequenceDebugCollector,
640
+ emitTextDelta: (textDelta) => {
641
+ fullResponse += textDelta;
642
+ },
643
+ });
644
+ fullResponse = agentResponse.text;
645
+ const speech = await audioAdapter.synthesize({
646
+ text: fullResponse,
647
+ ...speechBody.tts,
648
+ });
649
+ sequenceDebugCollector.flush();
650
+ const turnUpdates: Record<string, unknown> = {
651
+ [this.options.turnResource.responseField]: fullResponse,
652
+ };
653
+
654
+ if (this.options.turnResource.debugField) {
655
+ turnUpdates[this.options.turnResource.debugField] = sequenceDebugCollector.getHistory();
656
+ }
657
+
658
+ await this.updateTurn(turnId, turnUpdates);
659
+
660
+ return {
661
+ transcript: {
662
+ text: transcription.text,
663
+ language: transcription.language,
664
+ },
665
+ response: {
666
+ text: fullResponse,
667
+ },
668
+ audio: {
669
+ base64: speech.audio.toString("base64"),
670
+ mimeType: speech.mimeType,
671
+ format: speech.format,
672
+ },
673
+ sessionId,
674
+ turnId,
675
+ };
676
+ } catch (error) {
677
+ logger.error(`Agent speech response failed:\n${formatAgentError(error)}`);
678
+ sequenceDebugCollector.flush();
679
+ fullResponse = formatAgentResponseError(error);
680
+ const turnUpdates: Record<string, unknown> = {
681
+ [this.options.turnResource.responseField]: fullResponse,
682
+ };
683
+
684
+ if (this.options.turnResource.debugField) {
685
+ turnUpdates[this.options.turnResource.debugField] = sequenceDebugCollector.getHistory();
686
+ }
687
+
688
+ await this.updateTurn(turnId, turnUpdates);
689
+ response.setStatus(500, undefined);
690
+ return {
691
+ error: fullResponse,
692
+ };
693
+ }
694
+ }
695
+ });
516
696
  server.endpoint({
517
697
  method: 'POST',
518
698
  path: `/agent/get-sessions`,
@@ -630,6 +810,18 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
630
810
  ok: true
631
811
  };
632
812
  }
813
+ }),
814
+ server.endpoint({
815
+ method: 'POST',
816
+ path: `/agent/add-system-message-to-turns`,
817
+ handler: async ({body, adminUser, _raw_express_req }) => {
818
+ const sessionId = body.sessionId;
819
+ const systemMessage = body.systemMessage;
820
+ await this.createNewTurn(sessionId, systemMessage);
821
+ return {
822
+ ok: true
823
+ }
824
+ }
633
825
  });
634
826
  }
635
827
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/agent",
3
- "version": "1.34.2",
3
+ "version": "1.36.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "@langchain/core": "^1.1.40",
34
34
  "@langchain/langgraph": "^1.2.8",
35
35
  "@langchain/langgraph-checkpoint": "^1.0.1",
36
- "adminforth": "2.42.0",
36
+ "adminforth": "2.51.0",
37
37
  "dayjs": "^1.11.20",
38
38
  "langchain": "^1.3.3",
39
39
  "yaml": "^2.8.3",
package/types.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  type PluginsCommonOptions,
3
3
  type AdminUser,
4
4
  type HttpExtra,
5
+ type AudioAdapter,
5
6
  } from "adminforth";
6
7
  import type { AgentModeCompletionAdapter } from "./agent/simpleAgent.js";
7
8
 
@@ -61,6 +62,11 @@ export interface PluginOptions extends PluginsCommonOptions {
61
62
  completionAdapter: AgentModeCompletionAdapter;
62
63
  }[];
63
64
 
65
+ /**
66
+ * Optional audio adapter for speech-to-text and text-to-speech flows.
67
+ */
68
+ audioAdapter?: AudioAdapter;
69
+
64
70
  /**
65
71
  * Max tokens for the generation.
66
72
  * Default is 1000