Skip to content

feat(routing): toAiGateway() terminator for BoxLang AI Gateways - #694

Merged
lmajano merged 2 commits into
developmentfrom
claude/coldbox-agent-gateway-support-twnh7c
Sep 10, 2026
Merged

feat(routing): toAiGateway() terminator for BoxLang AI Gateways#694
lmajano merged 2 commits into
developmentfrom
claude/coldbox-agent-gateway-support-twnh7c

Conversation

@lmajano

@lmajano lmajano commented Sep 10, 2026

Copy link
Copy Markdown
Member

Description

Exposes a BoxLang AI Gateway over HTTP natively from the routing DSL, alongside toAi() and toMCP(). Until now an application had to hand-write the routes plus a passthrough handler for the gateway surface: that is exactly what BX Agents generates into every project it builds, with a comment saying ColdBox has no toAiGateway() terminator.

// One mount serving every gateway registered in aiGatewayRegistry()
route( "/gateways" ).toAiGateway( session: "SupportAgentSession" );

// Pinned to one gateway: POST/GET /webhooks/slack/events
route( "/webhooks/slack" ).toAiGateway( "slack", "SupportAgentSession" );

// Verify and parse only, dispatching nothing
route( "/gateways" ).toAiGateway();

Registers, inheriting any modifiers already set (withCondition, withDomain, withSSL, meta, module/namespace):

Verb Pattern Route name
GET, POST {pattern}[/:gateway]/events {base}.gateway.events
GET {pattern}/interactions/:requestID {base}.gateway.interaction
POST {pattern}/interactions/:requestID/decisions {base}.gateway.decision
GET {pattern}/info {base}.gateway.info

Design notes

  • GET and POST share /events because a platform is given ONE URL to store and verifies it with a GET before it ever POSTs to it (Meta's hub.challenge echo being the canonical case). Two routes sharing a pattern would merge into one in addRoute() anyway, losing the second closure, so the verb branch lives inside the closure.
  • A pinned gateway name always wins over the :gateway placeholder or a stray gateway value in the request collection, so a request can never redirect a pinned mount at another gateway.
  • session is resolved per request, so registering a route never forces the session to be constructed. Passing it means every inbound message is dispatched as an agent turn and acked 202 immediately, without waiting on the turn: a platform webhook times out in seconds while an agent turn does not. Omitting it parses without dispatching.
  • Routes carry gateway / gatewayName / gatewaySession metadata, and RoutingService logs them the same way it logs ai and mcp routes.

Related bug fix. A route response closure that rendered the response itself (event.renderData(...)) had its status code and content type flattened back onto the route's static statusCode by the router's own renderData() call at the end of renderResponse(). Render data set during the closure is now respected, which is what lets a gateway route answer 401 on a bad signature and text/plain on a handshake. Render data an interceptor set before the route ran is deliberately left alone.

Dependency. BoxLang only, and requires the bxai module, same guard toAi()/toMCP() use. The processing itself lives in bx-ai: this terminator calls the transport-agnostic statics from ortus-boxlang/bx-ai#286, now merged.

Jira Issues

Not filed yet: the enabling bx-ai half is ortus-boxlang/bx-ai#286, and this is the framework side. Happy to open a COLDBOX issue and link it if you want one tracked before merge.

Type of change

  • Improvement
  • New Feature
  • This change requires a documentation update

Checklist

  • My code follows the style guidelines of this project cfformat
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

On that last box, precisely what was and wasn't verified locally. The TestBox suite itself could not run here (no engine or server available in this environment), so CI is the first real execution of RouterGatewayTest.cfc. Everything else was checked against the real tools:

  • cfformat. Run locally at the pinned v0.22.1 against this repo's own .cfformat.json, and the three changed files are formatter-stable under it. One caveat worth flagging: that local copy also wants to change lines this PR never touched ({ GET : "edit" }{ GET : "edit" } in resources(), and similar inline structs elsewhere) which CI's copy evidently accepts as-is, so its output was applied only inside this PR's own code and reverted everywhere else.
  • Adobe compatibility. The first CI run caught a real one: Adobe 2023/2025 cannot compile a member call on a parenthesized expression (( struct ?: {} ).each( ... )), while BoxLang, BoxLang-CFML and Lucee 5/6 all accepted it. Fixed in the second commit, along with the same class of problem in the test (an arrow function whose body was an assignment).
  • Route registration. Executed for real on BoxLang against a patched copy of the router (the bxai guard stubbed, since the module is not installed here), before and after formatting. Every value the new spec asserts came back from that run: patterns (gateways/:gateway/events/, gateways/interactions/:requestID/, gateways/interactions/:requestID/decisions/, gateways/info/), verbs (GET,POST / GET / POST / GET), route names, patternParams, ssl and condition inheritance, the gateway/gatewayName/gatewaySession metadata, and both InvalidArgumentException guards.
  • Compilation. All three changed files compile clean under the BoxLang compiler.

tests/specs/web/routing/RouterGatewayTest.cfc mirrors RouterAITest.cfc (same skip="notBoxlang" guard and mocked controller) and covers registration, verbs, naming, modifier inheritance, argument validation, and the private per-request resolvers.

🤖 Generated with Claude Code

https://claude.ai/code/session_015nRppywTeuVpXgVG24Wm9W

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Test Results

0 tests  ±0   0 ✅ ±0   0s ⏱️ ±0s
0 suites ±0   0 💤 ±0 
0 files   ±0   0 ❌ ±0 

Results for commit e0216e3. ± Comparison against base commit d5cd7b0.

♻️ This comment has been updated with latest results.

lmajano commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

CI status on 6eb213e, and one failure I am standing down on.

Fixed by this push: Checks Source Code Formatting is green, and adobe@2023 and adobe@2025 now compile and run the suite (867 tests, versus the parse error that produced 0 before). BoxLang, BoxLang-CFML and Lucee 5/6 are green.

Still red in one run: tests / Test Suites (adobe@2025, 21, false, 8) in run 34514237784 — 865 passed, 1 skipped, 1 failed.

I do not believe that failure belongs to this PR:

  • The same commit passed that exact job in the parallel run 34514235236 (tests / Test Suites (adobe@2025, 21, false, 8) → success). Push and pull_request both trigger the suite, so 6eb213e has one green and one red result for the same job on the same code.
  • This PR's only new spec, RouterGatewayTest.cfc, carries the same skip="notBoxlang" guard as RouterAITest.cfc, so it does not execute on Adobe at all — it is the run's 1 skipped entry.
  • The rest of the diff is BoxLang-gated at runtime behind ensureBoxLang(). The one part that could affect Adobe was compilation, and that is what the previous run caught and this push fixed.

I could not name the individual failing spec: the job log's readable tail is the test harness's own server-log dump, and the annotations on the TestBox Report check are not reachable from here. Worth noting the harness contains a deliberately random-failing scheduled task (test-harness/config/Scheduler.cfc — "I am throwing up randomly!"), and that task's totalFailures={1.0} appears in this job's log.

I also could not re-run the failed job to confirm: the API returns 403 Resource not accessible by integration for re-runs on this repository. Given the sibling run already passed on this SHA, that re-run would be confirming something the parallel run has effectively already shown. I will keep watching the PR either way, and if that job goes red again on a fresh push with a named spec I will chase it properly rather than calling it a flake twice.


Generated by Claude Code

lmajano commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

Follow-up with the actual failing spec, now that I can read the full job log rather than just its tail.

Failing spec: tests/specs/web/routing/RoutingServiceTest.cfc → "Routing Services > preserves the request domain when resolving module routes"

Error: Error loading module routes as the module requested 'domainRoutingTest' is not loaded.
  at system/web/routing/Router.cfc:323 (addModuleRoutes)
  at tests/specs/web/routing/RoutingServiceTest.cfc:305

This confirms it is not this PR's:

  • The file isn't touched by this PR at all. RoutingServiceTest.cfc isn't in the diff, and Router.cfc:323 is addModuleRoutes()'s existing module-registered guard — nowhere near toAiGateway() (this PR's only additions start around line 2870).
  • The failing spec builds its own fake module. It constructs a throwaway mockController and manually stuffs a domainRoutingTest module into getSetting( "modules" ) before calling addModuleRoutes() — a self-contained mock with no dependency on anything this PR changes.
  • The same commit (6eb213e) passed this exact job in the parallel run. 34514235236 (tests / Test Suites (adobe@2025, 21, false, 8)) succeeded; 34514237784 (same job, same SHA, the pull_request-triggered run) is the one that failed here. Identical code, two different outcomes — that's order-dependence or shared-mock-state flakiness in the existing suite, not something introduced by this diff.

I still don't have the means to re-run just that job (rerun_failed_jobs returns 403 Resource not accessible by integration for this integration), but given the same SHA already has a green run of the identical job, I'm treating this as confirmed pre-existing flakiness rather than spending the one re-run I'm allowed on it. Continuing to watch the PR.


Generated by Claude Code

lmajano and others added 2 commits September 10, 2026 20:22
Exposes a BoxLang AI Gateway over HTTP natively, alongside toAi() and toMCP().
Until now an application had to hand-write the routes and a passthrough handler
for the gateway surface, which is exactly what BX Agents generates per project.

route( "/gateways" ).toAiGateway( session: "SupportAgentSession" ) registers:

  GET/POST {pattern}[/:gateway]/events                 platform handshake + inbound
  GET      {pattern}/interactions/:requestID           poll a pending approval
  POST     {pattern}/interactions/:requestID/decisions submit a human decision
  GET      {pattern}/info                              what this mount serves

GET and POST share the /events path because a platform is given ONE URL and
verifies it with a GET before it ever POSTs to it. Pin a mount to one gateway
with toAiGateway( "slack" ), or leave the name out and the terminator inserts a
:gateway placeholder so one mount serves every registered gateway. A pinned name
always wins over the placeholder, so a request cannot redirect a pinned mount.

Pass a session (a WireBox ID resolved per request, or a live GatewaySession) and
every inbound message is dispatched as an agent turn and acked 202 immediately,
without waiting on the turn: a platform webhook times out in seconds while an
agent turn does not. Without one, events are verified and parsed only.

Also fixes a related routing bug: a response closure that rendered the response
itself had its status code and content type flattened back onto the route's
static statusCode by the router's own renderData() call. Render data set during
the closure is now respected, which is what lets a gateway route answer 401 on a
bad signature and text/plain on a handshake. Render data an interceptor set
before the route ran is left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nRppywTeuVpXgVG24Wm9W
…ormat

Two CI failures on the previous commit:

- Adobe 2023/2025 could not compile Router.cfc: a member call on a
  parenthesized expression, `( arguments.result.headers ?: {} ).each( ... )`,
  is not valid CFML there. The struct goes into a local first. The router test
  had the same class of problem in an arrow function whose body was an
  assignment, so that is now a block body.
- cfformat rejected all three files. Formatted with cfformat 0.22.1 against the
  repo's own .cfformat.json: the over-long handshake call is split, the info
  payload's struct keys align, and the test drops method chains that the
  formatter re-wraps.

Also narrows the RoutingService change: the render-data tracking now lives
inside the closure branch that uses it, so it no longer joins (and re-aligns)
an assignment group it has no business touching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nRppywTeuVpXgVG24Wm9W
@lmajano
lmajano force-pushed the claude/coldbox-agent-gateway-support-twnh7c branch from 6eb213e to e0216e3 Compare September 10, 2026 20:24

lmajano commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

Rebased onto development (e0216e3, now on top of d5cd7b0 / #692 "preserve grouped domains on fluent routes"). No conflicts; recompiled and re-ran cfformat and the BoxLang route-registration smoke test against the rebased code with identical results to before.

The same pre-existing failure recurred post-rebase — this time on both adobe@2023 and adobe@2025 in one workflow run, while the parallel run's adobe@2023 passed on the identical commit. Same spec, same message as previously reported:

tests/specs/web/routing/RoutingServiceTest.cfc
"preserves the request domain when resolving module routes"
Error: Error loading module routes as the module requested 'domainRoutingTest' is not loaded.

I dug into why it's flaky rather than just noting the coincidence again. The spec (RoutingServiceTest.cfc:286-327) does this:

var modules = getController().getSetting( "modules" )   // the REAL app's live, shared struct
modules[ moduleName ] = { resources: [], routes: [] }    // mutated in place
router.addModuleRoutes( ... )                            // re-reads getSetting("modules") independently
// ...
router.removeModuleRoutes( moduleName )
structDelete( modules, moduleName )                       // cleanup, same captured reference

getController() here is the one real, shared application controller for the whole suite (this class extends BaseIntegrationTest, no per-spec isolation), and the spec captures a reference to its live modules struct rather than a private copy. If application settings get reloaded during the run — and the harness log shows scheduled tasks (ProcessJobs, testharness-Heartbeat) firing every 15-20s throughout the suite — getSetting( "modules" ) could hand back a new struct instance on reload, while this spec's captured modules variable still points at the old one. The injected entry lands in the stale struct; addModuleRoutes()'s own getSetting( "modules" ) call reads the fresh one and never sees it. That race would produce exactly this error, exactly this nondeterministically (worse odds the more often it runs, hence hitting different engines/jobs each time), and it's entirely inside a test this PR doesn't touch.

Not fixing it here — it's a pre-existing isolation bug in an unrelated spec, and patching it would widen this PR's scope. Flagging the mechanism in case it's worth its own issue. Every other check remains green (BoxLang, BoxLang-CFML, Lucee 5/6, formatting), and I'll keep watching.


Generated by Claude Code

@lmajano
lmajano merged commit b7680aa into development Sep 10, 2026
25 of 28 checks passed
@lmajano
lmajano deleted the claude/coldbox-agent-gateway-support-twnh7c branch September 10, 2026 20:40
lmajano added a commit to ortus-boxlang/bx-agents that referenced this pull request Sep 10, 2026
route().toAiGateway() (ColdBox/coldbox-platform#694) just merged to
development but hasn't shipped in a release yet, so the test harness's
pinned coldbox-8.1.0.zip predates it entirely. Point tests/box.json at
ColdBox's "be" bleeding-edge build instead of the versioned release zip,
matching how other modules in this ecosystem track unreleased core
features (bx-ai's own devDependencies pin testbox the same way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nRppywTeuVpXgVG24Wm9W
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant