Repository navigation
fix(ai): saved AI choice in non-interactive runs, JSON-only --agent stdout, mission agent loop - #854
Merged
Merged
Conversation
`fluid mission run` always drives the tool-use agent loop, but it resolved its LLM without saying so. `_agents_allowed` therefore stayed true, so a run without a TTY auto-picked a coding agent from PATH. Coding agents have no tool use, so the loop then failed mid-run with NotImplementedError after the agent had been invoked. Resolve with a copy of the args carrying `agent_loop=True`. No coding agent is chosen, the caller's args are untouched, and a run with nothing else configured stops with `copilot_no_llm_provider` before any agent runs.
Only the interactive inline setup applied the provider, model and key that `fluid ai setup` saves. `check_llm_readiness` (`fluid doctor`) read the saved choice too, but the real selection (`_provider_name_from`) never did. So non-interactive runs (CI, scripts, `fluid forge --agent` with an AI flag) ignored it, and doctor and forge disagreed about which provider would run. Selection now walks one ladder, and readiness calls it (`_provider_name_from` with no args) instead of keeping its own copy: 1. A named provider: `--llm-provider`, `FLUID_LLM_PROVIDER` or `FLUID_FORGE_AGENT`. 2. Exactly one key environment variable. 3. The saved `fluid ai setup` choice. 4. Exactly one keyring key. 5. A local Ollama, then (non-interactive, nothing configured) the first coding-agent CLI on PATH. - The saved model and Ollama host fill in what the run does not state, for the saved provider only. The environment's `OLLAMA_HOST` wins over the saved host. - A run that names its provider takes nothing from the saved choice. Without `--llm-model`, a named provider gets its catalog model, as before. Command Center's `fluid forge data-model from-intent --llm-provider <p>` call relies on that, so its provider and model are unchanged even where a saved config exists. - The saved `endpoint` field is not applied. It has had no writer since the multi-provider config (#363). Applied next to an environment key, it would send that key to a host it was not saved with. - A saved agent (coding-agent CLI or MCP sampling) is passed over wherever the auto-pick is. That covers a stated endpoint or model, given as a flag or as a `FLUID_LLM_*` variable, and the agent loop. A stated destination is never swapped for an agent. When nothing else selects a provider, `no_provider_message` names the saved agent. - Saved names are read through the provider registry, so a hand-written `claude` or `claude_code` means `anthropic` or `claude-code`. - Keys still resolve through `_resolve_api_key`. The saved choice is never written into `--llm-provider` or `FLUID_LLM_PROVIDER`. - The lean (no litellm) preflight follows the same order. A saved coding agent runs without litellm. - Three re-exports that only the old readiness ladder used are removed. tests/conftest.py gains an autouse fixture that hides the developer's saved choice (`ai_config.json` and the unified `config.yaml`) from every test, as `_hermetic_keyring` does for the OS keyring. Without it, the Command Center pin test failed on a machine with a saved gemini config. The precedence follows layered-config practice (flags, then environment, then the config file, then defaults), as in ConfigArgParse and pydantic-settings.
AGENT_IDE.md promises one JSON object per line on stdout. But Rich panels
("Forge Complete", "Next steps"), prints and error text landed between the
`{"event": ...}` lines. In blank mode, 28 of 32 stdout lines were not JSON.
Under `--agent`, `forge.run` keeps the stdout the run started with for the
events (`_AGENT_EVENTS`) and runs the rest under
`contextlib.redirect_stdout(sys.stderr)`. Every human-facing writer moves to
stderr without being touched:
- Rich consoles built without `file=` (Rich looks `sys.stdout` up at each
write, rich/console.py);
- `print`;
- a provider SDK's banner.
A missing extra still propagates from `forge.run`, so an in-process caller
(the MCP `forge_run`) still gets the exception. The CLI entry point prints the
remedy on stderr under `--agent` (`_render_missing_optional`). Outside
`--agent`, nothing changes.
The redirect is the stdlib pattern `cli/mcp/dispatch.py` already uses to
capture the same events in-process.
fas89
added a commit
that referenced
this pull request
Nov 12, 2026
#854 walks _provider_name_from and the saved fluid ai setup choice in check_llm_readiness. There FLUID_LLM_API_KEY counts only for a named provider (_provider_is_named(None, env, ...)), never for the saved choice. resolve_llm_config keeps #854's 'not saved' auto-pick condition and this branch's unnamed-provider note.
fas89
added a commit
that referenced
this pull request
Nov 12, 2026
… stay off project hosts (#853) * security(ai): FLUID_LLM_API_KEY goes only to a provider you name FLUID_LLM_API_KEY does not say which vendor it is for, yet forge sent it to whatever provider it picked, including one it only inferred: a running Ollama or the one OLLAMA_HOST names (as a bearer token, in cleartext over http), the provider of the one keyring key, or the provider of the one key variable set. So an OpenAI key could reach a local or remote Ollama, or Anthropic. The key is now used only when --llm-provider or FLUID_LLM_PROVIDER names the provider the run uses (founder decision, 2026-11-12). A provider forge picks itself gets its own key variable, then its keyring key, and a warning says the generic key was not used. Interactive fluid forge and fluid init record what the operator named before their setup replaces args.llm_provider, so setup's pick counts as picked. fluid ai test reads the key only with --llm-provider, fluid doctor only with FLUID_LLM_PROVIDER. A project .env can no longer pair an OpenAI, Anthropic, Gemini or GitHub endpoint with FLUID_LLM_API_KEY: forge can pick those providers without a name, and then the operator's keyring key, not the project's key, would go out. * security(ai): interactive setup keeps your saved key off a project's host A project .env may point a provider at its own host (OPENAI_BASE_URL, ANTHROPIC_BASE_URL, GEMINI_API_BASE, GITHUB_API_BASE) next to a key of its own; credentials/protected_env pairs it because that key is what the readers send. Interactive fluid forge and fluid init then loaded the provider that fluid ai setup saved and copied its keyring key into the provider's key variable with set_session_env, over the project's key (for Gemini into GOOGLE_API_KEY, which forge reads before GEMINI_API_KEY). The operator's key went to the project's host. set_session_env now leaves the variable alone while a project endpoint of that provider is in effect (dotenv_store.project_endpoints_in_effect, as the Snowflake readers already do), so the project's own key goes there, and logs that the operator's key was not used. fluid ai setup checked a typed or imported key with one litellm call, which reads the endpoint from the environment, so the check sent that key to the project's host too. While such an endpoint is in effect, setup now asks for no key, skips a key it would import, and _validate_api_key sends nothing. For a provider without a mapped group, any LLM endpoint a project file set counts. * fix(ai): build the unused-key warnings from names, never from a key CodeQL (py/clear-text-logging-sensitive-data) flagged the two warnings this branch adds: - resolve_llm_config logged a note unpacked from the same tuple as the API key, returned by _api_key_for_run, a call whose name reads as a password source. The note now comes from _unnamed_provider_note, from the provider names and the named flag alone; the key comes from _resolve_api_key on its own. - set_session_env's warning logged the provider string, which setup can get by classifying a typed key (_classify_key_shape -> detect_provider_from_ api_key). The notice now names the endpoint group from _PROVIDER_ENDPOINT_SERVICES, picked by comparison so it is the table's own constant, and the endpoint variables; the provider string is never logged. * chore(ai): one literal per suggestion, one import style in the key-scope tests Addresses the two code-quality review threads on #853: - the copilot_missing_llm_api_key suggestion this branch added was two adjacent string literals in a list (implicit concatenation, read as a possibly missing comma); it is one literal now; - tests/test_llm_api_key_scope.py imported fluid_build.llm.providers both as a module and with from-imports; it uses the module import only, and the new interactive-setup matrix test does the same. * test(ai): the saved fluid ai setup choice never gets FLUID_LLM_API_KEY Runs without a TTY use the provider fluid ai setup saved (#854). That provider is picked, not named, so it gets its own keyring key and the generic key is unused; without a key of its own it is not ready and resolve_llm_config raises copilot_missing_llm_api_key. Both tests fail on main 3b7e8c1. * chore(tests): one import style for fluid_build.llm.providers in the pairing matrix The file imported the module as llm_providers in two places and with 'from ... import LlmConfig' in _run_codex (github-code-quality on #853). _run_codex now uses llm_providers.LlmConfig as well.
fas89
added a commit
that referenced
this pull request
Nov 12, 2026
Moves [Unreleased] under [0.29.0] — 2026-11-12, leaving an empty [Unreleased] above it. release.yml extracts this section as the GitHub Release body: 22 entries under Added, Changed, Fixed and Security. Two corrections ride along so the release notes are accurate: - #854 merged without a CHANGELOG change. Its three entries are added: runs without a terminal use the saved fluid ai setup choice, --agent stdout carries only the JSON-Lines events, and fluid mission run no longer picks a coding agent it cannot use. - #852's no-provider entry quoted a "does not read the saved choice" variant that #854 replaced; it now names the saved coding agent the run passes over.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This fixes two defects that hit
fluid forgewithout a TTY (CI, scripts, IDE agents), plus one they exposed. All three are follow-ups to #850 and #852. There is no issue.1. The saved
fluid ai setupchoice is used in runs without a TTY (fix(ai)).check_llm_readiness(fluid doctor) honoured the saved choice, but the real selection never read it. So doctor and forge disagreed.--llm-provider,FLUID_LLM_PROVIDER,FLUID_FORGE_AGENT);OLLAMA_HOSTwins over the saved host.--llm-model, a named provider gets its catalog model, as before. So Command Center'sfluid forge data-model from-intent --llm-provider <p>drafting call keeps its provider and model even where a saved config exists. A test pins this for openai, anthropic, claude and gemini, with no saved config, a saved config for another provider, and one for the same provider.endpointfield is not applied. That is the founder's decision: a run states its endpoint with--llm-endpointorFLUID_LLM_ENDPOINT.FLUID_LLM_*variable) or the agent loop. The no-provider message names it.claudeorclaude_codecounts asanthropicorclaude-code._resolve_api_key. The saved choice is never written as a named provider.tests/conftest.pygains an autouse fixture that hides the developer's saved choice from every test, as_hermetic_keyringdoes for the keyring.2.
fluid forge --agentstdout carries only the JSON-Lines events (fix(forge)).forge.runnow keeps the real stdout for the events and runs the rest undercontextlib.redirect_stdout(sys.stderr). That is the same stdlib patterncli/mcp/dispatch.pyuses to capture these events in-process.forge.run, so the MCPforge_runstill gets the exception. The CLI entry point prints its remedy on stderr under--agent.3.
fluid mission runresolves its LLM as an agent-loop run (fix(mission)).NotImplementedError.Commits: one per unit, each green on its own (checked in scratch worktrees).
Overlap: the FLUID_LLM_API_KEY scope PR also touches
check_llm_readinessand provider resolution. Whichever lands second merges main in and resolves the overlap. This PR never writes the saved choice into--llm-providerorFLUID_LLM_PROVIDER, so that PR's named and picked distinction holds.Type of Change
Documentation
Pages to update in forge_docs:
llm-providers: the selection order now includes the savedfluid ai setupchoice for runs without a TTY. A named provider takes nothing from it. The saved endpoint is not applied.advanced/production-troubleshooting: the "No LLM provider is selected" section from forge_docs#205 quotes "this command does not read the saved choice", which is no longer said. Add the message for a saved coding agent that a run passes over.error-codes: thecopilot_no_llm_providermessage variants.cli/forge: under--agent, stdout carries only the JSON-Lines events and human-facing output is on stderr. Non-interactive runs use the saved choice.cli/doctor: readiness walks the same ladder as forge, includingFLUID_FORGE_AGENT.environment-variables:FLUID_LLM_ENDPOINTandFLUID_LLM_MODEL(and their routing twins) also keep a saved or auto-picked coding agent from being used.upgrading: a short "on main" note for the three behaviour changes.fluid missionpage, if there is one: a mission never selects a coding agent.Checklist
pytest). The local full suite fails only on the known shared-venv baseline; see Testing.ruff checkandblackwith no errorsTesting
Each fix was written test-first. The new tests failed on main and pass here.
tests/test_llm_keyless_autopick.py, using theworldfixture with no real~/.fluidand no real keyring.--agentrun parses as JSON: blank mode as a subprocess, AI mode with the generation seam mocked, and a missing extra through the CLI entry point.Gates on the rebased head:
.secrets.baselineneeded only a line-number refresh.Full suite (
-n 6, shared venv): 34616 passed. The 11 failures and 2 collection errors are the known baseline:test_lazy_core_commands(2) andtest_duckdb_sandbox_workspace_root(2);TestSnowflakeLogin,test_an_operator_exported_aws_endpoint_is_used);Reviews:
Screenshots / Output
Live runs used a scratch HOME and a null keyring.
fluid forge --non-interactive --context intent.txtwith a saved anthropic and no key:fluid forge --agent --context intent.txt -d wswith a saved Ollamaqwen3-coder:30b, a real local generation:The run's receipt records
llm_provider: ollama, llm_model: qwen3-coder:30b, andfluid doctorreports the same provider, model and endpoint.fluid forge --agent --blank: 4 of 4 stdout lines are JSON. Before, 28 of 32 were not.