From b5cbae25fa79e3ce0c3fd0ed95b0125352fe58d6 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 9 Jun 2026 12:45:24 +0200 Subject: [PATCH 1/2] refactor(workflow-executor): drop direct @langchain dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerAiAdapter now builds its model through AiClient.getModel (passing a single openai config whose fetch is rewritten to the Forest AI proxy) instead of instantiating ChatOpenAI directly. The remaining BaseChatModel type imports are routed through the @forestadmin/ai-proxy facade, which already re-exports the langchain types the executor needs. This removes @langchain/openai from the package's dependencies — the executor no longer imports from @langchain/* at all; ai-proxy owns the langchain coupling. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/workflow-executor/package.json | 1 - .../src/adapters/ai-client-adapter.ts | 3 +- .../adapters/always-error-ai-model-port.ts | 3 +- .../src/adapters/server-ai-adapter.ts | 59 ++++++++++--------- .../src/ports/ai-model-port.ts | 3 +- .../test/adapters/server-ai-adapter.test.ts | 46 ++++++++------- 6 files changed, 59 insertions(+), 56 deletions(-) diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json index 5b0b36b6e1..b7099eabbe 100644 --- a/packages/workflow-executor/package.json +++ b/packages/workflow-executor/package.json @@ -28,7 +28,6 @@ "dependencies": { "@forestadmin/agent-client": "1.5.10", "@forestadmin/ai-proxy": "1.10.1", - "@langchain/openai": "1.2.5", "@forestadmin/forestadmin-client": "1.39.9", "@koa/bodyparser": "^6.1.0", "@koa/router": "^13.1.0", diff --git a/packages/workflow-executor/src/adapters/ai-client-adapter.ts b/packages/workflow-executor/src/adapters/ai-client-adapter.ts index 907305c5ae..3b16e1a014 100644 --- a/packages/workflow-executor/src/adapters/ai-client-adapter.ts +++ b/packages/workflow-executor/src/adapters/ai-client-adapter.ts @@ -1,6 +1,5 @@ import type { AiModelPort } from '../ports/ai-model-port'; -import type { AiConfiguration, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; -import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import type { AiConfiguration, BaseChatModel, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; import { AiClient } from '@forestadmin/ai-proxy'; diff --git a/packages/workflow-executor/src/adapters/always-error-ai-model-port.ts b/packages/workflow-executor/src/adapters/always-error-ai-model-port.ts index e950855079..24a7426c5c 100644 --- a/packages/workflow-executor/src/adapters/always-error-ai-model-port.ts +++ b/packages/workflow-executor/src/adapters/always-error-ai-model-port.ts @@ -1,6 +1,5 @@ import type { AiModelPort } from '../ports/ai-model-port'; -import type { RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; -import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import type { BaseChatModel, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; import { AiModelPortError } from '../errors'; diff --git a/packages/workflow-executor/src/adapters/server-ai-adapter.ts b/packages/workflow-executor/src/adapters/server-ai-adapter.ts index 68859cf8b9..e7f70ce93b 100644 --- a/packages/workflow-executor/src/adapters/server-ai-adapter.ts +++ b/packages/workflow-executor/src/adapters/server-ai-adapter.ts @@ -1,9 +1,7 @@ import type { AiModelPort } from '../ports/ai-model-port'; -import type { RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; -import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import type { AiConfiguration, BaseChatModel, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; import { AiClient } from '@forestadmin/ai-proxy'; -import { ChatOpenAI } from '@langchain/openai'; import { AiModelPortError, WorkflowExecutorError } from '../errors'; @@ -13,37 +11,17 @@ export interface ServerAiAdapterOptions { } export default class ServerAiAdapter implements AiModelPort { - private readonly forestServerUrl: string; - private readonly envSecret: string; private readonly aiClient: AiClient; constructor(options: ServerAiAdapterOptions) { - this.forestServerUrl = options.forestServerUrl; - this.envSecret = options.envSecret; - this.aiClient = new AiClient(); + this.aiClient = new AiClient({ + aiConfigurations: [ServerAiAdapter.buildProxyConfiguration(options)], + }); } getModel(): BaseChatModel { try { - const aiProxyUrl = `${this.forestServerUrl}/liana/v1/ai-proxy`; - const { envSecret } = this; - - return new ChatOpenAI({ - // Model has no effect — the server uses its own configured model. - // Set here only because ChatOpenAI requires it. - model: 'gpt-4.1', - maxRetries: 2, - configuration: { - apiKey: 'unused', - fetch: (_url: RequestInfo | URL, init?: RequestInit) => { - const headers = new Headers(init?.headers); - headers.delete('authorization'); - headers.set('forest-secret-key', envSecret); - - return fetch(aiProxyUrl, { ...init, headers }); - }, - }, - }); + return this.aiClient.getModel(); } catch (cause) { if (cause instanceof WorkflowExecutorError) throw cause; throw new AiModelPortError('getModel', cause); @@ -58,6 +36,33 @@ export default class ServerAiAdapter implements AiModelPort { return this.callPort('closeConnections', () => this.aiClient.closeConnections()); } + // Every call is routed to the Forest server's AI proxy, which picks the real provider/model. + // The model name is therefore a placeholder, and fetch is rewritten to hit the proxy with the + // env secret instead of an OpenAI Authorization header. + private static buildProxyConfiguration({ + forestServerUrl, + envSecret, + }: ServerAiAdapterOptions): AiConfiguration { + const aiProxyUrl = `${forestServerUrl}/liana/v1/ai-proxy`; + + return { + name: 'forest-server', + provider: 'openai', + model: 'gpt-4.1', + maxRetries: 2, + configuration: { + apiKey: 'unused', + fetch: (_url: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.delete('authorization'); + headers.set('forest-secret-key', envSecret); + + return fetch(aiProxyUrl, { ...init, headers }); + }, + }, + }; + } + private async callPort(operation: string, fn: () => Promise): Promise { try { return await fn(); diff --git a/packages/workflow-executor/src/ports/ai-model-port.ts b/packages/workflow-executor/src/ports/ai-model-port.ts index ea43b7a286..dd94f024e7 100644 --- a/packages/workflow-executor/src/ports/ai-model-port.ts +++ b/packages/workflow-executor/src/ports/ai-model-port.ts @@ -1,5 +1,4 @@ -import type { RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; -import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import type { BaseChatModel, RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; export interface AiModelPort { getModel(aiConfigName?: string): BaseChatModel; diff --git a/packages/workflow-executor/test/adapters/server-ai-adapter.test.ts b/packages/workflow-executor/test/adapters/server-ai-adapter.test.ts index 00d81efa2f..ddbd3e96d2 100644 --- a/packages/workflow-executor/test/adapters/server-ai-adapter.test.ts +++ b/packages/workflow-executor/test/adapters/server-ai-adapter.test.ts @@ -1,18 +1,15 @@ +import { AiClient } from '@forestadmin/ai-proxy'; + import ServerAiAdapter from '../../src/adapters/server-ai-adapter'; jest.mock('@forestadmin/ai-proxy', () => ({ AiClient: jest.fn().mockImplementation(() => ({ + getModel: jest.fn().mockReturnValue({ id: 'fake-model' }), loadRemoteTools: jest.fn().mockResolvedValue([]), closeConnections: jest.fn().mockResolvedValue(undefined), })), })); -jest.mock('@langchain/openai', () => ({ - ChatOpenAI: jest.fn().mockImplementation((opts: Record) => ({ - mockOpts: opts, - })), -})); - const ENV_SECRET = 'a'.repeat(64); describe('ServerAiAdapter', () => { @@ -21,38 +18,43 @@ describe('ServerAiAdapter', () => { envSecret: ENV_SECRET, }); - describe('getModel', () => { - it('returns a ChatOpenAI configured for the FA server', () => { - const model = adapter.getModel() as unknown as { mockOpts: Record }; + const aiClientMock = AiClient as unknown as jest.Mock; + const proxyConfig = () => aiClientMock.mock.calls[0][0].aiConfigurations[0]; + const aiClientInstance = () => aiClientMock.mock.results[0].value; - expect(model.mockOpts).toEqual( + describe('constructor', () => { + it('configures AiClient with a single openai config targeting the FA server', () => { + expect(proxyConfig()).toEqual( expect.objectContaining({ + provider: 'openai', model: 'gpt-4.1', maxRetries: 2, - configuration: expect.objectContaining({ - fetch: expect.any(Function), - }), + configuration: expect.objectContaining({ fetch: expect.any(Function) }), }), ); }); + }); - it('sends forest-secret-key header instead of Authorization', () => { - const model = adapter.getModel() as unknown as { mockOpts: Record }; + describe('getModel', () => { + it('delegates to the internal AiClient', () => { + expect(adapter.getModel()).toEqual({ id: 'fake-model' }); + expect(aiClientInstance().getModel).toHaveBeenCalled(); + }); - const { fetch: customFetch } = model.mockOpts.configuration as { + it('rewrites fetch to the proxy URL with forest-secret-key instead of Authorization', () => { + const { fetch: customFetch } = proxyConfig().configuration as { fetch: (url: RequestInfo | URL, init?: RequestInit) => Promise; }; - const mockInit = { - method: 'POST', - body: '{}', - headers: { Authorization: 'Bearer unused' }, - } as RequestInit; const originalFetch = global.fetch; global.fetch = jest.fn().mockResolvedValue(new Response('{}', { status: 200 })); try { - customFetch('https://ignored.com/chat/completions', mockInit); + customFetch('https://ignored.com/chat/completions', { + method: 'POST', + body: '{}', + headers: { Authorization: 'Bearer unused' }, + }); const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; expect(url).toBe('https://api.forestadmin.com/liana/v1/ai-proxy'); From 45c47afc5cea6a626a9c41f420eb58e371ae0737 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 9 Jun 2026 13:39:59 +0200 Subject: [PATCH 2/2] fix(workflow-executor): surface WorkflowExecutorError userMessage on context build failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The StepExecutorFactory catch returned a generic "An unexpected error occurred." for every construction failure, discarding the userMessage carried by domain errors (e.g. AiModelPortError, StepStateError) thrown while building the execution context — mirroring base-step-executor's existing handling. Also strengthen ServerAiAdapter tests: assert the delegation arguments (loadRemoteTools/closeConnections), the load-bearing apiKey placeholder and the config name, and reset mocks per test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/executors/step-executor-factory.ts | 12 +++- .../test/adapters/server-ai-adapter.test.ts | 59 +++++++++++++------ .../workflow-executor/test/runner.test.ts | 27 ++++++++- 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/workflow-executor/src/executors/step-executor-factory.ts b/packages/workflow-executor/src/executors/step-executor-factory.ts index 446a27dc3c..d0ce901ca7 100644 --- a/packages/workflow-executor/src/executors/step-executor-factory.ts +++ b/packages/workflow-executor/src/executors/step-executor-factory.ts @@ -22,7 +22,12 @@ import type { UpdateRecordStepDefinition, } from '../types/validated/step-definition'; -import { StepStateError, causeMessage, extractErrorMessage } from '../errors'; +import { + StepStateError, + WorkflowExecutorError, + causeMessage, + extractErrorMessage, +} from '../errors'; import SchemaResolver from '../schema-resolver'; import ActivityLog from './activity-log'; import AgentWithLog from './agent-with-log'; @@ -114,7 +119,10 @@ export default class StepExecutorFactory { stepId: step.stepId, stepIndex: step.stepIndex, status: 'error', - error: 'An unexpected error occurred.', + error: + error instanceof WorkflowExecutorError + ? error.userMessage + : 'An unexpected error occurred.', }, }), }; diff --git a/packages/workflow-executor/test/adapters/server-ai-adapter.test.ts b/packages/workflow-executor/test/adapters/server-ai-adapter.test.ts index ddbd3e96d2..3af2a71db7 100644 --- a/packages/workflow-executor/test/adapters/server-ai-adapter.test.ts +++ b/packages/workflow-executor/test/adapters/server-ai-adapter.test.ts @@ -1,35 +1,51 @@ -import { AiClient } from '@forestadmin/ai-proxy'; - import ServerAiAdapter from '../../src/adapters/server-ai-adapter'; +const mockGetModel = jest.fn().mockReturnValue({ id: 'fake-model' }); +const mockLoadRemoteTools = jest.fn().mockResolvedValue([]); +const mockCloseConnections = jest.fn().mockResolvedValue(undefined); +const mockAiClientConstructor = jest.fn(); + jest.mock('@forestadmin/ai-proxy', () => ({ - AiClient: jest.fn().mockImplementation(() => ({ - getModel: jest.fn().mockReturnValue({ id: 'fake-model' }), - loadRemoteTools: jest.fn().mockResolvedValue([]), - closeConnections: jest.fn().mockResolvedValue(undefined), - })), + AiClient: jest.fn().mockImplementation((...args: unknown[]) => { + mockAiClientConstructor(...args); + + return { + getModel: mockGetModel, + loadRemoteTools: mockLoadRemoteTools, + closeConnections: mockCloseConnections, + }; + }), })); const ENV_SECRET = 'a'.repeat(64); describe('ServerAiAdapter', () => { - const adapter = new ServerAiAdapter({ - forestServerUrl: 'https://api.forestadmin.com', - envSecret: ENV_SECRET, + let adapter: ServerAiAdapter; + + beforeEach(() => { + jest.clearAllMocks(); + adapter = new ServerAiAdapter({ + forestServerUrl: 'https://api.forestadmin.com', + envSecret: ENV_SECRET, + }); }); - const aiClientMock = AiClient as unknown as jest.Mock; - const proxyConfig = () => aiClientMock.mock.calls[0][0].aiConfigurations[0]; - const aiClientInstance = () => aiClientMock.mock.results[0].value; + const proxyConfig = () => + (mockAiClientConstructor.mock.calls[0][0] as { aiConfigurations: Record[] }) + .aiConfigurations[0]; describe('constructor', () => { it('configures AiClient with a single openai config targeting the FA server', () => { expect(proxyConfig()).toEqual( expect.objectContaining({ + name: 'forest-server', provider: 'openai', model: 'gpt-4.1', maxRetries: 2, - configuration: expect.objectContaining({ fetch: expect.any(Function) }), + configuration: expect.objectContaining({ + apiKey: 'unused', + fetch: expect.any(Function), + }), }), ); }); @@ -38,7 +54,7 @@ describe('ServerAiAdapter', () => { describe('getModel', () => { it('delegates to the internal AiClient', () => { expect(adapter.getModel()).toEqual({ id: 'fake-model' }); - expect(aiClientInstance().getModel).toHaveBeenCalled(); + expect(mockGetModel).toHaveBeenCalled(); }); it('rewrites fetch to the proxy URL with forest-secret-key instead of Authorization', () => { @@ -69,16 +85,21 @@ describe('ServerAiAdapter', () => { }); describe('loadRemoteTools', () => { - it('delegates to internal AiClient', async () => { - const result = await adapter.loadRemoteTools({}); + it('delegates to internal AiClient with the given configs', async () => { + const configs = {}; + + const result = await adapter.loadRemoteTools(configs); + expect(mockLoadRemoteTools).toHaveBeenCalledWith(configs); expect(result).toEqual([]); }); }); describe('closeConnections', () => { - it('resolves without error', async () => { - await expect(adapter.closeConnections()).resolves.toBeUndefined(); + it('delegates to internal AiClient', async () => { + await adapter.closeConnections(); + + expect(mockCloseConnections).toHaveBeenCalled(); }); }); }); diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index 8992799c1f..af5d22585b 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -9,6 +9,7 @@ import type { StepDefinition } from '../src/types/validated/step-definition'; import type { BaseChatModel } from '@forestadmin/ai-proxy'; import { + AiModelPortError, ConfigurationError, MalformedRunError, RunAlreadyInFlightError, @@ -1665,7 +1666,7 @@ describe('StepExecutorFactory.create — factory', () => { ); const { stepOutcome } = await executor.execute(); expect(stepOutcome.status).toBe('error'); - expect(stepOutcome.error).toBe('An unexpected error occurred.'); + expect(stepOutcome.error).toBe('An unexpected error occurred while processing this step.'); }); it('returns an executor with an error outcome when loadTools rejects for a McpTask step', async () => { @@ -1706,6 +1707,30 @@ describe('StepExecutorFactory.create — factory', () => { ); }); + it('surfaces the userMessage when construction throws a WorkflowExecutorError', async () => { + const contextConfig: StepContextConfig = { + ...makeContextConfig(), + aiModelPort: { + getModel: jest.fn().mockImplementationOnce(() => { + throw new AiModelPortError('getModel', new Error('boom')); + }), + } as unknown as AiModelPort, + }; + + const executor = await StepExecutorFactory.create( + makePendingStep(), + contextConfig, + makeRunLogger(), + jest.fn(), + ); + const { stepOutcome } = await executor.execute(); + + expect(stepOutcome.status).toBe('error'); + expect(stepOutcome.error).toBe( + 'The AI service is unavailable. Please try again or contact your administrator.', + ); + }); + it('logs cause as undefined when construction error cause is not an Error instance', async () => { const error = new Error('wrapper'); (error as Error & { cause: string }).cause = 'plain string';