-
Notifications
You must be signed in to change notification settings - Fork 12
feat(agent-bff): let the agent transport be injected #1872
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/prd-1076-2-lazy-environment-id
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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), | ||
| }; | ||
| } |
| 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 { | ||
| 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, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( To be fair to the design: it is not actually unbounded today. The finding is that
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Took the contract option: |
||
| }); | ||
|
|
||
| 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), | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -260,8 +261,10 @@ function toUnfoldSource( | |
|
|
||
| return { | ||
| store: bundle.store, | ||
| agentUrl: config.agentUrl, | ||
| timeoutMs: config.agentTimeoutMs, | ||
| transport: createHttpTransport({ | ||
| agentUrl: config.agentUrl, | ||
| timeoutMs: config.agentTimeoutMs, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The rewiring dropped the assertions that used to protect it: the tests that asserted One assertion on the constructed transport's
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: |
||
| }), | ||
| logger, | ||
| }; | ||
| } | ||
|
|
@@ -312,10 +315,12 @@ function buildAgentRouteMiddlewares( | |
| return [permissionsMiddleware, createAgentStubMiddleware()]; | ||
| } | ||
|
|
||
| const transport = createHttpTransport({ agentUrl, timeoutMs }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High BFF data and action routes always use an HTTP socket, so embedding callers cannot route requests through the in-process dispatcher. 🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deliberate staging, not a gap: this PR only opens the seam, and There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current PR still has no reachable transport seam: |
||
|
|
||
| return [ | ||
| permissionsMiddleware, | ||
| createDataRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), | ||
| createActionRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), | ||
| createDataRoutesMiddleware({ store, transport, logger }), | ||
| createActionRoutesMiddleware({ store, transport, logger }), | ||
| ]; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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, sametimezonedefault, samestatus >= 400branch, samestream()refusal — and the copies already disagree on the one line that matters: mcp-server passespathraw, this one escapes it. Both dispatch into the sameInProcessDispatcher, 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/AgentDispatchResponsestructurally 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
createInProcessTransporthas 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.There was a problem hiding this comment.
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, andpathToRegexp('/forest/a\\+b').regexpmatches/forest/a+band 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 isescapeUrlSlug/buildUrlin 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.