Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions packages/agent-bff/src/action/action-routes-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
AgentActionClient,
AgentActionClientOptions,
} from './agent-action-client';
import type { AgentTransport } from '../agent/agent-transport';
import type { Logger } from '../ports/logger-port';
import type ReadModelStore from '../read-model/read-model-store';
import type { Context, Middleware } from 'koa';
Expand Down Expand Up @@ -76,8 +77,7 @@ function describePayloadShape(raw: unknown): string {

export interface ActionRoutesMiddlewareOptions {
store: ReadModelStore;
agentUrl: string;
timeoutMs?: number;
transport: AgentTransport;
logger: Logger;
createClient?: (options: AgentActionClientOptions) => AgentActionClient;
}
Expand Down Expand Up @@ -159,8 +159,7 @@ async function handleExecute({

export default function createActionRoutesMiddleware({
store,
agentUrl,
timeoutMs,
transport,
logger,
createClient = defaultCreateAgentActionClient,
}: ActionRoutesMiddlewareOptions): Middleware {
Expand Down Expand Up @@ -193,10 +192,9 @@ export default function createActionRoutesMiddleware({
const values = parseValues(body.values);

const client = createClient({
agentUrl,
transport,
token,
actionEndpoints: readModel.getActionEndpoints(),
timeoutMs,
});

const action = await callAgent(
Expand Down
13 changes: 5 additions & 8 deletions packages/agent-bff/src/action/agent-action-client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { AgentTransport } from '../agent/agent-transport';
import type { ActionEndpointsByCollection } from '@forestadmin/agent-client';
import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client';

import { createRemoteAgentClient } from '@forestadmin/agent-client';

import createAgentHttpRequester from '../agent/create-agent-http-requester';

export interface ActionFormField {
getName(): string;
/** A list type is the array the agent sent, `['String']`, not `'StringList'`. */
Expand Down Expand Up @@ -41,10 +40,9 @@ export interface AgentActionClient {
}

export interface AgentActionClientOptions {
agentUrl: string;
transport: AgentTransport;
token: string;
actionEndpoints: ActionEndpointsByCollection;
timeoutMs?: number;
}

// The raw layout must be read AFTER tryToSetFields: a change hook rebuilds fields+layout in place.
Expand All @@ -64,16 +62,15 @@ export function extractRawLayout(action: ActionForm): ForestServerActionFormLayo
* than reimplementing it. The endpoint map from the read-model is the action allow-list.
*/
export default function createAgentActionClient({
agentUrl,
transport,
token,
actionEndpoints,
timeoutMs,
}: AgentActionClientOptions): AgentActionClient {
const client = createRemoteAgentClient({
url: agentUrl,
url: transport.url,
token,
actionEndpoints,
httpRequester: createAgentHttpRequester(token, agentUrl, timeoutMs),
httpRequester: transport.createRequester(token),
});

return {
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-bff/src/agent/agent-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { HttpRequester } from '@forestadmin/agent-client';

import createAgentHttpRequester from './create-agent-http-requester';

/**
* How the BFF reaches the agent. Everything that talks to the agent takes one of these instead of a
* URL, so the same routes serve a remote agent over HTTP and an agent embedded in the same process.
*/
export interface AgentTransport {
/**
* Base url `createRemoteAgentClient` still requires. Over HTTP it is the agent; in-process it is a
* sentinel that never reaches the network, since the requester answers before any socket is opened.
*/
url: string;
createRequester(token: string): HttpRequester;
}

export interface HttpTransportOptions {
agentUrl: string;
timeoutMs?: number;
}

export function createHttpTransport({ agentUrl, timeoutMs }: HttpTransportOptions): AgentTransport {
return {
url: agentUrl,
createRequester: token => createAgentHttpRequester(token, agentUrl, timeoutMs),
};
}
147 changes: 147 additions & 0 deletions packages/agent-bff/src/agent/in-process-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { AgentTransport } from './agent-transport';

import { HttpRequester } from '@forestadmin/agent-client';

import { streamingUnsupported } from '../http/bff-local-errors';

/** A sentinel that never reaches the network: `query` answers before any socket is opened. */
const IN_PROCESS_URL = 'http://in-process.agent';

export interface AgentDispatchRequest {
method: 'get' | 'post' | 'put' | 'delete';
path: string;
headers: Record<string, string>;
query?: Record<string, unknown>;
payload?: Record<string, unknown>;
timeoutMs?: number;
}

export interface AgentDispatchResponse {
status: number;
body: unknown;
text?: string;
}

/**
* Dispatches a request into an agent living in the same process, without a socket. Declared
* structurally so neither package has to depend on the other for six primitive fields.
*/
export interface AgentDispatcher {
/**
* Bounding the call is the implementor's duty: there is no socket to abort, so nothing here can
* cut a hung agent handler loose. When `timeoutMs` is absent the deployment configured none —
* apply the 10s ceiling `HttpRequester.query` always has rather than waiting forever. A dispatch
* that fails, times out included, must reject.
*/
request(request: AgentDispatchRequest): Promise<AgentDispatchResponse>;
}

/**
* Reaches the agent through the dispatcher instead of the network, reusing `HttpRequester`'s parse
* helpers so results and error shape stay identical to the HTTP path.
*/
class InProcessRequester extends HttpRequester {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Should fix

This class is a near-verbatim copy of packages/mcp-server/src/in-process-http-requester.ts — same sentinel URL, same header block, same timezone default, same status >= 400 branch, same stream() refusal — and the copies already disagree on the one line that matters: mcp-server passes path raw, this one escapes it. Both dispatch into the same InProcessDispatcher, so an MCP tool call and a BFF call for the same record id now reach the agent's Koa stack with different path strings. One of the two is wrong, and after the finding above, arguably both are.

To be clear about what is not the finding: re-declaring AgentDispatcher / AgentDispatchRequest / AgentDispatchResponse structurally instead of importing mcp-server's is a deliberate, documented decision — PRD-1076's "Decisions taken" argues it explicitly, and I am not reopening it. The interface duplication is a choice; the requester duplication with divergent escaping is not covered by that rationale.

Cheapest shape given that createInProcessTransport has no production caller in this diff: fix the escaping once in a shared requester, rather than land a second copy ahead of the PR that needs it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not deduping, and the divergence is now deliberate rather than accidental. Executed: the agent registers escapeUrlSlug(collectionName) as a path-to-regexp pattern, and pathToRegexp('/forest/a\\+b').regexp matches /forest/a+b and rejects /forest/a/+b — so mcp-server's raw pass-through is the form the agent's routes actually match, and HTTP is the broken one. This transport's invariant is HTTP parity (the BFF serves the same routes over both, and a record must not resolve differently depending on how the BFF was deployed), so aligning it on mcp-server would reintroduce exactly the "same click, two answers" you flagged above. The single fix is escapeUrlSlug/buildUrl in agent-client under PRD-1124, which corrects HTTP, in-process and MCP at once; landing it from a stacked BFF PR would change MCP behavior in a package this branch does not touch. Happy to hoist the shared requester into agent-client as part of that ticket.

constructor(
private readonly bearerToken: string,
private readonly dispatcher: AgentDispatcher,
private readonly defaultTimeoutMs?: number,
) {
super(bearerToken, { url: IN_PROCESS_URL });
}

// No socket to stream from, and nothing in the BFF streams today. A typed 501 rather than a raw
// Error, so the day a route does reach here the client is told what is missing instead of being
// sent looking for a network the request never crossed.
override async stream(): Promise<void> {
throw streamingUnsupported('Streaming is not supported over the in-process transport');
}

override async query<Data = unknown>({
method,
path,
body,
query,
maxTimeAllowed,
contentType,
skipDeserialization,
}: {
method: 'get' | 'post' | 'put' | 'delete';
path: string;
body?: Record<string, unknown>;
query?: Record<string, unknown>;
maxTimeAllowed?: number;
contentType?: 'application/json' | 'text/csv';
skipDeserialization?: boolean;
}): Promise<Data> {
const target = InProcessRequester.toDispatchTarget(path);

const {
status,
body: responseBody,
text,
} = await this.dispatch({
method,
path: target.path,
headers: {
Authorization: `Bearer ${this.bearerToken}`,
'Content-Type': contentType ?? 'application/json',
Accept: contentType ?? 'application/json',
},
query: { timezone: 'Europe/Paris', ...target.query, ...query },
payload: body,
timeoutMs: maxTimeAllowed ?? this.defaultTimeoutMs,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Should fix

The 10 s ceiling the HTTP path always has (.timeout(maxTimeAllowed ?? 10_000)) exists in-process only by the grace of the dispatcher: this line passes timeoutMs as advisory data and then awaits with no race and no timer, and when BFF_AGENT_TIMEOUT_MS is unset it hands over undefined — which the sibling test freezes as the expected value.

To be fair to the design: it is not actually unbounded today. packages/agent/src/mcp-in-process-dispatcher.ts defaults to 10 s, races the injection, and even swallows a late settlement so a post-timeout rejection cannot surface as an unhandledRejection — with a comment saying it matches the HTTP bound. That is careful work.

The finding is that AgentDispatcher as declared here places no such obligation on an implementor, so the parity rests on a convention across a package boundary rather than on the contract. A second dispatcher hangs a BFF request forever where HTTP would have returned 502. Either ?? 10_000 on this line, or one sentence on the interface making the bound the implementor's stated duty.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the contract option: AgentDispatcher.request now states that bounding the call is the implementor's duty, that an absent timeoutMs means applying the 10s ceiling HttpRequester.query always has, and that a failed dispatch — timeout included — must reject. Not ?? 10_000 here: this side has nothing to race and no socket to abort, so the number would still be advisory and a dispatcher that ignores it would hang exactly as before — enforcement can only live where the injection happens, which is what the sentence now makes an obligation instead of a convention.

});

if (status >= 400) throw this.buildError(status, responseBody, text);

return this.deserialize<Data>(responseBody, text, skipDeserialization);
}

/**
* A rejection here is the agent throwing or the dispatch itself failing — never a network hop,
* since there is none. Rethrown with the shape an agent 5xx has, so `mapAgentError` logs the real
* cause and answers `agent_unavailable` instead of sending whoever debugs it to look for a socket.
*/
private async dispatch(request: AgentDispatchRequest): Promise<AgentDispatchResponse> {
try {
return await this.dispatcher.request(request);
} catch (error) {
const detail = error instanceof Error ? error.stack ?? error.message : String(error);

throw this.buildError(500, { errors: [{ detail }] });
}
}

/**
* Callers hand over raw segments — a record id, a collection name — because `buildUrl` escapes the
* whole path on the HTTP side, so the escaping has to happen here too. Escaping alone is not
* enough: `escapeUrlSlug` prefixes `+?*` with a backslash, which the WHATWG parser `buildUrl`
* feeds reads as a path separator, and that parse also resolves `..` and splits a trailing query
* off. Running it here is what keeps a given id addressing the same record over both transports —
* both remain wrong for those three characters, which is PRD-1124's to fix on both at once.
*/
private static toDispatchTarget(path: string): {
path: string;
query: Record<string, string>;
} {
const normalized = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${IN_PROCESS_URL}${HttpRequester.escapeUrlSlug(normalized)}`);

return { path: url.pathname, query: Object.fromEntries(url.searchParams) };
}
}

export interface InProcessTransportOptions {
dispatcher: AgentDispatcher;
timeoutMs?: number;
}

export default function createInProcessTransport({
dispatcher,
timeoutMs,
}: InProcessTransportOptions): AgentTransport {
return {
url: IN_PROCESS_URL,
createRequester: token => new InProcessRequester(token, dispatcher, timeoutMs),
};
}
13 changes: 9 additions & 4 deletions packages/agent-bff/src/build-bff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Koa from 'koa';
import createActionRoutesMiddleware from './action/action-routes-middleware';
import createConsoleLogger from './adapters/console-logger';
import createAgentStubMiddleware from './agent/agent-stub';
import { createHttpTransport } from './agent/agent-transport';
import AiProxyClient from './ai/ai-proxy-client';
import createAiRoutesMiddleware, { AI_QUERY_ROUTE } from './ai/ai-routes-middleware';
import createApiKeyAuthenticator from './api-key/api-key-authenticator';
Expand Down Expand Up @@ -260,8 +261,10 @@ function toUnfoldSource(

return {
store: bundle.store,
agentUrl: config.agentUrl,
timeoutMs: config.agentTimeoutMs,
transport: createHttpTransport({
agentUrl: config.agentUrl,
timeoutMs: config.agentTimeoutMs,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5 (claude-opus-5): Should fix

Delete this line and the whole suite stays green, while every agent call silently falls back to superagent's 10 s default instead of the configured BFF_AGENT_TIMEOUT_MS.

The rewiring dropped the assertions that used to protect it: the tests that asserted timeoutMs: 2500 and timeoutMs: undefined reached the client were replaced by expect.objectContaining({ transport: TRANSPORT }) — object identity on a module constant passed straight through, which cannot fail. git grep agentTimeoutMs over packages/agent-bff/test now hits only config/env-config.test.ts (parsing) and cli-ai-wiring.test.ts (the AI timeout), so the config.agentTimeoutMscreateHttpTransportmaxTimeAllowed link is asserted nowhere.

One assertion on the constructed transport's timeoutMs, in either the data or the action middleware suite, restores what the old tests covered.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: test/build-bff.test.ts now spies on createHttpTransport (a requireActual passthrough, so the rest of the suite keeps the real one) and asserts that every transport buildBff constructs carries { agentUrl, timeoutMs: 2500 } — both call sites, since asserting only one of them would have been satisfied by the toUnfoldSource call while the middleware line dropped it.

}),
logger,
};
}
Expand Down Expand Up @@ -312,10 +315,12 @@ function buildAgentRouteMiddlewares(
return [permissionsMiddleware, createAgentStubMiddleware()];
}

const transport = createHttpTransport({ agentUrl, timeoutMs });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/build-bff.ts:318

BFF data and action routes always use an HTTP socket, so embedding callers cannot route requests through the in-process dispatcher. buildAgentRouteMiddlewares unconditionally creates createHttpTransport, while BuildBffOptions provides no transport override and createInProcessTransport is not exposed; accept and select a transport for embedded deployments, and export the in-process factory as needed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/build-bff.ts around line 318:

BFF data and action routes always use an HTTP socket, so embedding callers cannot route requests through the in-process dispatcher. `buildAgentRouteMiddlewares` unconditionally creates `createHttpTransport`, while `BuildBffOptions` provides no transport override and `createInProcessTransport` is not exposed; accept and select a transport for embedded deployments, and export the in-process factory as needed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate staging, not a gap: this PR only opens the seam, and feature/prd-1076-7-add-bff (commit 6e6c695d4, "feat(agent): serve a BFF in-process with addBff()") is the one that exports createInProcessTransport and lets BuildBffOptions carry a transport — accepting an override here would ship an option no caller in this branch can reach.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current PR still has no reachable transport seam: BuildBffOptions has no override and route middleware hard-codes HTTP. A follow-up branch does not resolve this PR’s stated behavior, so this remains applicable here.


return [
permissionsMiddleware,
createDataRoutesMiddleware({ store, agentUrl, timeoutMs, logger }),
createActionRoutesMiddleware({ store, agentUrl, timeoutMs, logger }),
createDataRoutesMiddleware({ store, transport, logger }),
createActionRoutesMiddleware({ store, transport, logger }),
];
}

Expand Down
10 changes: 4 additions & 6 deletions packages/agent-bff/src/data/agent-data-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import createAgentHttpRequester from '../agent/create-agent-http-requester';
import type { AgentTransport } from '../agent/agent-transport';

export interface AgentDataClientOptions {
agentUrl: string;
transport: AgentTransport;
token: string;
timeoutMs?: number;
}

export interface AgentDataClient {
Expand All @@ -29,11 +28,10 @@ export interface AgentDataClient {
* endpoint's raw payload, which `collection.count()` coerces through `Number()` and loses.
*/
export default function createAgentDataClient({
agentUrl,
transport,
token,
timeoutMs,
}: AgentDataClientOptions): AgentDataClient {
const requester = createAgentHttpRequester(token, agentUrl, timeoutMs);
const requester = transport.createRequester(token);

// Segments are passed raw: HttpRequester.buildUrl already runs the whole path through
// escapeUrlSlug/encodeURI, so pre-encoding here would double-encode (`|` -> `%257C`).
Expand Down
21 changes: 7 additions & 14 deletions packages/agent-bff/src/data/data-routes-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
RelationCountRequestBody,
RelationListRequestBody,
} from './agent-query';
import type { AgentTransport } from '../agent/agent-transport';
import type { Logger } from '../ports/logger-port';
import type { CapabilitiesResult } from '../read-model/capabilities-cache';
import type ReadModel from '../read-model/read-model';
Expand Down Expand Up @@ -43,8 +44,7 @@ const RELATION_ROUTE = /^\/agent\/v1\/([^/]+)\/relations\/([^/]+)\/(list|count)$

export interface DataRoutesMiddlewareOptions {
store: ReadModelStore;
agentUrl: string;
timeoutMs?: number;
transport: AgentTransport;
logger: Logger;
createClient?: (options: AgentDataClientOptions) => AgentDataClient;
}
Expand All @@ -53,8 +53,7 @@ interface RequestHandlerDeps {
collection: string;
client: AgentDataClient;
store: ReadModelStore;
agentUrl: string;
timeoutMs?: number;
transport: AgentTransport;
token: string;
timezone: string;
logger: Logger;
Expand Down Expand Up @@ -86,11 +85,7 @@ function resolveCapabilities(
() =>
deps.store.getCapabilities(
collection,
createAgentCapabilitiesFetcher({
agentUrl: deps.agentUrl,
token: deps.token,
timeoutMs: deps.timeoutMs,
}),
createAgentCapabilitiesFetcher({ transport: deps.transport, token: deps.token }),
),
deps.logger,
);
Expand Down Expand Up @@ -306,8 +301,7 @@ async function handleRelation(

export default function createDataRoutesMiddleware({
store,
agentUrl,
timeoutMs,
transport,
logger,
createClient = defaultCreateAgentDataClient,
}: DataRoutesMiddlewareOptions): Middleware {
Expand Down Expand Up @@ -336,10 +330,9 @@ export default function createDataRoutesMiddleware({

const deps: RequestHandlerDeps = {
collection,
client: createClient({ agentUrl, token, timeoutMs }),
client: createClient({ transport, token }),
store,
agentUrl,
timeoutMs,
transport,
token,
timezone: ctx.state.timezone as string,
logger,
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-bff/src/http/bff-local-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export function unsupportedActionResult(message = 'Unsupported action result'):
return new BffHttpError(501, 'unsupported_action_result', message);
}

export function streamingUnsupported(
message = 'Streaming is not supported over this transport',
): BffHttpError {
return new BffHttpError(501, 'streaming_unsupported', message);
}

export function actionError(message = 'The action failed', details?: unknown): BffHttpError {
return new BffHttpError(400, 'action_error', message, { details });
}
Expand Down
Loading
Loading