@adminforth/agent 1.23.0 → 1.24.1

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/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # AdminForth Agent Plugin
2
+
3
+ <img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /> <img src="https://woodpecker.devforth.io/api/badges/3848/status.svg" alt="Build Status" /> <a href="https://www.npmjs.com/package/@adminforth/agent"><img src="https://img.shields.io/npm/dm/@adminforth/agent" alt="npm downloads" /></a> <a href="https://www.npmjs.com/package/@adminforth/agent"><img src="https://img.shields.io/npm/v/@adminforth/agent" alt="npm version" /></a>
4
+
5
+ [![Ask AI](https://tluma.ai/badge)](https://tluma.ai/ask-ai/devforth/adminforth)
6
+
7
+ Adds a native AI agent to your AdminForth application.
8
+
9
+ ## Features
10
+
11
+ - Connect AdminForth with native AI agent workflows.
12
+ - Expose API-based tools to agent-driven tasks.
13
+ - Extend back-office operations with custom agent actions.
14
+ - Keep agent capabilities close to your admin logic.
15
+
16
+ ## Documentation
17
+
18
+ Full setup and configuration guide:
19
+
20
+ [AdminForth Agent Documentation](https://adminforth.dev/docs/tutorial/Plugins/agent/)
21
+
22
+ ## About AdminForth
23
+
24
+ AdminForth is an open-source, agent-first admin framework for building robust admin panels and back-office applications faster.
25
+
26
+ ## Related links
27
+
28
+ - [AdminForth website](https://adminforth.dev)
29
+ - [npm package](https://www.npmjs.com/package/@adminforth/agent)
30
+ - [More AdminForth plugins](https://adminforth.dev/docs/tutorial/ListOfPlugins/)
31
+ - [Built by DevForth](https://devforth.io)
@@ -1,7 +1,10 @@
1
1
  import { ToolMessage } from "@langchain/core/messages";
2
2
  import { createMiddleware } from "langchain";
3
- import { logger } from "adminforth";
4
- import { type ApiBasedTool } from "../../apiBasedTools.js";
3
+ import { logger, type AdminUser, type IAdminForth } from "adminforth";
4
+ import {
5
+ formatApiBasedToolCall,
6
+ type ApiBasedTool,
7
+ } from "../../apiBasedTools.js";
5
8
  import {
6
9
  createToolCallTracker,
7
10
  type ToolCallEventSink,
@@ -46,6 +49,7 @@ function getEnabledApiToolNames(messages: unknown[]) {
46
49
 
47
50
  export function createApiBasedToolsMiddleware(
48
51
  apiBasedTools: Record<string, ApiBasedTool>,
52
+ adminforth: IAdminForth,
49
53
  ) {
50
54
  const alwaysAvailableApiToolNames = new Set<string>(ALWAYS_AVAILABLE_API_TOOL_NAMES);
51
55
  const dynamicTools = Object.fromEntries(
@@ -71,14 +75,33 @@ export function createApiBasedToolsMiddleware(
71
75
  async wrapToolCall(request, handler) {
72
76
  const startedAt = Date.now();
73
77
  const toolInput = JSON.stringify(request.toolCall.args ?? {});
74
- const { emitToolCallEvent } = request.runtime.context as {
78
+ const { adminUser, emitToolCallEvent, userTimeZone } = request.runtime.context as {
79
+ adminUser: AdminUser;
75
80
  emitToolCallEvent: ToolCallEventSink;
81
+ userTimeZone: string;
76
82
  };
83
+ const toolArgs = (request.toolCall.args ?? {}) as Record<string, unknown>;
84
+ let toolInfo: string | undefined;
85
+
86
+ if (request.toolCall.name === "fetch_skill") {
87
+ toolInfo = `Load ${(toolArgs.skillName as string).split("_").join(" ")} skill`;
88
+ } else if (request.toolCall.name === "fetch_tool_schema") {
89
+ toolInfo = `Load ${(toolArgs.toolName as string).split("_").join(" ")} tool `;
90
+ } else {
91
+ toolInfo = await formatApiBasedToolCall({
92
+ adminforth,
93
+ adminUser,
94
+ inputs: toolArgs,
95
+ toolName: request.toolCall.name,
96
+ userTimeZone,
97
+ });
98
+ }
77
99
  const toolCallTracker = createToolCallTracker({
78
100
  emit: emitToolCallEvent,
79
101
  toolCallId: request.toolCall.id,
80
102
  toolName: request.toolCall.name,
81
- input: (request.toolCall.args ?? {}) as Record<string, unknown>,
103
+ toolInfo,
104
+ input: toolArgs,
82
105
  startedAt,
83
106
  });
84
107
  toolCallTracker.start();
@@ -122,11 +122,32 @@ function finalizeSequenceDebug(sequence: PendingSequenceDebug): SequenceDebug {
122
122
  }
123
123
 
124
124
  type OpenAiResponsesDebugModel = {
125
- model: string;
125
+ getName?: () => string;
126
+ model?: string;
126
127
  zdrEnabled?: boolean;
127
- invocationParams: (options?: Record<string, unknown>) => Record<string, unknown>;
128
+ _defaultConfig?: {
129
+ modelProvider?: string;
130
+ };
131
+ invocationParams?: (options?: Record<string, unknown>) => Record<string, unknown>;
128
132
  };
129
133
 
134
+ function getDebugModelName(model: OpenAiResponsesDebugModel) {
135
+ return typeof model.getName === "function" ? model.getName() : undefined;
136
+ }
137
+
138
+ function supportsOpenAiResponseDebug(
139
+ model: OpenAiResponsesDebugModel,
140
+ ): model is OpenAiResponsesDebugModel & {
141
+ model: string;
142
+ invocationParams: (options?: Record<string, unknown>) => Record<string, unknown>;
143
+ } {
144
+ return (
145
+ getDebugModelName(model) === "ChatOpenAI" &&
146
+ typeof model.model === "string" &&
147
+ typeof model.invocationParams === "function"
148
+ );
149
+ }
150
+
130
151
  function stringifyPromptForDebug(params: {
131
152
  model: OpenAiResponsesDebugModel;
132
153
  systemMessage: { text: string };
@@ -137,6 +158,22 @@ function stringifyPromptForDebug(params: {
137
158
  }) {
138
159
  const { model, systemMessage, messages, tools, toolChoice, modelSettings } = params;
139
160
 
161
+ if (!supportsOpenAiResponseDebug(model)) {
162
+ return YAML.stringify({
163
+ model: {
164
+ name: getDebugModelName(model) ?? null,
165
+ provider: model._defaultConfig?.modelProvider ?? null,
166
+ configuredModel:
167
+ typeof model.model === "string" ? model.model : null,
168
+ },
169
+ systemMessage,
170
+ messages,
171
+ ...(tools.length > 0 ? { tools } : {}),
172
+ ...(toolChoice !== undefined ? { toolChoice } : {}),
173
+ ...(modelSettings ? { modelSettings } : {}),
174
+ });
175
+ }
176
+
140
177
  return YAML.stringify({
141
178
  input: convertMessagesToResponsesInput({
142
179
  messages: [
@@ -1,10 +1,19 @@
1
+ import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
1
2
  import { createAgent, summarizationMiddleware } from "langchain";
2
- import { logger, type AdminUser, type CompletionAdapter } from "adminforth";
3
+ import {
4
+ MODEL_PROVIDER_CONFIG,
5
+ getChatModelByClassName,
6
+ } from "langchain/chat_models/universal";
7
+ import {
8
+ logger,
9
+ type AdminUser,
10
+ type CompletionAdapter,
11
+ type IAdminForth,
12
+ } from "adminforth";
3
13
  import { BaseCallbackHandler } from "@langchain/core/callbacks/base";
4
14
  import {type BaseCheckpointSaver, type Messages } from "@langchain/langgraph";
5
15
  import type { LLMResult } from "@langchain/core/outputs";
6
16
  import { z } from "zod";
7
- import { ChatOpenAI } from "@langchain/openai";
8
17
  import { createAgentTools } from "./tools/index.js";
9
18
  import { createApiBasedToolsMiddleware } from "./middleware/apiBasedTools.js";
10
19
  import {
@@ -23,9 +32,24 @@ export const contextSchema = z.object({
23
32
  emitToolCallEvent: z.custom<ToolCallEventSink>(),
24
33
  });
25
34
 
26
- type OpenAIBackedCompletionAdapter = CompletionAdapter & {
35
+ export type AgentModelProvider = "openai" | "anthropic" | "google-genai";
36
+ export type AgentChatModel = BaseChatModel<any, any>;
37
+
38
+ type ProviderBackedCompletionAdapter = CompletionAdapter & {
39
+ constructor?: {
40
+ name?: string;
41
+ };
27
42
  options?: {
28
43
  openAiApiKey?: string;
44
+ openAIApiKey?: string;
45
+ anthropicApiKey?: string;
46
+ geminiApiKey?: string;
47
+ googleApiKey?: string;
48
+ googleGenAiApiKey?: string;
49
+ googleGenerativeAiApiKey?: string;
50
+ apiKey?: string;
51
+ provider?: string;
52
+ modelProvider?: string;
29
53
  model?: string;
30
54
  baseURL?: string;
31
55
  baseUrl?: string;
@@ -34,7 +58,9 @@ type OpenAIBackedCompletionAdapter = CompletionAdapter & {
34
58
  };
35
59
  };
36
60
 
37
- type OpenAiReasoningConfig = Record<string, unknown>;
61
+ type AgentChatModelConstructor = new (
62
+ fields?: Record<string, unknown>,
63
+ ) => AgentChatModel;
38
64
 
39
65
  type LlmOutputTokenUsage = {
40
66
  promptTokens?: unknown;
@@ -51,6 +77,233 @@ type PendingLlmRun = {
51
77
  firstTokenAt?: number;
52
78
  };
53
79
 
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === "object" && value !== null;
82
+ }
83
+
84
+ function normalizeProvider(value: unknown): AgentModelProvider | undefined {
85
+ if (typeof value !== "string") {
86
+ return undefined;
87
+ }
88
+
89
+ const normalized = value.toLowerCase().replace(/[_\s]+/g, "-");
90
+
91
+ if (["openai", "open-ai"].includes(normalized)) {
92
+ return "openai";
93
+ }
94
+
95
+ if (["anthropic", "claude"].includes(normalized)) {
96
+ return "anthropic";
97
+ }
98
+
99
+ if (
100
+ [
101
+ "google",
102
+ "gemini",
103
+ "google-genai",
104
+ "google-gemini",
105
+ "google-generative-ai",
106
+ "google-generativeai",
107
+ ].includes(normalized)
108
+ ) {
109
+ return "google-genai";
110
+ }
111
+
112
+ return undefined;
113
+ }
114
+
115
+ function detectProviderFromConstructorName(
116
+ constructorName: string | undefined,
117
+ ): AgentModelProvider | undefined {
118
+ const normalized = constructorName?.toLowerCase();
119
+
120
+ if (!normalized) {
121
+ return undefined;
122
+ }
123
+
124
+ if (normalized.includes("openai")) {
125
+ return "openai";
126
+ }
127
+
128
+ if (normalized.includes("anthropic") || normalized.includes("claude")) {
129
+ return "anthropic";
130
+ }
131
+
132
+ if (normalized.includes("gemini") || normalized.includes("google")) {
133
+ return "google-genai";
134
+ }
135
+
136
+ return undefined;
137
+ }
138
+
139
+ function detectProviderFromModelName(
140
+ model: string | undefined,
141
+ ): AgentModelProvider | undefined {
142
+ const normalized = model?.toLowerCase();
143
+
144
+ if (!normalized) {
145
+ return undefined;
146
+ }
147
+
148
+ if (normalized.startsWith("claude")) {
149
+ return "anthropic";
150
+ }
151
+
152
+ if (normalized.startsWith("gemini")) {
153
+ return "google-genai";
154
+ }
155
+
156
+ if (/^(gpt|o[1-9]|chatgpt)/.test(normalized)) {
157
+ return "openai";
158
+ }
159
+
160
+ return undefined;
161
+ }
162
+
163
+ function detectAgentModelProvider(
164
+ adapter: ProviderBackedCompletionAdapter,
165
+ ): AgentModelProvider {
166
+ const options = adapter.options ?? {};
167
+
168
+ return (
169
+ normalizeProvider(options.modelProvider) ??
170
+ normalizeProvider(options.provider) ??
171
+ detectProviderFromConstructorName(adapter.constructor?.name) ??
172
+ (options.openAiApiKey || options.openAIApiKey
173
+ ? "openai"
174
+ : undefined) ??
175
+ (options.anthropicApiKey ? "anthropic" : undefined) ??
176
+ (options.geminiApiKey ||
177
+ options.googleApiKey ||
178
+ options.googleGenAiApiKey ||
179
+ options.googleGenerativeAiApiKey
180
+ ? "google-genai"
181
+ : undefined) ??
182
+ detectProviderFromModelName(options.model) ??
183
+ (() => {
184
+ throw new Error(
185
+ "Could not infer completion adapter provider. Set options.modelProvider to openai, anthropic, or google-genai.",
186
+ );
187
+ })()
188
+ );
189
+ }
190
+
191
+ function getProviderApiKey(
192
+ provider: AgentModelProvider,
193
+ options: ProviderBackedCompletionAdapter["options"],
194
+ ) {
195
+ switch (provider) {
196
+ case "openai":
197
+ return options?.openAiApiKey ?? options?.openAIApiKey ?? options?.apiKey;
198
+ case "anthropic":
199
+ return options?.anthropicApiKey ?? options?.apiKey;
200
+ case "google-genai":
201
+ return (
202
+ options?.geminiApiKey ??
203
+ options?.googleApiKey ??
204
+ options?.googleGenAiApiKey ??
205
+ options?.googleGenerativeAiApiKey ??
206
+ options?.apiKey
207
+ );
208
+ }
209
+ }
210
+
211
+ function getProviderModel(
212
+ provider: AgentModelProvider,
213
+ options: ProviderBackedCompletionAdapter["options"],
214
+ ) {
215
+ if (options?.model) {
216
+ return options.model;
217
+ }
218
+
219
+ if (provider === "openai") {
220
+ return "gpt-5-nano";
221
+ }
222
+
223
+ if (provider === "google-genai") {
224
+ return "gemini-3-flash-preview";
225
+ }
226
+
227
+ throw new Error(
228
+ `CompletionAdapter for provider ${provider} must expose options.model`,
229
+ );
230
+ }
231
+
232
+ function buildChatModelConfig(params: {
233
+ provider: AgentModelProvider;
234
+ options: ProviderBackedCompletionAdapter["options"];
235
+ maxTokens: number;
236
+ }) {
237
+ const { provider, options, maxTokens } = params;
238
+ const apiKey = getProviderApiKey(provider, options);
239
+
240
+ if (!apiKey) {
241
+ const optionName =
242
+ provider === "openai"
243
+ ? "options.openAiApiKey"
244
+ : provider === "anthropic"
245
+ ? "options.anthropicApiKey"
246
+ : "options.geminiApiKey";
247
+
248
+ throw new Error(
249
+ `CompletionAdapter must expose ${optionName} for ${provider} agent mode`,
250
+ );
251
+ }
252
+
253
+ const model = getProviderModel(provider, options);
254
+ const baseURL = options?.baseURL ?? options?.baseUrl;
255
+ const extraRequestBodyParameters = {
256
+ ...(options?.extraRequestBodyParameters ?? {}),
257
+ };
258
+
259
+ if (provider === "openai" && isRecord(extraRequestBodyParameters.reasoning)) {
260
+ extraRequestBodyParameters.reasoning = {
261
+ ...extraRequestBodyParameters.reasoning,
262
+ summary: "auto",
263
+ };
264
+ }
265
+
266
+ const config: Record<string, unknown> = {
267
+ model,
268
+ apiKey,
269
+ maxTokens,
270
+ streaming: true,
271
+ ...extraRequestBodyParameters,
272
+ };
273
+
274
+ if (typeof options?.timeoutMs === "number") {
275
+ config.timeout = options.timeoutMs;
276
+ }
277
+
278
+ if (baseURL) {
279
+ config.baseURL = baseURL;
280
+ config.baseUrl = baseURL;
281
+ config.configuration = {
282
+ baseURL,
283
+ };
284
+ }
285
+
286
+ if (provider === "openai") {
287
+ config.openAIApiKey = apiKey;
288
+ config.useResponsesApi = true;
289
+ config.outputVersion = "v1";
290
+ config.promptCacheKey = `adminforth-agent:${model}:system-v1:tools-v1`;
291
+ config.promptCacheRetention = "in_memory";
292
+ }
293
+
294
+ if (provider === "anthropic") {
295
+ config.anthropicApiKey = apiKey;
296
+ }
297
+
298
+ if (provider === "google-genai") {
299
+ config.geminiApiKey = apiKey;
300
+ config.googleApiKey = apiKey;
301
+ config.maxOutputTokens = maxTokens;
302
+ }
303
+
304
+ return { model, config };
305
+ }
306
+
54
307
  function getFiniteNumber(value: unknown) {
55
308
  return typeof value === "number" && Number.isFinite(value)
56
309
  ? value
@@ -162,63 +415,39 @@ function createAgentLlmMetricsLogger() {
162
415
  return new AgentLlmMetricsLogger();
163
416
  }
164
417
 
165
- export function createAgentChatModel(params: {
418
+ export async function createAgentChatModel(params: {
166
419
  adapter: CompletionAdapter;
167
420
  maxTokens: number;
168
421
  }) {
169
- const adapter = params.adapter as OpenAIBackedCompletionAdapter;
422
+ const adapter = params.adapter as ProviderBackedCompletionAdapter;
170
423
  const options = adapter.options ?? {};
171
-
172
- if (!options.openAiApiKey) {
173
- throw new Error(
174
- "CompletionAdapter must expose options.openAiApiKey for ChatOpenAI",
175
- );
176
- }
177
-
178
- const model = options.model ?? "gpt-5-nano";
179
- const baseURL = options.baseURL ?? options.baseUrl;
180
- const reasoning = options.extraRequestBodyParameters
181
- ?.reasoning as OpenAiReasoningConfig | undefined;
182
- const reasoningConfig = reasoning
183
- ? {
184
- ...reasoning,
185
- summary: "auto",
186
- }
187
- : undefined;
188
-
189
- // @ts-ignore
190
- return new ChatOpenAI({
191
- apiKey: options.openAiApiKey,
192
- model,
424
+ const provider = detectAgentModelProvider(adapter);
425
+ const { config } = buildChatModelConfig({
426
+ provider,
427
+ options,
193
428
  maxTokens: params.maxTokens,
194
- useResponsesApi: true,
195
- outputVersion: "v1",
196
- streaming: true,
197
-
198
- promptCacheKey: `adminforth-agent:${model}:system-v1:tools-v1`,
199
- promptCacheRetention: "in_memory",
200
-
201
- ...(reasoningConfig ? { reasoning: reasoningConfig } : {}),
202
- ...(typeof options.timeoutMs === "number"
203
- ? { timeout: options.timeoutMs }
204
- : {}),
205
- ...(baseURL
206
- ? {
207
- configuration: {
208
- baseURL,
209
- },
210
- }
211
- : {}),
212
429
  });
430
+ const className = MODEL_PROVIDER_CONFIG[provider].className;
431
+ const ChatModelClass = await getChatModelByClassName(
432
+ className,
433
+ provider,
434
+ ) as AgentChatModelConstructor;
435
+
436
+ return {
437
+ model: new ChatModelClass(config),
438
+ provider,
439
+ };
213
440
  }
214
441
 
215
442
  export async function callAgent(params: {
216
443
  name: string;
217
- model: ChatOpenAI;
218
- summaryModel: ChatOpenAI;
444
+ model: AgentChatModel;
445
+ summaryModel: AgentChatModel;
446
+ modelProvider: AgentModelProvider;
219
447
  checkpointer?: BaseCheckpointSaver;
220
448
  messages: Messages;
221
449
  adminUser: AdminUser;
450
+ adminforth: IAdminForth;
222
451
  apiBasedTools: Record<string, ApiBasedTool>;
223
452
  customComponentsDir: string;
224
453
  sessionId: string;
@@ -231,9 +460,11 @@ export async function callAgent(params: {
231
460
  name,
232
461
  model,
233
462
  summaryModel,
463
+ modelProvider,
234
464
  checkpointer,
235
465
  messages,
236
466
  adminUser,
467
+ adminforth,
237
468
  apiBasedTools,
238
469
  customComponentsDir,
239
470
  sessionId,
@@ -244,16 +475,16 @@ export async function callAgent(params: {
244
475
  } = params;
245
476
 
246
477
  const tools = await createAgentTools(customComponentsDir, apiBasedTools);
247
- const apiBasedToolsMiddleware = createApiBasedToolsMiddleware(apiBasedTools);
248
- const openAiResponsesContinuationMiddleware =
249
- createOpenAiResponsesContinuationMiddleware();
478
+ const apiBasedToolsMiddleware = createApiBasedToolsMiddleware(apiBasedTools, adminforth);
250
479
  const sequenceDebugMiddleware = createSequenceDebugMiddleware(
251
480
  sequenceDebugSink,
252
481
  );
253
482
 
254
483
  const middleware = [
255
484
  apiBasedToolsMiddleware,
256
- openAiResponsesContinuationMiddleware,
485
+ ...(modelProvider === "openai"
486
+ ? [createOpenAiResponsesContinuationMiddleware()]
487
+ : []),
257
488
  sequenceDebugMiddleware,
258
489
  summarizationMiddleware({
259
490
  model: summaryModel,
@@ -6,6 +6,7 @@ export type ToolCallEvent =
6
6
  | {
7
7
  toolCallId: string;
8
8
  toolName: string;
9
+ toolInfo?: string;
9
10
  phase: "start";
10
11
  input: string;
11
12
  }
@@ -64,6 +65,7 @@ export function createToolCallTracker(params: {
64
65
  emit: ToolCallEventSink;
65
66
  toolCallId?: string;
66
67
  toolName: string;
68
+ toolInfo?: string;
67
69
  input?: Record<string, unknown>;
68
70
  startedAt?: number;
69
71
  }) {
@@ -75,6 +77,7 @@ export function createToolCallTracker(params: {
75
77
  params.emit({
76
78
  toolCallId,
77
79
  toolName: params.toolName,
80
+ toolInfo: params.toolInfo,
78
81
  phase: "start",
79
82
  input: YAML.stringify(params.input ?? {}),
80
83
  });