Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions fluid_build/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,14 @@ def _render_missing_optional(e: Any, args: argparse.Namespace, context: Any, cli
"exit_code": e.exit_code,
},
)
context.console.print(literal_text(_redact_error_str(f"❌ {e}"), "red"))
context.console.print(literal_text(f"[{slug}]", "dim"))
import contextlib

# ``fluid forge --agent``: stdout carries the JSON-Lines events only, so the
# remedy goes to stderr with the rest of the run's human output.
agent = getattr(args, "agent", False)
with contextlib.redirect_stdout(sys.stderr) if agent else contextlib.nullcontext():
context.console.print(literal_text(_redact_error_str(f"❌ {e}"), "red"))
context.console.print(literal_text(f"[{slug}]", "dim"))
return int(e.exit_code)


Expand Down
66 changes: 42 additions & 24 deletions fluid_build/cli/forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from __future__ import annotations

import argparse
import contextlib
import functools
import json
import logging
Expand Down Expand Up @@ -1251,23 +1252,31 @@ def _print_next_steps(console: Any, target_dir: Path, contract_path: Path) -> No
# event stream over stdout lets the agent parse progress (start / done /
# contract_written / cost) without scraping Rich console output.
#
# Design choice: events go to stdout AS WHOLE JSON OBJECTS PER LINE so the
# agent's parser is ``for line in stdout: try: obj = json.loads(line);
# except: pass``. Rich's banners on stdout don't parse as JSON, so they're
# harmlessly ignored. No need to mute Rich (which would break user-facing
# UX outside ``--agent``).
# Design choice: stdout carries the events and nothing else, one whole JSON
# object per line (NDJSON, as AGENT_IDE.md promises), so the agent's parser
# is ``for line in stdout: json.loads(line)``. ``run`` keeps the real stdout
# for the events and points ``sys.stdout`` at stderr for the rest of the run
# (``contextlib.redirect_stdout``, as the MCP ``forge_run`` dispatch does), so
# every human-facing writer moves without being touched: Rich consoles built
# without ``file=`` (Rich looks ``sys.stdout`` up at each write), ``print``,
# and a provider SDK's banner. Outside ``--agent`` nothing changes.

#: Where ``--agent`` events go while ``run`` points ``sys.stdout`` at stderr:
#: the stdout the run started with. ``None`` outside an ``--agent`` run.
_AGENT_EVENTS: Optional[Any] = None


def _emit_agent_jsonl(event: str, **kvs: Any) -> None:
"""Emit one canonical JSON-Lines event to stdout for agent consumption.
"""Emit one canonical JSON-Lines event on the run's real stdout for the agent.

Only used when ``--agent`` is active; callers gate via ``args.agent``.
Failures are swallowed so a stdout pipe-closed never poisons the run.
"""
try:
stream = _AGENT_EVENTS or sys.stdout
payload = {"event": event, "ts": time.time(), **kvs}
sys.stdout.write(json.dumps(payload, default=str) + "\n")
sys.stdout.flush()
stream.write(json.dumps(payload, default=str) + "\n")
stream.flush()
except Exception: # noqa: BLE001
pass

Expand Down Expand Up @@ -1492,26 +1501,35 @@ def run(args, logger: logging.Logger) -> int:
)
)
)
_emit_agent_jsonl(
"forge.start",
run_id=run_id,
mode=mode,
data_product_type=getattr(args, "data_product_type", None),
target_dir=str(getattr(args, "target_dir", None) or ""),
emit_plan=bool(getattr(args, "emit_plan", False)),
)
global _AGENT_EVENTS # noqa: PLW0603 — one run at a time, reset below
_AGENT_EVENTS = sys.stdout
rc = 1
try:
rc = _run_main(args, logger)
# On success, optionally emit the structured plan checklist BEFORE
# forge.done so consumers parsing the stream see the plan in order.
if rc == 0 and getattr(args, "emit_plan", False):
target_dir = getattr(args, "target_dir", None)
contract_path = Path(target_dir) / "contract.fluid.yaml" if target_dir else None
_emit_forge_plan(args, contract_path)
return rc
# Everything a human reads goes to stderr; the events keep stdout.
with contextlib.redirect_stdout(sys.stderr):
_emit_agent_jsonl(
"forge.start",
run_id=run_id,
mode=mode,
data_product_type=getattr(args, "data_product_type", None),
target_dir=str(getattr(args, "target_dir", None) or ""),
emit_plan=bool(getattr(args, "emit_plan", False)),
)
# A missing extra still propagates, as it always has: the CLI entry
# point says the remedy on stderr under --agent
# (``cli._render_missing_optional``), and an in-process caller
# (the MCP ``forge_run``) gets the exception itself.
rc = _run_main(args, logger)
# On success, optionally emit the structured plan checklist BEFORE
# forge.done so consumers parsing the stream see the plan in order.
if rc == 0 and getattr(args, "emit_plan", False):
target_dir = getattr(args, "target_dir", None)
contract_path = Path(target_dir) / "contract.fluid.yaml" if target_dir else None
_emit_forge_plan(args, contract_path)
return rc
finally:
_emit_agent_jsonl("forge.done", run_id=run_id, exit_code=int(rc or 0))
_AGENT_EVENTS = None


#: Per-process flag for the personal-memory sanitiser. See
Expand Down
8 changes: 7 additions & 1 deletion fluid_build/cli/mission.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,16 @@ def _resolve_workspace_root(args: argparse.Namespace) -> Path:

def _resolve_mission_llm_config(args: argparse.Namespace):
"""Resolve the LLM config, or ``(None, message)`` when unavailable."""
import copy

from fluid_build.llm.providers import CopilotGenerationError, resolve_llm_config

# A mission always runs the tool-use agent loop, which no coding agent can
# serve: say so, so neither a saved nor an auto-picked agent is chosen.
loop_args = copy.copy(args)
loop_args.agent_loop = True
try:
return resolve_llm_config(args, environ=None), None
return resolve_llm_config(loop_args, environ=None), None
except CopilotGenerationError as exc:
return None, str(exc)

Expand Down
119 changes: 90 additions & 29 deletions fluid_build/llm/provider_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import logging
import shutil
import sys
from typing import Dict, List, Mapping, Optional, Tuple
from typing import Any, Dict, List, Mapping, Optional, Tuple

LOG = logging.getLogger("fluid.cli.llm_providers.resolve")

Expand Down Expand Up @@ -86,26 +86,77 @@ def _explicit_key_signals(env: Mapping[str, str]) -> Dict[str, List[str]]:
return signals


def _canonical_provider_name(name: Any) -> Optional[str]:
"""*name* as the provider registry spells it, ``None`` when it is not a name.

A hand-written ``~/.fluid/config.yaml`` may say ``claude`` or ``claude_code``;
the registry answers ``anthropic`` and ``claude-code``. A name it does not
know (a plugin provider) comes back as written.
"""
if not isinstance(name, str) or not name.strip():
return None
registry = getattr(_host(), "BUILTIN_LLM_PROVIDERS", {})
key = name.strip().lower()
provider = registry.get(key) or registry.get(key.replace("_", "-"))
return provider.name if provider is not None else name.strip()


def _saved_provider_name() -> Optional[str]:
"""The provider ``fluid ai setup`` saved, if any (``~/.fluid``)."""
"""The provider ``fluid ai setup`` saved, if any (``~/.fluid``), by its canonical name."""
try:
from fluid_build._ai_config_shared import load_ai_config

name = (load_ai_config() or {}).get("provider")
except Exception: # noqa: BLE001 — an unreadable config names nothing
return None
return name if isinstance(name, str) and name else None
return _canonical_provider_name(name)


def _saved_choice_for(args: Any, env: Dict[str, str], provider: str) -> dict:
"""The active ``fluid ai setup`` entry when it is *provider*'s, else ``{}``.

It fills in what a run does not state, for that provider only: its model,
and its Ollama host, set in *env* here when ``OLLAMA_HOST`` is not. A run
that names its provider (``--llm-provider``, ``FLUID_LLM_PROVIDER``,
``FLUID_FORGE_AGENT``) takes nothing from it: a named provider without a
stated model gets its catalog model, which Command Center's drafting call
relies on. Shared by ``check_llm_readiness`` and ``resolve_llm_config``.
"""
if _host()._explicit_provider_name(args, env):
return {}
try:
from fluid_build._ai_config_shared import load_ai_config

saved = load_ai_config()
except Exception: # noqa: BLE001 — an unreadable config supplies nothing
return {}
if not saved or _canonical_provider_name(saved.get("provider")) != provider:
return {}
if provider == "ollama" and saved.get("ollama_host") and not env.get("OLLAMA_HOST"):
env["OLLAMA_HOST"] = saved["ollama_host"]
return saved


def _passed_over(saved: str, allow_agents: bool) -> bool:
"""Whether a run passes over the saved provider *saved*.

Only an agent (a coding-agent CLI or MCP sampling), and only where the keyless
auto-pick is off too (``allow_agents=False``): an agent cannot call a stated
endpoint, may not get a stated model meant for another LLM, and has no tool use
for the agent loop.
"""
return not allow_agents and saved in _host()._NON_LITELLM_PROVIDERS


def no_provider_message(env: Mapping[str, str]) -> str:
"""Why no LLM provider is selected. ``fluid doctor`` and forge both say this."""
saved = _saved_provider_name()
if saved:
# Only a command that does not read the saved choice gets here: the
# readiness ladder (``fluid doctor``) uses it.
if saved in _host()._NON_LITELLM_PROVIDERS:
# The one saved choice the ladder passes over (:func:`_passed_over`).
return (
f"No LLM provider is selected for this run: 'fluid ai setup' saved {saved}, "
f"but this command does not read the saved choice. Pass --llm-provider {saved}."
f"No LLM provider is selected: 'fluid ai setup' saved {saved}, and an agent "
"is not used when the run states an endpoint or a model, or runs the agent "
"loop. Name a provider with --llm-provider."
)
signals = _explicit_key_signals(env)
if len(signals) > 1:
Expand Down Expand Up @@ -216,30 +267,31 @@ def _first_coding_agent_on_path(env: Mapping[str, str]) -> Optional[str]:
def _lean_keyless_autopick(
env: Mapping[str, str], *, non_interactive: bool = False, allow_agents: bool = True
) -> Optional[str]:
"""The coding agent a run without litellm auto-picks, else ``None``.

The ambient rung of the ladder without its keyring read, so the litellm
preflight stays cheap: a non-interactive run, no configuring variable, no
saved provider, an agent CLI on PATH, and no local Ollama (it needs litellm,
and a running one is never silently swapped for a cloud agent).
``allow_agents=False`` (the agent loop, or a stated endpoint or model)
disables it.
"""The agent a run without litellm selects, else ``None``.

The selection ladder without its keyring read, so the litellm preflight
stays cheap. A key variable, a keyring key and Ollama all need litellm, so
two rungs can answer: an agent ``fluid ai setup`` saved, and the keyless
auto-pick (a non-interactive run, no configuring variable, no saved
provider, an agent CLI on PATH, and no local Ollama: a running one is never
silently swapped for a cloud agent). ``allow_agents=False`` (the agent loop,
or a stated endpoint or model) disables both.
"""
if _infer_provider_from_explicit_keys(env):
return None
saved = _saved_provider_name()
if saved:
# Below the saved rung only providers that need litellm remain.
if saved in _host()._NON_LITELLM_PROVIDERS and not _passed_over(saved, allow_agents):
return saved
return None
if not allow_agents or not _is_non_interactive(non_interactive):
return None
if _configured_by_env(env):
return None
agent = _first_coding_agent_on_path(env)
if agent is None:
return None
try:
from fluid_build._ai_config_shared import load_ai_config

saved = load_ai_config()
except Exception: # noqa: BLE001 — detection must never hard-fail
saved = None
if saved and saved.get("provider"):
return None
detect_fn = getattr(_host(), "detect_ollama_available", None)
if detect_fn is not None and detect_fn(env):
return None
Expand All @@ -260,7 +312,7 @@ def _infer_provider_from_ambient(

Only call this AFTER the saved config and the keyring have been
consulted — ambient services must not override an explicit
operator choice. See :func:`check_llm_readiness` for the full
operator choice. See :func:`_infer_provider_from_env` for the full
resolution ladder.
"""

Expand All @@ -280,16 +332,25 @@ def _infer_provider_from_ambient(
return None


# Backwards-compatible alias for callers outside this module that
# imported ``_infer_provider_from_env``. The new code paths inside
# :func:`check_llm_readiness` use the split functions above so the
# saved config gets a chance to win over ambient Ollama.
def _infer_provider_from_env(
env: Mapping[str, str], *, non_interactive: bool = False, allow_agents: bool = True
) -> Optional[str]:
"""The provider the environment selects when no provider is named.

1. Exactly one explicit key variable: a deliberate per-run choice.
2. The provider ``fluid ai setup`` saved, unless :func:`_passed_over`.
3. Exactly one keyring key.
4. Ambient discovery (:func:`_infer_provider_from_ambient`).

The rest of the ladder after a named provider, shared by the real selection
and :func:`check_llm_readiness`, so ``fluid doctor`` names what a run uses.
"""
explicit = _infer_provider_from_explicit_keys(env)
if explicit:
return explicit
saved = _saved_provider_name()
if saved and not _passed_over(saved, allow_agents):
return saved
keyring_match = _infer_provider_from_keyring()
if keyring_match:
return keyring_match
Expand Down
Loading
Loading