Skip to content

feat(headless): RSI prompt-optimization loop, validated end-to-end (#64) - #149

Merged
Astro-Han merged 37 commits into
mainfrom
claude/rsi-loop-integration
Jun 23, 2026
Merged

feat(headless): RSI prompt-optimization loop, validated end-to-end (#64)#149
Astro-Han merged 37 commits into
mainfrom
claude/rsi-loop-integration

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What

Implements the v1 RSI prompt-optimization loop (Issue #64): an unattended loop that lets a meta-agent iteratively rewrite a benchmark system prompt, evaluating each candidate on cached Terminal-Bench tasks via Harbor + a real DeepSeek v4-flash backend in Docker, and accepting/discarding candidates under a rigorous policy.

Pieces

  • Host-side HarborTaskRunner that drives harbor run, with secret-safe API-key files (never argv/env values), per-trial pricing, and configurable cell timeouts.
  • Real DeepSeek v4-flash meta-agent + top-level loop driver: baseline calibration (N sweeps → per-partition noise band) → per-round candidate commit → held-in/held-out evaluation → reward-hack canary scan → acceptance policy (dual noise bands, coverage, held-out floor) → WAL append → budget/infra stop guards → structural smoke report.
  • Production-robustness guards: stability filter (drop tasks that don't complete in baseline), minimum-stable-task floor, optional duration cap (drop pathologically slow tasks from rounds), infra-failure-rate guard, cost ceiling, duplicate-task-id rejection, and a no-canary held-in drop.
  • Runtime fixes: pause the stream-idle watchdog during all tool executions (not just subagents) so long-running tools no longer trip a false idle timeout; give the Grep builtin a self-timeout + turn-abort.

Validation

Full unattended run (59 held-in / 20 held-out, 3 baseline sweeps, 10 rounds, real DeepSeek v4-flash + Docker, ~20.5h):

status: pass
rounds: 10 / 10
decisions: keep=0, discard=10
task_events: completed=475, infra_failed=32, plumbing_failed=0
rounds_without_task_evidence: 0
reward_hack_quarantine: 0
cost_usd: 4.76 / 30

keep=0 is the expected pass criterion for v1 structural validation — it proves the whole pipeline runs end-to-end (generate → commit → evaluate → scan → decide → discard/reset → guards → report) with zero plumbing failures and zero reward-hack leaks. Most discards were coverage_regressed: a blind prompt edit occasionally makes one task stop producing a scorable artifact, which the policy treats as a hard "do no harm" veto.

Build green; runtime 592 tests and headless 286 tests all pass.

Follow-ups (not in this PR)

  • Feed an aggregated tool_failed run-trace signal into the meta-agent input so it can avoid coverage regressions (the dominant discard cause). See the enhancement comment on RFC: Harness RSI loop — autonomous system prompt optimization #64.
  • A/B harness to compare a hand-authored new baseline prompt against the current seed using the existing acceptance policy.

Part of #64.

Astro-Han added 19 commits June 22, 2026 19:02
…r-consistent

The prompt-optimization controller round-trips two values it reads back from the
Harbor cell output, but the ai-sdk cell path broke both, so every task would fail
plumbing classification once wired to the real controller (unit tests passed only
because they used self-consistent fake runners):

- Prompt hash: harborCellSystemPrompt() prepended a fixed preamble to
  config.systemPrompt before the runtime hashed it, so the emitted
  systemPromptHash was stableHash(preamble+candidate) while the controller
  expects hashSystemPrompt(candidate) -> prompt_hash_mismatch every task. An
  explicit prompt is now passed through byte-for-byte (the preamble remains only
  as the default for prompt-less ad-hoc cell runs). This also makes "the prompt
  being optimized" equal "the prompt that runs".

- Cost: builtin pricing has no deepseek-v4-flash entry, so computeCost(null)
  returned 0 -> costUsd=0 with tokens>0 -> zero_cost_with_tokens plumbing failure,
  and the budget ceiling would never trip. The cell now honors the same
  MAKA_TRIAL_*_USD_PER_1M env the Python adapter (trial_pricing.py) already reads,
  so one pricing source feeds both runtime cell cost and Harbor trial cost.

Tests cover explicit-prompt pass-through and the pricing override.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
…-sdk cell

Two more prerequisites for running the prompt-optimization loop on the real
ai-sdk + DeepSeek path:

- Key files: the ai-sdk cell now resolves an API key from `<NAME>_API_KEY_FILE`
  (e.g. DEEPSEEK_API_KEY_FILE) in addition to the raw env var. The Harbor adapter
  already forwards the *_API_KEY_FILE path, so the secret can stay in a mounted
  file and only its path travels through the Harbor CLI / job config — never on a
  command line. A raw key still takes precedence. (Resolves the deferred
  AI-SDK _API_KEY_FILE support noted in PR #85.)

- Trial pricing reaches the cell: maka_agent.py now forwards
  MAKA_TRIAL_*_USD_PER_1M (+ MAKA_TRIAL_PRICING_SOURCE) into the in-container cell
  env, so the cell's pricing override actually activates and emits real costUsd
  for models absent from builtin pricing (deepseek-v4-flash). Without this the
  override was unreachable and cost stayed 0.

Tests: cell key-file resolution (file + raw-precedence), adapter forwards trial
pricing env into _cell_env.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
…e prompt loop

Implements the production HarborTaskRunner the fixed-prompt controller injects
(previously only test fakes existed). Per task it generates an isolated
single-task Harbor JobConfig (agent maka_agent:MakaAgent, MAKA_BACKEND=ai-sdk),
runs `harbor run --config`, and parses results:

- reward from the per-trial verifier/reward.txt (the per-trial result.json has no
  reward field), parsed to a finite number;
- status/errorClass/promptHash/cost from agent/maka-cell-output.json;
- runtimeEventsPath rewritten to the host trial path (agent/runtime-events.jsonl)
  so the controller's reward-hack scan and structural smoke can read raw events
  instead of the container-local path the cell records.

Infra vs benchmark split: harbor non-zero exit, missing cell output, or
missing/empty/non-numeric reward throw HarborInfraError (controller -> infra_failed,
excluded from scoring); a present-but-failed cell (e.g. runtime_error) is returned
so the controller marks it unscored+eligible. The system prompt is passed verbatim
(trailing newline preserved) for the hash round-trip, the API key travels only as a
mounted *_API_KEY_FILE path, and each task uses its own run/round/task job dir to
avoid cross-reads under the controller's concurrency.

Process invocation is injectable (HarborProcessRunner); tests drive the full
parse/classify/JobConfig path with a fake that writes real trial-shaped output.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
The prompt-candidate loop only had a scripted meta-agent for tests. Add the real
one:

- runtime: runOneShotCompletion — a single-turn, tool-less text completion over
  the same getAIModel + `ai` SDK stack the agent backend uses. Keeps the AI SDK
  dependency in runtime.
- headless: createAiSdkMetaAgent / createAiSdkMetaAgentCompletion wrap it as the
  loop's MetaAgentCompletion. A JSON-only system instruction plus extractJsonObject
  (strips ```json fences / surrounding prose) make the model output survive the
  strict parseMetaAgentResult. The underlying generator is injectable so the loop
  is testable without a network call.

Default model is deepseek-v4-flash (deepseek-chat is a deprecated alias).

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
Compose the existing baseline-calibration, candidate-round, fixed-prompt
controller, acceptance-policy, and structural-smoke layers into one
unattended driver: calibrate noise bands, then per round ask the
meta-agent for a candidate prompt, sweep held-in + held-out via Harbor,
scan held-in trajectories for reward-hacking, and KEEP (advance the
lineage) or DISCARD (roll the candidate commit back). Harbor and the
meta-agent are injected, so the full composition is tested with fakes and
no network or containers.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
… default

Hard-coded timeouts turn slow-but-healthy tasks into infra failures, and
the deprecated deepseek-chat alias is being retired. Make both the cell
wall-clock budget (MAKA_CELL_TIMEOUT_SEC) and the in-container Bash
per-command floor (MAKA_CELL_COMMAND_TIMEOUT_MS) operator-configurable
with safe defaults, and default the model to deepseek-v4-flash in both the
adapter and the cell. The adapter forwards the command-timeout floor to
the cell; malformed or non-positive timeout values fall back to the
default.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
Compose a real RSI run: deterministic id-sorted held-in/held-out
partition of cached Terminal-Bench tasks, DeepSeek v4-flash/v4-pro pricing
constants, canary-GUID reward-hack verifier patterns (the canary lives
only in a task's test files, so seeing it in model output is the
reward-hack signal), cached-task discovery, and runPromptOptimizationRun
gluing the real Harbor runner + DeepSeek meta-agent + CLI git before the
loop. Expensive components stay injectable so the wiring is tested without
Docker or the network.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
Adversarial review surfaced several real defects before the first run:

- Native DeepSeek would 400: the runner sent a provider-qualified model id
  (deepseek/deepseek-v4-flash) while also setting MAKA_PROVIDER, and the
  cell preserves the slash when a provider is set. Strip the provider-
  matching prefix for both MAKA_MODEL and model_name; a gateway model whose
  prefix differs (anthropic/... via openai-compatible) keeps its slash.
- Secret exfiltration: the cell passed the full env (incl. *_API_KEY /
  *_API_KEY_FILE) to every task tool subprocess, so a candidate prompt
  could cat the key. Scrub provider secrets from the tool child env; the
  LLM backend already captured the key.
- Held-out leak: the driver relied on the caller to hide the held-out TSV.
  Always pass it as a controller-only artifact so the candidate round
  rejects if it is visible to the meta-agent.
- Duplicate cached task ids would silently collide in the controller's
  event keying; discovery now fails loud.
- Unattended hang: harbor run had no wall-clock ceiling. Add a configurable
  timeout (default 45m) so a stuck Docker/Harbor becomes an infra failure.
- Crash-safety: revert a discarded candidate before persisting the discard
  decision, so the WAL and git HEAD cannot disagree.
- Budget: check the cost/infra stop guard before each round so an
  over-budget baseline cannot start a new candidate.

Documents that the canary reward-hack scan is a best-effort backstop.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
A thin Harbor entry that resolves config, builds the seed prompt repo
(program.md + system_prompt.md), discovers and partitions cached
Terminal-Bench tasks, derives canary verifier patterns, and calls
runPromptOptimizationRun, then persists the result and structural smoke
report. The DeepSeek key travels as a file path only. Defaults to a 60/20
partition over 10 rounds; cheap-smoke overrides documented in the header.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
…agent

The first real run failed immediately: harbor (a uv-installed tool) could
not resolve the bare `maka_agent:MakaAgent` import path because the adapter
directory was not on its PYTHONPATH, and the runner invoked harbor with an
unmodified env. Prepend <repo>/packages/headless/harbor to PYTHONPATH for
the harbor process (preserving any inherited value). Verified: harbor's
python imports maka_agent (name = maka) only with this set.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
The stream idle watchdog (120s default) was paused only for subagent tools.
A regular tool that ran longer than the idle timeout — an apt-get install, a
build, an ML training step — left the watchdog armed, so at 120s it fired and
aborted the whole invocation with "Model stream idle timeout" (redacted to
"Request timed out"), even though the model stream is legitimately silent
between steps while any tool runs.

In the ai-sdk step loop a tool always executes between model requests, so
provider silence during a tool is expected for every tool, not just subagents.
Pause the watchdog for all tool executions; the tool carries its own timeout
(Bash timeout_ms, the cell command timeout) and the trial/run layer is the
outer backstop.

Surfaced by the Harbor RSI smoke: a held-in trial whose first command was a
~120s `apt-get install r-base` was killed at exactly +120000ms with 0 tokens
and no prompt hash, while a sibling trial that never ran a single >120s
command completed normally.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
MAKA_PROMPT_HELD_IN_IDS / MAKA_PROMPT_HELD_OUT_IDS (comma-separated) override
the count-based id-sorted partition so a controlled smoke can target known
tasks instead of whatever sorts first. Both must be set together, must be
disjoint, and every id must exist or the run aborts before spending Docker
time.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
The driver calibrated directly on every configured task, so a single baseline
trial that did not complete (scored + eligible) across all sweeps tripped the
strict completeness check and aborted the whole run — fatal for an unattended
60/20 run over hard Terminal-Bench tasks where occasional non-completion is
expected.

Wire the existing selectStablePromptTasks into the baseline step: drop tasks
that fail to complete across every baseline sweep (completion-only filter — a
flaky-pass task's variance is honest noise the band already absorbs), then
calibrate and run all candidate rounds on the stable subset. Dropped tasks are
excluded from candidate sweeps too, so they neither cost more nor skew a
decision. The run aborts only when a whole partition has no stable task left.

Candidate-round non-completion was already handled gracefully (lower coverage
-> coverage_regressed -> discard); only baseline calibration was fragile.

Result gains droppedHeldInTaskIds / droppedHeldOutTaskIds for transparency; the
runner logs them. Structural smoke is unaffected: an unscored task_completed
still counts as completed and trips no failure (only plumbing failures do).

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
Now that the stream idle watchdog is paused during all tool executions, it no
longer incidentally caps a tool's wall-clock time, so every process-spawning
tool must carry its own bound. Bash already does (spawn timeout + abortSignal);
Grep ran ripgrep via execAsync with neither, so a pathological tree (network
mount, /proc, a FIFO) could hang it and — with the watchdog paused — hang the
whole turn. Add a generous self-timeout and forward the turn's abort signal so
the rg process is killed, not leaked.

Found by codex challenge review of the watchdog change.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
…task ids

Two robustness gaps from the codex challenge review of the baseline stability
filter:

- The driver only aborted when a partition had zero stable tasks, so a
  harness/cache regression that silently dropped most tasks would still
  calibrate and decide on a tiny, unrepresentative subset and could promote an
  overfit prompt. Add minStableHeldInTasks / minStableHeldOutTasks (default 1,
  threaded through runPromptOptimizationRun and exposed as
  MAKA_PROMPT_MIN_STABLE_HELD_IN / _HELD_OUT) so a run can fail loud when too
  few tasks survive baseline.
- Explicit MAKA_PROMPT_HELD_IN_IDS / _HELD_OUT_IDS accepted duplicates, which
  would double-weight a task in the pass-rate summary. Reject duplicates up
  front with a clear error.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
…script

The loop already supports an infra-failure-rate stop guard; surface it as an
env knob so a long unattended run aborts early if Docker/Harbor breaks
mid-run (e.g., the VM dies) instead of burning hours emitting infra_failed
events. Left unset by default.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
Some Terminal-Bench tasks are very slow for a small model (many pdflatex /
compile / training iterations). With Docker capped at ~8GB the run must stay at
low concurrency, so a handful of multi-minute tasks dominate wall-clock and can
push a full 10-round run past 12 hours.

Add maxStableTaskDurationMs (threaded through runPromptOptimizationRun, env
MAKA_PROMPT_MAX_STABLE_TASK_MS): a task whose baseline trial exceeded the cap in
any sweep is dropped from the calibrated set and every candidate round via the
existing selectStablePromptTasks 'too_slow' path. Unset = no cap (prior
behaviour). Calibration stays honest — the cap only trims which tasks the loop
iterates on.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
A held-in task whose tests carry no `terminal-bench-canary GUID` string cannot
be scanned for reward-hacking, so the controller quarantines every round it
completes in — which fails the structural smoke. headless-terminal is such a
task and sorts into the default 60-task held-in partition.

Drop no-pattern tasks from held-in (an unverifiable task does not belong in the
set the meta-agent optimizes); held-out keeps them since it is not
reward-hack-scanned. When held-in ids were pinned explicitly, fail loud instead
of silently changing the caller's set.

Claude-Session: https://claude.ai/code/session_015yqs8KqJo3DB5DtkGeiNTA
Astro-Han added 10 commits June 23, 2026 19:47
Addresses review findings on the prompt-optimization runner:

- P1: default the cost ceiling to $30 and validate every guard knob via
  fail-loud parsers (envFinitePositiveNumber/envRatio/envNonNegativeInt),
  so a malformed env value throws instead of yielding NaN that passes a
  `!== undefined` check and silently disables the guard.
- P1: exit non-zero when the structural smoke does not pass, so CI and
  shell callers don't treat a structurally-broken run as success.
- P2: scale the min-stable floors with the actual post-drop partition
  sizes (ceil(size * ratio), at least 1) instead of a flat default of 1;
  an explicit MAKA_PROMPT_MIN_STABLE_* still wins for cheap smokes.
- P2: scan a task's tests/ tree recursively for canary GUIDs so verifier
  material nested in fixtures (tests/data/…) is found, not silently
  treated as "no pattern" and dropped from held-in.

Extracts the parsers into a pure, unit-tested prompt-optimization-env
module and adds behavior tests for the validators and the recursive
canary scan. Full headless + runtime suites green.
Resolve the builtin-tools.ts conflict: drop the now-dead
BASH_MAX_OUTPUT_BYTES (main's #92 P0 moved output bounding into
runShellWithBoundedTail) and keep this branch's GREP_TIMEOUT_MS, which
its self-bounding Grep tool still uses. Full runtime + headless suites
green after the merge.
… min-stable

P1 (blocking): the prompt-optimization-env.ts source was left untracked in
the prior commit, so index.ts/runner/tests imported a module absent from the
tree — a clean checkout's build would fail. Commit the source.

P2: resolveMinStable now rejects an explicit floor of 0. The loop's guard is
`selectedTaskIds.length < floor`, so a 0 floor can never trip and would
silently disable the stable-sample protection; an explicit value must be a
positive integer (cheap smokes already pass 1). The loop-level "0 stable
tasks aborts calibration" behavior is already covered in
prompt-optimization-loop.test.ts.
DeepSeek pricing was exported from @maka/headless's public index, leaking
vendor-specific run config into the package's generic surface.

- Delete DEEPSEEK_V4_PRO_PRICING outright — it was defined, exported and
  tested but never consumed (dead code).
- Move DEEPSEEK_V4_FLASH_PRICING into the runner (run-prompt-optimization.mjs),
  its only consumer; drop the definition from prompt-optimization-run.ts and
  the export from index.ts. The harbor-adapter wiring test still asserts the
  constant is present in the runner source.

@maka/headless's public API now exposes only the generic loop / Harbor runner
/ discovery helpers; the genuinely generic, injectable runPromptOptimizationRun
and its task-discovery helpers stay (they carry no vendor specifics). The
pricing-value unit test is dropped: the figures are operational config, not
correctness logic, and the wiring test still pins the constant name.
The DeepSeek pricing object now lives in the plain-JS runner, so it lost both
the HarborTaskPricing type-check and the value unit test. A mistyped rate field
would leave it undefined and the runner would silently emit wrong/zero costUsd.
Assert each canonical rate field is a finite, non-negative number at startup —
before any Docker time. The field-name -> MAKA_TRIAL_* forwarding contract
remains covered in harbor-task-runner.test.ts.
MAKA_PROMPT_ROUNDS=0 produced a baseline-only run that trivially passed the
structural smoke (minimumRounds 0), contradicting the unattended >=1-round
validation the runner exists to perform. Add a fail-loud envPositiveInt parser
(rejects 0/negative/non-integer) and use it for rounds. Unit-tested.
…the task

#64 LOOP step 6: a thrown Harbor/Docker error is an infra failure and is
often transient. Retry the harborRunner once; only record task_infra_failed
when the retry also throws. Keeps a single Docker hiccup from poisoning a
candidate's pass-eligible rate.
…heck budget at sweep boundaries

#64 LOOP steps 7-10: split acceptanceReason into a held-in gate and a
held-out gate sharing one decision source. The loop now runs the held-in
sweep, evaluates heldInGateReason, and only spends the held-out sweep when
the candidate clears held-in (delta beyond the noise band). A candidate that
regresses or sits within noise is discarded without paying for held-out.

Also enforce the cost ceiling at sweep boundaries (step 14): guard before
each baseline sweep and before the held-out sweep, reverting the candidate
prompt and breaking out cleanly when the budget is hit, so the loop never
overshoots into an extra sweep. Concurrency stays at 4 — the ceiling is
handled at the loop's stopGuard, never passed into the controller.
…ge entry

The env-parsing helpers (envFinitePositiveNumber, envNonNegativeInt,
envPositiveInt, envRatio, resolveMinStable, smokeExitCode) are consumed only
by the prompt-optimization runner, not by package API consumers. Expose them
through a dedicated "@maka/headless/prompt-optimization-env" subpath export
instead of the main entry, and repoint the Harbor runner's import. Keeps the
package's public surface to genuine contracts; tests still import the helpers
via their relative module path.
…t misconfig

Three runner-config fixes from review:

- Pin the model to deepseek/deepseek-v4-flash instead of reading
  MAKA_PROMPT_MODEL. DEEPSEEK_V4_FLASH_PRICING is tied to that model, so an
  override would let cost and smoke accounting silently use the wrong rates.
  The loop is contractually a deepseek-v4-flash run; provider/baseUrl stay
  overridable since they don't change the pricing.

- Parse MAKA_PROMPT_MAX_CONCURRENCY with the positive-int parser instead of
  the finite-positive one, so a fractional value (e.g. 1.5) fails loud at
  startup rather than being silently floored later in the controller. Widen
  envPositiveInt to accept an optional fallback (mirroring
  envFinitePositiveNumber) so the undefined-default knob stays type-honest.

- Clarify the cost-ceiling comment: it is a round/sweep-boundary ceiling
  (checked before each baseline sweep and the held-out sweep), not a hard
  mid-task cap, so a single in-flight sweep can complete past it.
…s cannot pass the smoke

The runner already rejects MAKA_PROMPT_ROUNDS=0 via envPositiveInt, but
runPromptOptimizationLoop is callable directly and only rejected negative
rounds. A 0-round run is baseline-only and would trivially pass the
structural smoke (minimumRounds 0), so it must not share the normal
structural-pass semantics. Enforce rounds >= 1 at the public API too.
…controller

The CLI env parsers validated env strings, but runPromptOptimizationLoop and
runFixedPromptController are public and callable directly, where a NaN,
fraction, or 0 would slip past a `value < 1` / `cost >= ceiling` comparison
and silently disable a guard or change semantics (rounds 1.5 runs two rounds;
a NaN ceiling never trips; minStable 0 disables the stable-task floor).

Add a single source of truth for the invariants — numeric-guards
(assertPositiveInt / assertNonNegativeInt / assertFinitePositive /
assertRatio) — and enforce it at both public APIs:

- loop: validate rounds, baselineRuns, zScore, minStableHeldIn/Out,
  maxStableTaskDurationMs, costCeilingUsd, maxInfraFailureRate, maxConcurrency.
- controller: validate costCeilingUsd and maxInfraFailureRate up front;
  normalizeMaxConcurrency now rejects a fractional value instead of flooring
  it, and always runs (even when a stop guard forces concurrency to 1) so the
  value is never left unchecked.
- env parsers become string->number then delegate to the same guards, so the
  CLI and the core enforce one identical contract.
…t a public subpath

The runner-only env helpers were reachable as the public subpath export
"@maka/headless/prompt-optimization-env", widening the package's contract for
internals that only the Harbor runner uses. Move them behind a Node package
"imports" entry (#prompt-optimization-env) so the runner can still import them
but external consumers cannot — the public surface is just the main entry.
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