Skip to content

feat(adapters): adapter middleware framework (base)#1646

Closed
Glorf wants to merge 3 commits into
mainfrom
feat/adapter-base
Closed

feat(adapters): adapter middleware framework (base)#1646
Glorf wants to merge 3 commits into
mainfrom
feat/adapter-base

Conversation

@Glorf

@Glorf Glorf commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Introduces an interceptor-based middleware framework that runs at every in-tree server's request/response boundary. PR 1 of 4 — base framework only. Follow-on PRs add observability / caching / request-rewriting interceptor families.

Framework

nemo_gym/adapters/
pipeline.py AdapterPipeline + stage-order validation
(REQUEST → REQUEST_TO_RESPONSE → RESPONSE)
middleware.py install_middleware(app, specs) — FastAPI middleware
that wraps call_next; body replay, multi-Set-Cookie
preservation, /s//... session-prefix routing,
GracefulError → 429
proxy.py start_adapter_proxy(upstream_url, adapters) —
localhost uvicorn host mode for external-inference
agents whose SDK respects *_BASE_URL env vars
registry.py short-name → Interceptor class + runtime register()
types.py AdapterRequest/Response, three Interceptor ABCs,
Stage enum, GracefulError, ContextVar-backed
per-request context, InterceptorSpec and
AdapterProxyConfig pydantic models
interceptors/
endpoint.py Drives upstream HTTP call (required by proxy mode,
forbidden inside install_middleware)
request_logging.py
Canonical "did the chain fire?" probe (logs body
keys + response status/latency)

Server wire-up

adapters: list[dict] | None = None on three base configs:
BaseResponsesAPIModelConfig
BaseResponsesAPIAgentConfig
BaseResourcesServerConfig

install_middleware(app, self.config.adapters) at the tail of each
base's setup_webserver. Every in-tree server inheriting from
SimpleResponsesAPI{Model,Agent} or SimpleResourcesServer picks up
the adapters knob automatically.

adapter_proxy: AdapterProxyConfig | None = None on
BaseResponsesAPIAgentConfig. When set, SimpleResponsesAPIAgent.
setup_webserver starts a localhost uvicorn proxy in a daemon thread,
stores the ProxyHandle on self._proxy_handle, registers atexit cleanup.

Per-server override fixes

harbor_agent and mini_swe_agent override setup_webserver without
calling super(). Inline install_middleware(app, self.config.adapters)
calls added to both so the adapters field still applies.

claude_code_agent integration

Reads self._proxy_handle.url when adapter_proxy is set; threads the
proxy URL into the claude CLI subprocess via ANTHROPIC_BASE_URL +
ANTHROPIC_AUTH_TOKEN. Preserves the full model-name prefix in proxy
mode (no .split("/")[-1] stripping).

Safety

  • Stage ordering validated at startup
  • Unknown interceptor name raises at config-validation time
  • install_middleware rejects endpoint in chain (host already forwards)
  • start_adapter_proxy rejects user-supplied endpoint AND refuses host="0.0.0.0" unless unsafe_allow_remote=True (otherwise leaks the upstream API key to any caller on the network)
  • best_effort=True interceptors swallow exceptions; strict ones propagate

Tests

42 tests, framework-level coverage. Follow-on PRs add per-interceptor
test suites.

Docs

fern/versions/latest/pages/model-server/adapters.mdx new
fern/versions/latest/pages/model-server/index.mdx link added


Recreated with an upstream head so it can join the GitHub stack (supersedes #1518, which had a fork head).

@copy-pr-bot

copy-pr-bot Bot commented Jun 22, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions

Copy link
Copy Markdown

🌿 Preview your docs: https://nvidia-preview-feat-adapter-base.docs.buildwithfern.com/nemo/gym

Here are the markdown pages you've updated:

@Glorf Glorf force-pushed the feat/adapter-base branch from 35a1b91 to 8e7dee2 Compare June 22, 2026 14:19
@ffrujeri ffrujeri self-requested a review June 22, 2026 16:16
@ffrujeri

Copy link
Copy Markdown
Contributor

🌿 Preview your docs: https://nvidia-preview-feat-adapter-base.docs.buildwithfern.com/nemo/gym

Here are the markdown pages you've updated:

Hey @Glorf, thank you for the docs. Will we also have pointers for the SimpleResponsesAPIAgent, or SimpleResourcesServer somewhere in the docs? Or maybe we should move the adapter framework one level up on the docs organization.

@ffrujeri

ffrujeri commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

@Glorf, Could you link a small Design Note here as well?

  • One initial question I had is the overhead added by those extra http proxy servers. Are there any concerns about it? Both in terms of latency and throughput?

  • Also I think we should watch for adapter/proxy code drifting into provider-specific semantic translation, right? The responses_api_models are responsible for model translation protocol level. My understanding of your interceptor / adapter framework to be on the HTTP level itself I guess, right? This is relevant because we should probably follow the principle of No translation in the proxy And Messages↔Responses stays in anthropic_converter in PR feat: Add a default /v1/messages (Anthropic Messages) route to the base Gym… #1627 (single source). @linj-glitch to review this PR as well

  • Regarding the relationship with feat: Add a default /v1/messages (Anthropic Messages) route to the base Gym… #1627, the endpoint interceptor also forward /v1/messages path-preservingly? Or only the /v1/chat/completions or /v1/responses?

  • How about (text/event-stream) SSE? Does this change pass SSE through?
    If I read the EPIC correctly this is not supported right?

    • The SSE body fails json.loads → falls back to raw bytes → returned with upstream text/event-stream intact. So:
      • short outputs: well-formed, parses fine (delivered all-at-once);
      • long generations: first byte reaches the CLI only after the upstream completes → risk of the
        Claude CLI's first-event/idle timeout → apparent hang (this is the EPIC's "buffers, no SSE");
    • response-stage interceptors see raw bytes for SSE (not a dict) → streamed-Messages capture
      is degraded
      ; interceptors need a non-dict guard. The proxy is not SSE-aware.

@ffrujeri

ffrujeri commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

My current understanding of the Topology is something like this:

ng_collect_rollouts → ClaudeCodeAgent.run()
   ├─ start adapter proxy on 127.0.0.1:<pp>                         [#1646]
   │     endpoint interceptor target ← _resolve_base_url()          [/v1/messages PR: the model_server URL]
   └─ spawn `claude -p`  with  ANTHROPIC_BASE_URL = http://127.0.0.1:<pp>   [#1646]

   claude -p ──POST /v1/messages (Anthropic, SSE)──▶ [adapter proxy]              [#1646]
                          request_logging (capture) ─▶ endpoint (forward, path-preserving)
                                     │
                                     ▼  POST {model_server}/v1/messages
                          [Gym model server] messages():                          [/v1/messages #1627]
                                Messages→Responses → self.responses() → Responses→Messages → SSE
                                     │
                                     ▼
                                backend (vLLM / OpenAI / …)

Is that correct? In this case, should we handle how the proxy take its upstream url from a resolved model_server?

@@ -259,8 +259,10 @@ def _resolve_base_url(self) -> str:
async def _run_claude_code(self, instruction: str, system_prompt: Optional[str] = None) -> tuple[str, str]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When _proxy_handle is set, base_url (resolved from the model_server ref via _resolve_base_url()) is computed here but then discarded below — proxy_or_base = self._proxy_handle.url wins, and the proxy's own upstream comes from the static adapter_proxy.upstream_url. So model_server and the proxy are two independent notions of "where the model is," and they don't feed each other.

That's fine for a fixed/external upstream, but it means proxy mode can't target an in-cluster Gym model server resolved from model_server (OS-assigned port) — e.g. one exposing /v1/messages. The dynamic URL just isn't expressible via the static upstream_url.

Is "external upstreams only" the intended scope for this PR? If we later want the capture proxy in front of a Gym model server, the seam is small — either upstream = self._resolve_base_url() or cfg.upstream_url here, or an upstream_model_server: Optional[ModelServerRef] on AdapterProxyConfig resolved at setup_webserver. Non-blocking — just flagging it so the decoupling is a deliberate choice.

@ffrujeri ffrujeri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Adapter middleware framework — review summary

Thanks for this — it's a clean, well-structured base framework and the test suite genuinely exercises the hard HTTP behaviors (body replay, multi-Set-Cookie preservation, /s/<hex>/ parsing, GracefulError→429) rather than smoke-testing.

Two correctness items worth a look before merge:

  • Session-prefixed paths 404 in middleware mode unless an interceptor short-circuits (middleware.py:185).
  • get_context() diverges from req.ctx because process() drops ctx.extra (pipeline.py:103) — session-aware interceptors using the global accessor lose session_id.

The rest are medium/minor: a GracefulError→429-vs-500 asymmetry between middleware and proxy modes, hardcoded content:null normalization in the transport path (vs the "no translation in the proxy" principle), endpoint being unreachable while the docs say it's required by start_adapter_proxy, adapter_proxy being silently ignored on harbor_agent/mini_swe_agent, plus assorted nits.

Notes:

  • Coverage of the adapters package sits at exactly the 96% fail_under floor with zero headroom (proxy.py 90%); see the inline coverage note.

path = request.url.path
ctx = InterceptorContext()

m = _SESSION_PATH_RE.match(path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/middleware.py:185

[BUG] Session-prefixed paths 404 in middleware mode. /s/<hex>/ is stripped into adapter_req.path + ctx.extra["session_id"], but request.scope["path"] is never rewritten, so the default _upstream's call_next(request) routes on the original prefixed path → FastAPI 404. This works only if an interceptor short-circuits; with the shipped logging/endpoint interceptors, POST /s/deadbeef/v1/chat/completions → 404 (reproduced). The adapters.mdx doc presents Path-Based Session Scoping as a working feature. Suggested fix in _upstream before call_next:

Suggested change
m = _SESSION_PATH_RE.match(path)
async def _upstream(req: AdapterRequest) -> AdapterResponse:
new_body = json.dumps(req.body).encode("utf-8")
_override_request_body(request, new_body)
# Re-point the ASGI scope at the prefix-stripped path so the
# router can match it (session prefix is middleware-only).
request.scope["path"] = req.path
request.scope["raw_path"] = req.path.encode("latin-1")
starlette_resp = await call_next(request)
return await _starlette_response_to_adapter(starlette_resp, req.ctx)

Comment thread nemo_gym/adapters/pipeline.py Outdated
is invoked with the (possibly mutated) request. Response interceptors
then run in reverse order.
"""
ctx = InterceptorContext(request_id=request.ctx.request_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/pipeline.py:103

[BUG] process() builds a fresh InterceptorContext copying only request_id, not request.ctx.extra, so get_context() returns an empty ctx (no session_id) while req.ctx is populated — req.ctx is get_context()False (reproduced). Any session-aware interceptor using the documented get_context() accessor silently gets nothing, which undermines the session-scoping feature above. Reusing the request's own ctx fixes it:

Suggested change
ctx = InterceptorContext(request_id=request.ctx.request_id)
set_context(request.ctx)

Secondary nit (pipeline.py:104): set_context() is never paired with a ContextVar.reset(). Low impact today (Starlette gives each request its own task/context), but consider capturing the token and resetting in a finally so the var doesn't persist after process() returns.

retry_on_status: list[int] | None = None,
max_concurrent: int = 64,
) -> None:
clean = upstream_url.rstrip("/")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/interceptors/endpoint.py:48

[BUG] upstream_url only has /chat/completions|/completions|/embeddings suffixes stripped, then intercept_request builds f"{self._upstream_url}{req.path}" with req.path already starting /v1/.... A common base like http://host:8000/v1 yields http://host:8000/v1/v1/chat/completions (reproduced; the double-prefixed URL also appears unasserted in this PR's endpoint test logs). The same pattern is reachable in proxy mode via adapter_proxy.upstream_url (proxy.py:214). Could you either also strip a trailing /v1 or document the expected upstream_url format (bare host root)?


try:
resp = await pipeline.process(adapter_req, upstream_call=_upstream)
except Exception:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/proxy.py:247

[BUG] GracefulError is mapped to a 429 (session_budget_exhausted) in middleware.py:206, but here the bare except Exception turns the same error into a 500. The same interceptor chain returns different statuses depending on host mode. Consider adding a except GracefulError: arm mirroring the middleware path. (These lines are also currently uncovered — see the coverage note.)

Comment thread nemo_gym/adapters/proxy.py Outdated
except (ValueError, UnicodeDecodeError):
parsed = raw

if isinstance(parsed, dict):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/proxy.py:231

[GUIDELINE] This content: null → "" normalization is hardcoded unconditionally in the transport-forwarding path (here and again in endpoint.py:65-69,127) and duplicated across both. That's semantic payload mutation in the proxy, which runs against the "no translation in the proxy — translation stays in anthropic_converter" principle. There's also a third asymmetry: middleware.py does not normalize, so middleware-mode responses keep content: null. Consider moving this into an opt-in ResponseInterceptor so the behavior is uniform and explicit.

_server: uvicorn.Server
_thread: threading.Thread

def stop(self, timeout: float = 5.0) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/proxy.py:72

[ASYNC, minor] ProxyHandle.stop has no idempotency guard and no log if join(timeout=5) fails to exit — a silent daemon-thread/port leak for a long-lived process that restarts proxies. Minor (the daemon thread dies with the process), but a guard + a warning on join timeout would help.

headers.setdefault("Content-Type", "application/json")

attempt = 0
while True:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/interceptors/endpoint.py:86

[ASYNC, minor] This retry loop stacks on top of global_request's built-in 3-try retry, so on exception paths you can get ~3×(max_retries+1) attempts with two backoff schemes. Fine at the default max_retries=0; worth a comment so users who raise max_retries understand the multiplier.

"""

upstream_url: str
adapters: list[InterceptorSpec] = Field(default_factory=list)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/types.py:137

[CONFIG, minor] adapters is typed list[dict] on the three base configs but list[InterceptorSpec] here in AdapterProxyConfig. The raw-dict form gets no Hydra schema validation until the runtime registry name lookup — consider unifying on InterceptorSpec for the top-level adapters too.

return 200 <= self.status_code < 400


class Stage(enum.Enum):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/types.py:78

[DOCSTRING, nit] A few public API symbols lack docstrings: Stage, RequestInterceptor, ResponseInterceptor, get_context/set_context (here in types.py), plus endpoint.Interceptor (and its no-op close() stub, which has no caller) and request_logging.Interceptor. Short prose docstrings would help since these are the framework's extension surface.


t0 = time.perf_counter()
async with session.post(target, data=_json.dumps(req.body), headers=fwd_headers) as resp:
raw = await resp.read()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nemo_gym/adapters/proxy.py:220

[DESIGN] Reinforcing the design-note question on SSE: the proxy fully buffers (raw = await resp.read() here, then json.loads with a raw-bytes fallback at :227) — no incremental streaming. For a long generation the Claude CLI receives nothing until upstream completes, which risks its first-event/idle timeout (apparent hang). The shipped logging interceptor already guards bytes (request_logging.py:34) so it won't crash, but capture degrades to a 512-char preview, and any future dict-assuming ResponseInterceptor would break on an SSE body. Likely intended scope for PR 1 — flagging so the SSE limitation is a deliberate, documented choice.

@Glorf Glorf force-pushed the feat/ecs-fargate-sandbox branch from a80772d to 11124f9 Compare June 23, 2026 08:41
@Glorf Glorf requested a review from a team as a code owner June 23, 2026 08:41
@Glorf Glorf force-pushed the feat/adapter-base branch from 8e7dee2 to 4916143 Compare June 23, 2026 09:06
Glorf added a commit that referenced this pull request Jun 23, 2026
Per @ffrujeri's review of #1646:
- middleware: re-point the ASGI scope path so /s/<hex>/ session-prefixed
  requests route to the real handler instead of 404'ing
- pipeline: reuse request.ctx so get_context() exposes ctx.extra (session_id);
  never swallow GracefulError under best_effort (both phases)
- proxy: map GracefulError -> 429 (parity with middleware mode); preserve the
  query string when forwarding to the upstream
- endpoint: strip a trailing /v1 from upstream_url (avoid /v1/v1); guard the
  Retry-After parse against HTTP-date values
- drop the proxy/endpoint content:null normalization (translation belongs in
  the /v1/messages route, not the transport proxy)
- tests: lock in each fix + add claude_code env/model-name threading coverage;
  adapters package coverage 95% -> 100%

Signed-off-by: Michal Bien <mbien@nvidia.com>
Glorf added 2 commits June 23, 2026 13:51
Introduces an interceptor-based middleware framework that runs at every
in-tree server's request/response boundary. PR 1 of 4 — base framework
only. Follow-on PRs add observability / caching / request-rewriting
interceptor families.

Framework

  nemo_gym/adapters/
    pipeline.py      AdapterPipeline + stage-order validation
                     (REQUEST → REQUEST_TO_RESPONSE → RESPONSE)
    middleware.py    install_middleware(app, specs) — FastAPI middleware
                     that wraps call_next; body replay, multi-Set-Cookie
                     preservation, /s/<hex>/... session-prefix routing,
                     GracefulError → 429
    proxy.py         start_adapter_proxy(upstream_url, adapters) —
                     localhost uvicorn host mode for external-inference
                     agents whose SDK respects *_BASE_URL env vars
    registry.py      short-name → Interceptor class + runtime register()
    types.py         AdapterRequest/Response, three Interceptor ABCs,
                     Stage enum, GracefulError, ContextVar-backed
                     per-request context, InterceptorSpec and
                     AdapterProxyConfig pydantic models
    interceptors/
      endpoint.py    Drives upstream HTTP call (required by proxy mode,
                     forbidden inside install_middleware)
      request_logging.py
                     Canonical "did the chain fire?" probe (logs body
                     keys + response status/latency)

Server wire-up

  adapters: list[dict] | None = None  on three base configs:
    BaseResponsesAPIModelConfig
    BaseResponsesAPIAgentConfig
    BaseResourcesServerConfig

  install_middleware(app, self.config.adapters)  at the tail of each
  base's setup_webserver. Every in-tree server inheriting from
  SimpleResponsesAPI{Model,Agent} or SimpleResourcesServer picks up
  the adapters knob automatically.

  adapter_proxy: AdapterProxyConfig | None = None  on
  BaseResponsesAPIAgentConfig. When set, SimpleResponsesAPIAgent.
  setup_webserver starts a localhost uvicorn proxy in a daemon thread,
  stores the ProxyHandle on self._proxy_handle, registers atexit cleanup.

Per-server override fixes

  harbor_agent and mini_swe_agent override setup_webserver without
  calling super(). Inline install_middleware(app, self.config.adapters)
  calls added to both so the adapters field still applies.

claude_code_agent integration

  Reads self._proxy_handle.url when adapter_proxy is set; threads the
  proxy URL into the claude CLI subprocess via ANTHROPIC_BASE_URL +
  ANTHROPIC_AUTH_TOKEN. Preserves the full model-name prefix in proxy
  mode (no .split("/")[-1] stripping).

Safety

  - Stage ordering validated at startup
  - Unknown interceptor name raises at config-validation time
  - install_middleware rejects `endpoint` in chain (host already forwards)
  - start_adapter_proxy rejects user-supplied `endpoint` AND refuses
    host="0.0.0.0" unless unsafe_allow_remote=True (otherwise leaks
    the upstream API key to any caller on the network)
  - best_effort=True interceptors swallow exceptions; strict ones
    propagate

Tests

  42 tests, framework-level coverage. Follow-on PRs add per-interceptor
  test suites.

Docs

  fern/versions/latest/pages/model-server/adapters.mdx       new
  fern/versions/latest/pages/model-server/index.mdx          link added

Signed-off-by: Michal Bien <mbien@nvidia.com>
Raise core coverage past the 96% gate (93.29% -> 96%) with real unit tests
for the previously-untested adapter paths:

- endpoint interceptor: upstream-URL suffix stripping, None-content
  normalization, success path (body/header merge, api-key injection,
  hop-by-hop stripping), retry-on-status, timeout -> 504, ClientError
  raise/retry, and close().
- adapter internals: lazy context creation in a fresh contextvars.Context,
  AdapterResponse.ok, request_logging._trunc_preview branches, middleware
  request/response conversion (streaming/plain/non-json bodies, bytes body)
  and the endpoint guard, proxy host/endpoint guards + _bind_port/
  _wait_for_health, and registry/pipeline error + best-effort paths.

All upstream HTTP/SSH is mocked; tests are offline and deterministic.

Signed-off-by: Michal Bien <mbien@nvidia.com>
@Glorf Glorf force-pushed the feat/adapter-base branch from 2399ce2 to 45c8b8c Compare June 23, 2026 11:54
@Glorf Glorf changed the base branch from feat/ecs-fargate-sandbox to main June 23, 2026 11:54
Glorf added a commit that referenced this pull request Jun 23, 2026
Per @ffrujeri's review of #1646:
- middleware: re-point the ASGI scope path so /s/<hex>/ session-prefixed
  requests route to the real handler instead of 404'ing
- pipeline: reuse request.ctx so get_context() exposes ctx.extra (session_id);
  never swallow GracefulError under best_effort (both phases)
- proxy: map GracefulError -> 429 (parity with middleware mode); preserve the
  query string when forwarding to the upstream
- endpoint: strip a trailing /v1 from upstream_url (avoid /v1/v1); guard the
  Retry-After parse against HTTP-date values
- drop the proxy/endpoint content:null normalization (translation belongs in
  the /v1/messages route, not the transport proxy)
- tests: lock in each fix + add claude_code env/model-name threading coverage;
  adapters package coverage 95% -> 100%

Signed-off-by: Michal Bien <mbien@nvidia.com>
@Glorf Glorf force-pushed the feat/adapter-base branch from 45c8b8c to 73051c7 Compare June 23, 2026 12:29
Per @ffrujeri's review of #1646:
- middleware: re-point the ASGI scope path so /s/<hex>/ session-prefixed
  requests route to the real handler instead of 404'ing
- pipeline: reuse request.ctx so get_context() exposes ctx.extra (session_id);
  never swallow GracefulError under best_effort (both phases)
- proxy: map GracefulError -> 429 (parity with middleware mode); preserve the
  query string when forwarding to the upstream
- endpoint: strip a trailing /v1 from upstream_url (avoid /v1/v1); guard the
  Retry-After parse against HTTP-date values
- drop the proxy/endpoint content:null normalization (translation belongs in
  the /v1/messages route, not the transport proxy)
- tests: lock in each fix + add claude_code env/model-name threading coverage;
  adapters package coverage 95% -> 100%

Signed-off-by: Michal Bien <mbien@nvidia.com>
@Glorf Glorf force-pushed the feat/adapter-base branch from 73051c7 to c4313fb Compare June 23, 2026 12:38
@Glorf Glorf closed this Jun 24, 2026
Glorf added a commit that referenced this pull request Jul 8, 2026
ECS Fargate `SandboxProvider` on top of the sandbox API (#1377, now
merged to `main`). Auto-mirrors public images to ECR on demand; SSH
reverse-tunnel for exec / file-transfer / model egress.

Rebased onto `main` now that the sandbox base (#1377) has landed — this
PR stands on its own and is ready for review.

**Sandbox-bound agents line of work:**
**ECS Fargate (this PR)** → adapter middleware base (#1646) →
sandbox-bound CLI agents + capture (#1647). Interceptor follow-ups on
the adapter base: #1649 (caching), #1650 (observability), #1651
(rewrites).

3 commits, +5974: ECS provider + engine, on-demand ECR mirroring + conda
activation fix, and 233 unit tests (engine.py to 99%). `uv.lock`
reconciled with main's security upgrades (#1657).

---------

Signed-off-by: Michal Bien <mbien@nvidia.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants